1use std::{
29 cell::{Ref, RefCell},
30 fmt::Debug,
31 rc::Rc,
32 sync::LazyLock,
33 time::Duration,
34};
35
36use anyhow::Context;
37use indexmap::{IndexMap, IndexSet};
38use nautilus_common::{
39 cache::{Cache, refs::PositionRef},
40 clients::{DEFAULT_POSITION_RECONCILIATION_TOLERANCE, ExecutionClient},
41 clock::Clock,
42 config::ConfigResult,
43 enums::{LogColor, LogLevel},
44 live::dst,
45 log_info,
46 messages::{
47 ExecutionReport,
48 execution::{
49 QueryOrder, TradingCommand,
50 report::{
51 GenerateFillReports, GenerateOrderStatusReport, GenerateOrderStatusReports,
52 GeneratePositionStatusReports,
53 },
54 },
55 },
56 msgbus::{self, MessagingSwitchboard, switchboard},
57};
58use nautilus_core::{DurationNanos, UUID4, UnixNanos, datetime::mins_to_secs};
59use nautilus_execution::{
60 engine::ExecutionEngine,
61 reconciliation::{
62 RECONCILIATION_ORDER_TAG, ReconciliationResult as AdjustedReports,
63 calculate_reconciliation_price, create_inferred_fill_for_qty,
64 create_reconciliation_rejected, create_reconciliation_triggered,
65 fill_precedes_snapshot_reconciled_position,
66 generate_external_order_status_events_with_commission,
67 generate_reconciliation_order_pre_fill_events,
68 generate_reconciliation_order_snapshot_events_with_commission,
69 incremental_inferred_fill_price_and_liquidity, inferred_fill_price_and_liquidity,
70 position_prices_match, process_mass_status_for_reconciliation,
71 process_mass_status_for_reconciliation_without_synthetic_reports,
72 reconcile_order_report_with_commission, should_reconciliation_update,
73 },
74};
75use nautilus_model::{
76 enums::{AvgPxReconciliation, OmsType, OrderSide, OrderStatus},
77 events::{OrderEventAny, OrderFilled, OrderInitialized, OrderUpdated},
78 identifiers::{
79 AccountId, ClientId, ClientOrderId, InstrumentId, PositionId, StrategyId, TradeId,
80 TraderId, VenueOrderId,
81 },
82 instruments::{Instrument, InstrumentAny},
83 orders::{Order, OrderAny, TRIGGERABLE_ORDER_TYPES},
84 position::{Position, PositionReplayEvent},
85 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
86 types::{Money, Quantity},
87};
88use rust_decimal::Decimal;
89use ustr::Ustr;
90
91#[cfg(feature = "node")]
92pub(crate) use super::reconciliation::collect_targeted_order_reports;
93pub(crate) use super::reconciliation::{
94 OpenOrderReconciliationResult, OpenOrderReportCheck, SourcedOrderStatusReport,
95 TargetedOrderQuery, TargetedOrderReportResult, request_targeted_order_reports,
96 resolve_position_report_client_coverage,
97};
98pub use super::{
99 config::ExecutionManagerConfig,
100 reconciliation::{
101 ExternalOrderMetadata, InflightCheckResult, InstrumentAccountKey, PositionFillReportPlan,
102 PositionFillReportPreparation, PositionFillReportQuery, PositionReportCheck,
103 ReconciliationResult, ReportClientCoverage,
104 },
105};
106use super::{
107 recency::RecencyMap,
108 reconciliation::{
109 FillKey, InflightCheck, PositionQuantityComparison, PositionReconciliationState,
110 PositionReportShape, ReconciliationFillQueue, RetainedFillState,
111 create_orphan_fill_order_report, create_position_reconciliation_report,
112 create_reconciliation_canceled, distinct_position_reports, has_active_inferred_fill,
113 is_exact_order_match, position_avg_px, position_qty_aggregates,
114 resolve_inferred_fill_commission, responsible_client_ids, should_project_fill,
115 sort_reconciliation_events, terminal_report_has_missing_fills,
116 },
117 submission::{SubmissionRecoveryExhausted, SubmissionRecoveryPolicy, SubmissionRecoverySource},
118};
119
120static TAG_VENUE: LazyLock<Ustr> = LazyLock::new(|| Ustr::from("VENUE"));
122
123static TAG_RECONCILIATION: LazyLock<Ustr> = LazyLock::new(|| Ustr::from(RECONCILIATION_ORDER_TAG));
125
126const LOG_SAMPLES_MAX: usize = 5;
127
128#[derive(Clone, Debug)]
129struct SubmissionRecoveryState {
130 trader_id: TraderId,
131 client_id: Option<ClientId>,
132 strategy_id: StrategyId,
133 instrument_id: InstrumentId,
134 exhausted: bool,
135 venue_confirmed: bool,
136}
137
138#[derive(Clone)]
152pub struct ExecutionManager {
153 clock: Rc<RefCell<dyn Clock>>,
154 cache: Rc<RefCell<Cache>>,
155 config: ExecutionManagerConfig,
156
157 order_activity: RecencyMap<ClientOrderId>,
158 order_inflight_checks: IndexMap<ClientOrderId, InflightCheck>,
159 submissions: IndexMap<ClientOrderId, SubmissionRecoveryState>,
160 submission_exhaustions: Vec<SubmissionRecoveryExhausted>,
161 submission_retention: IndexMap<ClientId, AccountId>,
162 order_query_recency: RecencyMap<ClientOrderId>,
163 order_query_pending: IndexSet<ClientOrderId>,
164 order_recon_retries: IndexMap<ClientOrderId, u32>,
165 order_coverage_unresolved: IndexSet<ClientOrderId>,
166 order_coverage_warnings: IndexSet<ClientOrderId>,
167 order_lookback_warnings: IndexSet<ClientOrderId>,
168
169 fills_processed: RecencyMap<FillKey>,
170 fills_recent: RecencyMap<FillKey>,
171 fills_unapplied: RecencyMap<FillKey>,
172
173 position_activity: RecencyMap<InstrumentAccountKey>,
174 position_activity_revisions: IndexMap<InstrumentAccountKey, u64>,
175 position_recon: IndexMap<InstrumentAccountKey, PositionReconciliationState>,
176 position_recon_tolerances: IndexMap<AccountId, Decimal>,
177}
178
179impl Debug for ExecutionManager {
180 #[rustfmt::skip]
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 f.debug_struct(stringify!(ExecutionManager))
183 .field("clock", &self.clock)
184 .field("cache", &self.cache)
185 .field("config", &self.config)
186 .field("order_activity", &self.order_activity)
187 .field("order_inflight_checks", &self.order_inflight_checks)
188 .field("submissions", &self.submissions)
189 .field("submission_exhaustions", &self.submission_exhaustions)
190 .field("submission_retention", &self.submission_retention)
191 .field("order_query_recency", &self.order_query_recency)
192 .field("order_query_pending", &self.order_query_pending)
193 .field("order_recon_retries", &self.order_recon_retries)
194 .field("order_coverage_unresolved", &self.order_coverage_unresolved)
195 .field("order_coverage_warnings", &self.order_coverage_warnings)
196 .field("order_lookback_warnings", &self.order_lookback_warnings)
197 .field("fills_processed", &self.fills_processed)
198 .field("fills_recent", &self.fills_recent)
199 .field("fills_unapplied", &self.fills_unapplied)
200 .field("position_activity", &self.position_activity)
201 .field("position_activity_revisions", &self.position_activity_revisions)
202 .field("position_recon", &self.position_recon)
203 .field("position_recon_tolerances", &self.position_recon_tolerances)
204 .finish()
205 }
206}
207
208impl ExecutionManager {
209 pub fn new(
215 clock: Rc<RefCell<dyn Clock>>,
216 cache: Rc<RefCell<Cache>>,
217 config: ExecutionManagerConfig,
218 ) -> ConfigResult<Self> {
219 config.validate()?;
220
221 Ok(Self {
222 clock,
223 cache,
224 config,
225 order_activity: RecencyMap::default(),
226 order_inflight_checks: IndexMap::new(),
227 submissions: IndexMap::new(),
228 submission_exhaustions: Vec::new(),
229 submission_retention: IndexMap::new(),
230 order_query_recency: RecencyMap::default(),
231 order_query_pending: IndexSet::new(),
232 order_recon_retries: IndexMap::new(),
233 order_coverage_unresolved: IndexSet::new(),
234 order_coverage_warnings: IndexSet::new(),
235 order_lookback_warnings: IndexSet::new(),
236 fills_processed: RecencyMap::default(),
237 fills_recent: RecencyMap::default(),
238 fills_unapplied: RecencyMap::default(),
239 position_activity: RecencyMap::default(),
240 position_activity_revisions: IndexMap::new(),
241 position_recon: IndexMap::new(),
242 position_recon_tolerances: IndexMap::new(),
243 })
244 }
245
246 pub(crate) const fn config(&self) -> &ExecutionManagerConfig {
248 &self.config
249 }
250
251 pub(crate) fn timestamp_ns(&self) -> UnixNanos {
253 self.clock.borrow().timestamp_ns()
254 }
255
256 pub(crate) fn cache(&self) -> Ref<'_, Cache> {
258 self.cache.borrow()
259 }
260
261 pub fn register_inflight(&mut self, client_order_id: ClientOrderId) {
266 self.confirm_submission_outcome(&client_order_id);
267
268 if self.submission_recovery_pending(client_order_id)
269 && (self.order_inflight_checks.contains_key(&client_order_id)
270 || self.submission_is_retained(client_order_id))
271 {
272 return;
273 }
274
275 self.start_inflight_check(client_order_id);
276 }
277
278 pub fn register_submission(
284 &mut self,
285 initialized: &OrderInitialized,
286 client_id: Option<ClientId>,
287 ) {
288 let client_order_id = initialized.client_order_id;
289
290 if self.submissions.contains_key(&client_order_id)
291 || self.submission_retention_enabled(client_order_id, client_id)
292 {
293 self.confirm_submission_outcome(&client_order_id);
294
295 if self.submissions.contains_key(&client_order_id)
296 || self.submission_is_acknowledged(client_order_id)
297 {
298 return;
299 }
300
301 self.track_submission(initialized, client_id);
302 }
303
304 self.start_inflight_check(client_order_id);
305 }
306
307 fn start_inflight_check(&mut self, client_order_id: ClientOrderId) {
308 if self
309 .config
310 .filtered_client_order_ids
311 .contains(&client_order_id)
312 {
313 return;
314 }
315
316 self.order_inflight_checks.insert(
317 client_order_id,
318 InflightCheck {
319 submitted_at: dst::time::Instant::now(),
320 retry_count: 0,
321 last_query_at: None,
322 },
323 );
324
325 self.order_recon_retries.insert(client_order_id, 0);
326 self.order_query_recency.remove(&client_order_id);
327 self.order_activity.remove(&client_order_id);
328 }
329
330 fn track_submission(&mut self, initialized: &OrderInitialized, client_id: Option<ClientId>) {
331 if !self.submission_retention_enabled(initialized.client_order_id, client_id)
332 || self
333 .config
334 .filtered_client_order_ids
335 .contains(&initialized.client_order_id)
336 {
337 return;
338 }
339
340 self.submissions
341 .entry(initialized.client_order_id)
342 .or_insert(SubmissionRecoveryState {
343 trader_id: initialized.trader_id,
344 client_id,
345 strategy_id: initialized.strategy_id,
346 instrument_id: initialized.instrument_id,
347 exhausted: false,
348 venue_confirmed: false,
349 });
350 }
351
352 fn submission_is_unacknowledged(order: &OrderAny) -> bool {
353 !order.events().iter().any(|event| {
354 matches!(
355 event,
356 OrderEventAny::Accepted(_)
357 | OrderEventAny::Triggered(_)
358 | OrderEventAny::Filled(_)
359 | OrderEventAny::FillVoided(_)
360 | OrderEventAny::Rejected(_)
361 | OrderEventAny::Denied(_)
362 | OrderEventAny::Canceled(_)
363 | OrderEventAny::Expired(_)
364 ) || matches!(event, OrderEventAny::Updated(updated) if updated.venue_order_id.is_some())
365 })
366 }
367
368 fn submission_is_acknowledged(&self, client_order_id: ClientOrderId) -> bool {
369 self.cache
370 .borrow()
371 .order_ref(&client_order_id)
372 .is_some_and(|order| !Self::submission_is_unacknowledged(&order))
373 }
374
375 fn submission_recovery_pending(&self, client_order_id: ClientOrderId) -> bool {
376 self.submissions
377 .get(&client_order_id)
378 .is_some_and(|submission| !submission.venue_confirmed)
379 && !self.submission_is_acknowledged(client_order_id)
380 }
381
382 #[cfg(feature = "node")]
388 pub(crate) fn unresolved_submission_ids(&self) -> Vec<ClientOrderId> {
389 self.submissions
390 .keys()
391 .copied()
392 .filter(|client_order_id| self.submission_recovery_pending(*client_order_id))
393 .collect()
394 }
395
396 pub fn confirm_submission_outcome(&mut self, client_order_id: &ClientOrderId) {
401 let Some(submission) = self.submissions.get(client_order_id) else {
402 return;
403 };
404
405 let venue_confirmed = submission.venue_confirmed;
406
407 if !self.submission_is_acknowledged(*client_order_id) {
408 return;
409 }
410
411 if venue_confirmed {
412 self.submissions.shift_remove(client_order_id);
414 return;
415 }
416
417 self.clear_recon_tracking(client_order_id, true);
418 self.record_local_activity(*client_order_id);
419 }
420
421 fn exhaust_submission(
422 &mut self,
423 client_order_id: ClientOrderId,
424 source: SubmissionRecoverySource,
425 retry_count: u32,
426 ) -> bool {
427 if !self.submissions.contains_key(&client_order_id)
428 && !self.submission_retention_enabled(client_order_id, None)
429 {
430 return false;
431 }
432
433 let budget = match source {
434 SubmissionRecoverySource::Inflight => self.config.inflight_max_retries,
435 SubmissionRecoverySource::MissingOrder => self.config.open_check_missing_retries,
436 };
437
438 if retry_count < budget {
439 return false;
440 }
441
442 if let Some(order) = self.get_order(client_order_id) {
443 if !Self::submission_is_unacknowledged(&order) {
444 return false;
445 }
446
447 let client_id = self.cache.borrow().client_id(&client_order_id).copied();
448 self.track_submission(order.init_event(), client_id);
449 }
450
451 let Some(submission) = self.submissions.get_mut(&client_order_id) else {
452 return false;
453 };
454
455 if submission.venue_confirmed {
456 return false;
457 }
458
459 if !submission.exhausted {
460 submission.exhausted = true;
461 self.submission_exhaustions
462 .push(SubmissionRecoveryExhausted {
463 trader_id: submission.trader_id,
464 client_id: submission
465 .client_id
466 .or_else(|| self.cache.borrow().client_id(&client_order_id).copied()),
467 strategy_id: submission.strategy_id,
468 instrument_id: submission.instrument_id,
469 client_order_id,
470 source,
471 retry_count,
472 ts_event: self.clock.borrow().timestamp_ns(),
473 });
474
475 log::warn!(
476 "Submission recovery exhausted for {client_order_id}, retaining unacknowledged order pending venue evidence"
477 );
478 }
479
480 self.clear_recon_tracking(&client_order_id, true);
481 true
482 }
483
484 pub fn take_submission_recovery_exhaustions(&mut self) -> Vec<SubmissionRecoveryExhausted> {
491 std::mem::take(&mut self.submission_exhaustions)
492 }
493
494 pub fn record_local_activity(&mut self, client_order_id: ClientOrderId) {
501 self.order_activity.mark(client_order_id);
502 }
503
504 #[must_use]
508 pub fn recon_check_retry_count(&self, client_order_id: &ClientOrderId) -> u32 {
509 self.order_recon_retries
510 .get(client_order_id)
511 .copied()
512 .unwrap_or(0)
513 }
514
515 pub(crate) fn remove_targeted_order_queries(&mut self, client_order_ids: &[ClientOrderId]) {
517 for client_order_id in client_order_ids {
518 self.order_query_pending.shift_remove(client_order_id);
519 }
520 }
521
522 pub fn clear_recon_tracking(&mut self, client_order_id: &ClientOrderId, drop_last_query: bool) {
528 let pending = self.submission_recovery_pending(*client_order_id);
529 if pending && !self.submission_is_retained(*client_order_id) {
530 return;
531 }
532
533 self.order_inflight_checks.shift_remove(client_order_id);
534
535 let keep_confirmation = self
536 .submissions
537 .get(client_order_id)
538 .is_some_and(|submission| submission.venue_confirmed)
539 && self
540 .cache
541 .borrow()
542 .order_ref(client_order_id)
543 .is_some_and(|order| Self::submission_is_unacknowledged(&order));
544
545 if !pending && !keep_confirmation {
546 self.submissions.shift_remove(client_order_id);
547 }
548
549 self.order_recon_retries.shift_remove(client_order_id);
550 self.order_coverage_warnings.shift_remove(client_order_id);
551 self.order_lookback_warnings.shift_remove(client_order_id);
552 self.order_coverage_unresolved.shift_remove(client_order_id);
553 self.remove_targeted_order_queries(&[*client_order_id]);
554
555 if drop_last_query {
556 self.order_query_recency.remove(client_order_id);
557 }
558
559 self.order_activity.remove(client_order_id);
560 }
561
562 pub fn prune_order_local_activity(&mut self) {
564 self.order_activity
565 .prune_older_than(Duration::from(self.config.open_check_threshold_ns));
566 }
567
568 #[must_use]
570 pub fn is_fill_recently_processed(
571 &self,
572 account_id: AccountId,
573 instrument_id: InstrumentId,
574 trade_id: TradeId,
575 ) -> bool {
576 self.fills_recent
577 .contains_key(&(account_id, instrument_id, trade_id))
578 }
579
580 pub fn commit_recent_fill_if_applied(&mut self, fill: &OrderFilled) {
582 let fill_key = (fill.account_id, fill.instrument_id, fill.trade_id);
583 if self.is_fill_applied(fill, fill_key) {
584 self.mark_fill_processed(fill_key.0, fill_key.1, fill_key.2);
585 }
586 }
587
588 pub fn mark_fill_processed(
590 &mut self,
591 account_id: AccountId,
592 instrument_id: InstrumentId,
593 trade_id: TradeId,
594 ) {
595 self.fills_recent
596 .mark((account_id, instrument_id, trade_id));
597 }
598
599 pub fn prune_recent_fills_cache(&mut self, ttl_secs: f64) {
603 let ttl = match Duration::try_from_secs_f64(ttl_secs) {
611 Ok(ttl) => ttl,
612 Err(_) if ttl_secs > 0.0 => Duration::MAX,
613 Err(_) => Duration::ZERO,
614 };
615
616 self.fills_recent.prune_older_than(ttl);
617 }
618
619 pub fn prune_processed_fills(&mut self) {
624 let Some(lookback_mins) = self.config.lookback_mins else {
625 return;
626 };
627
628 let ttl = Duration::from_mins(lookback_mins).max(Duration::from_mins(1));
629 self.fills_processed.prune_older_than(ttl);
630 }
631
632 pub(crate) fn set_position_reconciliation_tolerance(
634 &mut self,
635 account_id: AccountId,
636 tolerance: Decimal,
637 ) {
638 let tolerance = if tolerance < Decimal::ZERO {
639 log::error!(
640 "Invalid negative position reconciliation tolerance {tolerance} for \
641 {account_id}; using the default"
642 );
643 DEFAULT_POSITION_RECONCILIATION_TOLERANCE
644 } else {
645 tolerance
646 };
647
648 self.position_recon_tolerances.insert(account_id, tolerance);
649 }
650
651 pub(crate) fn position_reconciliation_tolerance(&self, account_id: AccountId) -> Decimal {
653 self.position_recon_tolerances
654 .get(&account_id)
655 .copied()
656 .unwrap_or(DEFAULT_POSITION_RECONCILIATION_TOLERANCE)
657 }
658
659 pub fn record_position_activity(&mut self, instrument_id: InstrumentId, account_id: AccountId) {
665 let key = (instrument_id, account_id);
666 self.position_activity.mark(key);
667 let revision = self.position_activity_revisions.entry(key).or_default();
668 *revision = revision.saturating_add(1);
669 }
670
671 pub(crate) fn position_activity_is_recent(&self, key: &InstrumentAccountKey) -> bool {
673 self.position_activity
674 .within(key, Duration::from(self.config.position_check_threshold_ns))
675 }
676
677 pub(crate) fn position_activity_revision(&self, key: &InstrumentAccountKey) -> u64 {
679 self.position_activity_revisions
680 .get(key)
681 .copied()
682 .unwrap_or_default()
683 }
684
685 fn set_position_reconciliation_retries(
686 &mut self,
687 key: InstrumentAccountKey,
688 report_shape: PositionReportShape,
689 retries: u32,
690 ) {
691 self.position_recon.insert(
692 key,
693 PositionReconciliationState {
694 report_shape,
695 retries,
696 },
697 );
698 }
699
700 fn position_reconciliation_retries(
702 &self,
703 key: &InstrumentAccountKey,
704 report_shape: PositionReportShape,
705 ) -> u32 {
706 self.position_recon
707 .get(key)
708 .filter(|state| state.report_shape == report_shape)
709 .map_or(0, |state| state.retries)
710 }
711
712 #[must_use]
715 pub fn position_recon_retry_count(&self, key: &InstrumentAccountKey) -> u32 {
716 self.position_recon
717 .get(key)
718 .map_or(0, |state| state.retries)
719 }
720
721 pub(crate) fn clear_position_reconciliation(&mut self, key: &InstrumentAccountKey) {
723 self.position_recon.shift_remove(key);
724 }
725
726 pub(crate) fn retain_position_reconciliation(
728 &mut self,
729 active_keys: &IndexSet<InstrumentAccountKey>,
730 ) {
731 self.position_recon
732 .retain(|key, _| active_keys.contains(key));
733 }
734
735 pub fn reconcile_execution_mass_status(
741 &mut self,
742 mass_status: &ExecutionMassStatus,
743 exec_engine: &RefCell<ExecutionEngine>,
744 ) -> ReconciliationResult {
745 if exec_engine
746 .borrow()
747 .get_client(&mass_status.client_id)
748 .is_none()
749 {
750 log::error!(
751 "Cannot reconcile ExecutionMassStatus from unknown client {}",
752 mass_status.client_id
753 );
754 return ReconciliationResult::default();
755 }
756
757 self.validate_mass_status_order_sources(mass_status);
758
759 let raw_order_status_topic =
764 MessagingSwitchboard::reconciliation_raw_order_status_report_topic();
765
766 for report in mass_status.order_reports().values() {
767 msgbus::publish_any(raw_order_status_topic, report);
768 }
769
770 let raw_fill_topic = MessagingSwitchboard::reconciliation_raw_fill_report_topic();
771
772 for fills in mass_status.fill_reports().values() {
773 for fill in fills {
774 msgbus::publish_any(raw_fill_topic, fill);
775 }
776 }
777
778 let raw_position_topic =
779 MessagingSwitchboard::reconciliation_raw_position_status_report_topic();
780
781 for reports in mass_status.position_reports().values() {
782 for report in reports {
783 msgbus::publish_any(raw_position_topic, report);
784 }
785 }
786
787 if exec_engine
788 .borrow()
789 .get_client(&mass_status.client_id)
790 .is_none()
791 {
792 log::error!(
793 "Execution client {} disappeared while publishing raw mass status reports",
794 mass_status.client_id
795 );
796 return ReconciliationResult::default();
797 }
798
799 let venue = mass_status.venue;
800 let order_count = mass_status.order_reports().len();
801 let fill_count: usize = mass_status.fill_reports().values().map(Vec::len).sum();
802 let position_count: usize = mass_status.position_reports().values().map(Vec::len).sum();
803
804 log_info!(
805 "Reconciling ExecutionMassStatus for {venue}",
806 color = LogColor::Blue
807 );
808 log_info!(
809 "Received {order_count} order(s), {fill_count} fill(s), {position_count} position(s)",
810 color = LogColor::Blue
811 );
812
813 if mass_status.lookback_start().is_some() && !mass_status.reports_complete() {
814 log::warn!("Received incomplete bounded reconciliation reports for {venue}");
815 }
816
817 let retained_fill_state = self.retained_fill_state();
818 let reported_fill_keys: IndexSet<FillKey> = mass_status
819 .fill_reports()
820 .values()
821 .flatten()
822 .filter(|fill| !fill.last_qty.is_zero())
823 .map(|fill| (fill.account_id, fill.instrument_id, fill.trade_id))
824 .collect();
825
826 let order_venue_ids = exec_engine
827 .borrow()
828 .get_client(&mass_status.client_id)
829 .and_then(|client| client.venue_order_ids(mass_status));
830 let mut adjusted_reports = self.adjust_mass_status_fills(mass_status);
831 Self::preserve_external_order_history(
832 mass_status,
833 order_venue_ids.as_ref(),
834 &mut adjusted_reports,
835 );
836 let AdjustedReports {
837 orders: adjusted_order_reports,
838 fills: adjusted_fill_reports,
839 mut order_only_ids,
840 order_only_fill_keys,
841 } = adjusted_reports;
842
843 order_only_ids.extend(self.order_only_ids(
844 mass_status,
845 &adjusted_order_reports,
846 &adjusted_fill_reports,
847 ));
848
849 let mut events = Vec::new();
850 let mut external_orders = Vec::new();
851 let mut orders_reconciled = 0usize;
852 let mut external_orders_created = 0usize;
853 let mut open_orders_initialized = 0usize;
854 let mut orders_skipped_no_instrument = 0usize;
855 let mut orders_skipped_duplicate = 0usize;
856 let mut fills_applied = 0usize;
857 let mut fill_queue = ReconciliationFillQueue::default();
858
859 let fill_reports = &adjusted_fill_reports;
860 let mut seen_fill_keys: IndexSet<FillKey> = IndexSet::new();
861
862 for fills in fill_reports.values() {
863 for fill in fills {
864 let fill_key = (fill.account_id, fill.instrument_id, fill.trade_id);
865 if !seen_fill_keys.insert(fill_key) {
866 log::warn!(
867 "Duplicate trade_id {} for {} in mass status",
868 fill.trade_id,
869 fill.instrument_id
870 );
871 }
872 }
873 }
874
875 let order_reports = &adjusted_order_reports;
876 let mut orders_skipped_filtered = 0usize;
877 let mut orders_projected: IndexMap<ClientOrderId, OrderAny> = IndexMap::new();
878
879 for report in order_reports.values().filter(|report| {
880 order_venue_ids
881 .as_ref()
882 .and_then(|histories| report.client_order_id.and_then(|id| histories.get(&id)))
883 .is_none_or(|ids| ids.last() == Some(&report.venue_order_id))
884 }) {
885 if self.should_skip_order_report(report) {
886 orders_skipped_filtered += 1;
887 continue;
888 }
889
890 let order = report
891 .client_order_id
892 .and_then(|id| {
893 orders_projected
894 .get(&id)
895 .cloned()
896 .or_else(|| self.get_order(id))
897 })
898 .or_else(|| {
899 self.get_order_by_venue_order_id(report.venue_order_id)
900 .map(|order| {
901 orders_projected
902 .get(&order.client_order_id())
903 .cloned()
904 .unwrap_or(order)
905 })
906 });
907 let order_client = order
908 .as_ref()
909 .filter(|order| Some(order.client_order_id()) == report.client_order_id);
910 let events_start = events.len();
911
912 if let Some(client_order_id) = &report.client_order_id {
913 if let Some(cached_order) = order_client
914 && is_exact_order_match(cached_order, report)
915 {
916 log::debug!("Skipping order {client_order_id}: already in sync with venue");
917 orders_skipped_duplicate += 1;
918
919 if let Err(e) = self
921 .cache
922 .borrow_mut()
923 .index_venue_order_id(client_order_id, &report.venue_order_id)
924 {
925 log::warn!("Failed to index venue order ID: {e}");
926 }
927
928 continue;
929 }
930
931 if let Some(cached_order) = order_client
933 && cached_order.is_closed()
934 && cached_order
935 .tags()
936 .is_some_and(|tags| tags.contains(&*TAG_RECONCILIATION))
937 {
938 log::debug!(
939 "Skipping closed reconciliation order {client_order_id}: \
940 synthetic position adjustment from previous session",
941 );
942 orders_skipped_duplicate += 1;
943 continue;
944 }
945 }
946
947 if let Some(order) = order.as_ref() {
948 let instrument = self.get_instrument(&report.instrument_id);
949
950 if order_client.is_some() {
951 log::info!(
952 color = LogColor::Blue as u8;
953 "Reconciling {} {} {} [{}] -> [{}]",
954 order.client_order_id(),
955 report.venue_order_id,
956 report.instrument_id,
957 order.status(),
958 report.order_status,
959 );
960 } else {
961 log::info!(
963 color = LogColor::Blue as u8;
964 "Reconciling {} (matched by venue_order_id {}) {} [{}] -> [{}]",
965 order.client_order_id(),
966 report.venue_order_id,
967 report.instrument_id,
968 order.status(),
969 report.order_status,
970 );
971 }
972
973 let order_fills: Vec<&FillReport> = fill_reports
974 .get(&report.venue_order_id)
975 .map(|f| f.iter().collect())
976 .unwrap_or_default();
977 let engine_ref = exec_engine.borrow();
978 let commission_client = engine_ref.get_client(&mass_status.client_id);
979
980 let order_events = self.reconcile_order_with_fills(
981 true,
982 order,
983 report,
984 &order_fills,
985 instrument.as_ref(),
986 &mut fill_queue,
987 commission_client,
988 );
989
990 drop(engine_ref);
991
992 orders_reconciled += usize::from(!order_events.is_empty());
993 fills_applied += order_events
994 .iter()
995 .filter(|e| matches!(e, OrderEventAny::Filled(_)))
996 .count();
997 events.extend(order_events);
998
999 if let Err(e) = self
1001 .cache
1002 .borrow_mut()
1003 .index_venue_order_id(&order.client_order_id(), &report.venue_order_id)
1004 {
1005 log::warn!("Failed to index venue order ID: {e}");
1006 }
1007 } else if let Some(client_order_id) = &report.client_order_id {
1008 if let Some(instrument) = self.get_instrument(&report.instrument_id) {
1009 let venue_order_ids = order_venue_ids
1010 .as_ref()
1011 .and_then(|histories| histories.get(client_order_id));
1012 let order_fills: Vec<&FillReport> = venue_order_ids
1013 .map_or(std::slice::from_ref(&report.venue_order_id), Vec::as_slice)
1014 .iter()
1015 .filter_map(|id| fill_reports.get(id))
1016 .flatten()
1017 .collect();
1018 let engine_ref = exec_engine.borrow();
1019 let commission_client = engine_ref.get_client(&mass_status.client_id);
1020
1021 let (mut external_events, metadata) = self.handle_external_order(
1022 report,
1023 mass_status.account_id,
1024 &instrument,
1025 &order_fills,
1026 false, Some(&mut fill_queue),
1028 commission_client,
1029 );
1030
1031 drop(engine_ref);
1032
1033 self.restore_external_order_venue_ids(
1034 report,
1035 venue_order_ids.map(Vec::as_slice),
1036 &mut external_events,
1037 );
1038
1039 if !external_events.is_empty() {
1040 external_orders_created += 1;
1041 fills_applied += external_events
1042 .iter()
1043 .filter(|e| matches!(e, OrderEventAny::Filled(_)))
1044 .count();
1045
1046 if report.order_status.is_open() {
1047 open_orders_initialized += 1;
1048 }
1049
1050 events.extend(external_events);
1051
1052 if let Some(m) = metadata {
1053 external_orders.push(m);
1054 }
1055 }
1056 } else {
1057 orders_skipped_no_instrument += 1;
1058 }
1059 } else if let Some(instrument) = self.get_instrument(&report.instrument_id) {
1060 let is_synthetic = report.venue_order_id.as_str().starts_with("S-");
1062
1063 let order_fills: Vec<&FillReport> = fill_reports
1064 .get(&report.venue_order_id)
1065 .map(|f| f.iter().collect())
1066 .unwrap_or_default();
1067 let engine_ref = exec_engine.borrow();
1068 let commission_client = engine_ref.get_client(&mass_status.client_id);
1069
1070 let (external_events, metadata) = self.handle_external_order(
1071 report,
1072 mass_status.account_id,
1073 &instrument,
1074 &order_fills,
1075 is_synthetic,
1076 Some(&mut fill_queue),
1077 commission_client,
1078 );
1079
1080 drop(engine_ref);
1081
1082 if !external_events.is_empty() {
1083 external_orders_created += 1;
1084 fills_applied += external_events
1085 .iter()
1086 .filter(|e| matches!(e, OrderEventAny::Filled(_)))
1087 .count();
1088
1089 if report.order_status.is_open() {
1090 open_orders_initialized += 1;
1091 }
1092
1093 events.extend(external_events);
1094
1095 if let Some(m) = metadata {
1096 external_orders.push(m);
1097 }
1098 }
1099 } else {
1100 orders_skipped_no_instrument += 1;
1101 }
1102
1103 if events.len() > events_start
1104 && let Some(mut order) = order
1105 {
1106 for event in &events[events_start..] {
1107 if event.client_order_id() == order.client_order_id()
1108 && let Err(e) = order.apply(event.clone())
1109 {
1110 log::warn!("Cannot project queued reconciliation event: {e}");
1111 }
1112 }
1113 orders_projected.insert(order.client_order_id(), order);
1114 }
1115 }
1116
1117 let mut dropped_fill_groups: Vec<(VenueOrderId, InstrumentId, usize)> = Vec::new();
1119
1120 for (venue_order_id, fills) in fill_reports {
1121 if order_reports.contains_key(venue_order_id) {
1122 continue;
1123 }
1124
1125 let Some(first_fill) = fills.first() else {
1126 continue;
1127 };
1128
1129 if !self.should_reconcile_instrument(&first_fill.instrument_id) {
1130 log::debug!(
1131 "Skipping orphan fills for {}: not in reconciliation_instrument_ids",
1132 first_fill.instrument_id
1133 );
1134 continue;
1135 }
1136
1137 if let Some(client_order_id) = &first_fill.client_order_id
1139 && self
1140 .config
1141 .filtered_client_order_ids
1142 .contains(client_order_id)
1143 {
1144 log::debug!(
1145 "Skipping orphan fills for {client_order_id}: in filtered_client_order_ids"
1146 );
1147 continue;
1148 }
1149
1150 let order = first_fill
1151 .client_order_id
1152 .as_ref()
1153 .and_then(|id| self.get_order(*id))
1154 .or_else(|| self.get_order_by_venue_order_id(*venue_order_id));
1155
1156 if let Some(ref order) = order
1158 && self
1159 .config
1160 .filtered_client_order_ids
1161 .contains(&order.client_order_id())
1162 {
1163 log::debug!(
1164 "Skipping orphan fills for {}: in filtered_client_order_ids",
1165 order.client_order_id()
1166 );
1167 continue;
1168 }
1169
1170 if let Some(order) = order {
1171 let instrument_id = order.instrument_id();
1172 if let Some(instrument) = self.get_instrument(&instrument_id) {
1173 let mut sorted_fills: Vec<&FillReport> = fills.iter().collect();
1174 sorted_fills.sort_by_key(|f| f.ts_event);
1175
1176 for fill in sorted_fills {
1177 if let Some((event, fill_key)) = self.create_order_fill(
1178 &order,
1179 fill,
1180 &instrument,
1181 &fill_queue.pending_fill_keys,
1182 ) {
1183 fills_applied += 1;
1184 fill_queue.push(&mut events, event, fill_key);
1185 }
1186 }
1187 } else {
1188 orders_skipped_no_instrument += 1;
1189 }
1190 } else if fills.iter().any(FillReport::has_venue_position_id) {
1191 if !self.config.generate_missing_orders {
1192 log::debug!(
1193 "Skipping orphan fills for venue order {venue_order_id}: \
1194 `generate_missing_orders` is disabled"
1195 );
1196 orders_skipped_filtered += 1;
1197 continue;
1198 }
1199
1200 let Some(instrument) = self.get_instrument(&first_fill.instrument_id) else {
1201 orders_skipped_no_instrument += 1;
1202 continue;
1203 };
1204
1205 let mut sorted_fills: Vec<&FillReport> = fills.iter().collect();
1206 sorted_fills.sort_by_key(|fill| fill.ts_event);
1207
1208 let report = match create_orphan_fill_order_report(&sorted_fills, &instrument) {
1209 Ok(report) => report,
1210 Err(e) => {
1211 log::error!(
1212 "Cannot materialize orphan fills for venue order {venue_order_id}: {e}"
1213 );
1214
1215 continue;
1216 }
1217 };
1218
1219 let engine_ref = exec_engine.borrow();
1220 let commission_client = engine_ref.get_client(&mass_status.client_id);
1221
1222 let (external_events, metadata) = self.handle_external_order(
1223 &report,
1224 mass_status.account_id,
1225 &instrument,
1226 &sorted_fills,
1227 false,
1228 Some(&mut fill_queue),
1229 commission_client,
1230 );
1231
1232 drop(engine_ref);
1233
1234 if !external_events.is_empty() {
1235 external_orders_created += 1;
1236 fills_applied += external_events
1237 .iter()
1238 .filter(|event| matches!(event, OrderEventAny::Filled(_)))
1239 .count();
1240
1241 events.extend(external_events);
1242
1243 if let Some(metadata) = metadata {
1244 external_orders.push(metadata);
1245 }
1246 }
1247 } else {
1248 log::debug!(
1249 "Dropping {} fill(s) for venue order {venue_order_id} ({}): \
1250 no order report or cached order",
1251 fills.len(),
1252 first_fill.instrument_id,
1253 );
1254 dropped_fill_groups.push((*venue_order_id, first_fill.instrument_id, fills.len()));
1255 }
1256 }
1257
1258 sort_reconciliation_events(&mut events);
1259
1260 let mut unapplied_fill_position_ids = IndexSet::new();
1261
1262 for event in &events {
1263 if let OrderEventAny::Filled(fill) = event
1264 && should_project_fill(
1265 fill,
1266 &retained_fill_state,
1267 &reported_fill_keys,
1268 &order_only_ids,
1269 &order_only_fill_keys,
1270 )
1271 {
1272 exec_engine.borrow_mut().project_reconciliation_fill(fill);
1273 } else {
1274 exec_engine.borrow_mut().process(event);
1275 }
1276
1277 if let OrderEventAny::Filled(fill) = event
1278 && let Some(fill_key) = fill_queue.event_fill_keys.get(&fill.event_id).copied()
1279 {
1280 if self.is_fill_applied(fill, fill_key) {
1281 self.fills_processed.mark(fill_key);
1282 } else if let Some(venue_position_id) = fill.position_id {
1283 log::error!(
1284 "Skipping reconciliation for venue position {venue_position_id}: historical fill {} was not applied",
1285 fill.trade_id,
1286 );
1287
1288 unapplied_fill_position_ids.insert(venue_position_id);
1289 }
1290 }
1291 }
1292
1293 let mut positions_created = 0usize;
1294
1295 if !self.config.filter_position_reports {
1296 let instruments_with_unattributed_fills: IndexSet<InstrumentId> = mass_status
1299 .fill_reports()
1300 .values()
1301 .flatten()
1302 .filter(|f| !f.last_qty.is_zero() && f.venue_position_id.is_none())
1303 .map(|f| f.instrument_id)
1304 .chain(
1305 mass_status
1306 .order_reports()
1307 .values()
1308 .filter(|r| !r.filled_qty.is_zero() && r.venue_position_id.is_none())
1309 .map(|r| r.instrument_id),
1310 )
1311 .collect();
1312
1313 for (instrument_id, reports) in mass_status.position_reports() {
1314 if !self.should_reconcile_instrument(&instrument_id) {
1315 log::debug!(
1316 "Skipping position reports for {instrument_id}: not in reconciliation_instrument_ids"
1317 );
1318 continue;
1319 }
1320
1321 for report in reports {
1322 if report.venue_position_id.is_some_and(|venue_position_id| {
1323 unapplied_fill_position_ids.contains(&venue_position_id)
1324 }) {
1325 continue;
1326 }
1327
1328 if let Some(position_events) = self.reconcile_position_report(
1329 &report,
1330 mass_status.account_id,
1331 &instruments_with_unattributed_fills,
1332 ) {
1333 for event in position_events {
1334 exec_engine.borrow_mut().process(&event);
1335 events.push(event);
1336 }
1337
1338 positions_created += 1;
1339 }
1340 }
1341 }
1342 }
1343
1344 if orders_skipped_no_instrument > 0 {
1345 log::warn!("{orders_skipped_no_instrument} orders skipped (instrument not in cache)");
1346 }
1347
1348 if !dropped_fill_groups.is_empty() {
1349 let dropped_fill_count: usize =
1350 dropped_fill_groups.iter().map(|(_, _, count)| count).sum();
1351
1352 let samples = dropped_fill_groups
1353 .iter()
1354 .take(LOG_SAMPLES_MAX)
1355 .map(|(venue_order_id, instrument_id, _)| {
1356 format!("{venue_order_id} ({instrument_id})")
1357 })
1358 .collect::<Vec<_>>()
1359 .join(", ");
1360
1361 log::warn!(
1362 "Dropped {dropped_fill_count} fill(s) in {} fill group(s) without an order report \
1363 or cached order ({samples})",
1364 dropped_fill_groups.len(),
1365 );
1366 }
1367
1368 if orders_skipped_duplicate > 0 {
1369 log::debug!("{orders_skipped_duplicate} orders skipped (already in sync)");
1370 }
1371
1372 if orders_skipped_filtered > 0 {
1373 log::debug!("{orders_skipped_filtered} orders skipped (filtered by config)");
1374 }
1375
1376 log::info!(
1377 color = LogColor::Blue as u8;
1378 "Reconciliation complete for {venue}: reconciled={orders_reconciled}, external={external_orders_created}, open={open_orders_initialized}, fills={fills_applied}, positions={positions_created}, skipped={orders_skipped_duplicate}, filtered={orders_skipped_filtered}",
1379 );
1380
1381 ReconciliationResult {
1382 events,
1383 external_orders,
1384 unresolved_positions: self.unresolved_mass_status_positions(mass_status),
1385 }
1386 }
1387
1388 fn preserve_external_order_history(
1389 mass_status: &ExecutionMassStatus,
1390 order_venue_ids: Option<&IndexMap<ClientOrderId, Vec<VenueOrderId>>>,
1391 adjusted: &mut AdjustedReports,
1392 ) {
1393 let Some(order_venue_ids) = order_venue_ids else {
1394 return;
1395 };
1396
1397 let reports = mass_status.order_reports();
1398 let fills = mass_status.fill_reports();
1399
1400 for id in order_venue_ids.values().flatten() {
1401 if let Some(report) = reports.get(id) {
1402 adjusted.orders.entry(*id).or_insert_with(|| report.clone());
1403 }
1404
1405 let retained = adjusted.fills.entry(*id).or_default();
1406 for fill in fills.get(id).into_iter().flatten() {
1407 if !fill.last_qty.is_zero()
1408 && !retained
1409 .iter()
1410 .any(|existing| existing.trade_id == fill.trade_id)
1411 {
1412 adjusted.order_only_fill_keys.insert((
1414 fill.account_id,
1415 fill.instrument_id,
1416 fill.trade_id,
1417 ));
1418 retained.push(fill.clone());
1419 }
1420 }
1421 }
1422 }
1423
1424 fn restore_external_order_venue_ids(
1425 &self,
1426 report: &OrderStatusReport,
1427 venue_order_ids: Option<&[VenueOrderId]>,
1428 events: &mut Vec<OrderEventAny>,
1429 ) {
1430 let Some((first, remaining)) = venue_order_ids.and_then(|ids| ids.split_first()) else {
1431 return;
1432 };
1433
1434 let Some(OrderEventAny::Accepted(mut accepted)) = events.first().cloned() else {
1435 return;
1436 };
1437
1438 let Some(order) = report.client_order_id.and_then(|id| self.get_order(id)) else {
1439 return;
1440 };
1441
1442 accepted.venue_order_id = *first;
1443 let mut lifecycle = vec![OrderEventAny::Accepted(accepted)];
1444
1445 for venue_order_id in remaining {
1447 lifecycle.push(OrderEventAny::Updated(OrderUpdated::new(
1448 order.trader_id(),
1449 order.strategy_id(),
1450 order.instrument_id(),
1451 order.client_order_id(),
1452 order.quantity(),
1453 UUID4::new(),
1454 report.ts_accepted,
1455 self.clock.borrow().timestamp_ns(),
1456 true,
1457 Some(*venue_order_id),
1458 Some(accepted.account_id),
1459 order.price(),
1460 order.trigger_price(),
1461 None,
1462 order.is_quote_quantity(),
1463 )));
1464 }
1465
1466 if let Err(e) =
1467 self.cache
1468 .borrow_mut()
1469 .add_venue_order_id(&order.client_order_id(), first, true)
1470 {
1471 log::error!("Cannot initialize venue order ID history: {e}");
1472 return;
1473 }
1474
1475 events.splice(..1, lifecycle);
1476 }
1477
1478 fn unresolved_mass_status_positions(&self, mass_status: &ExecutionMassStatus) -> Vec<String> {
1479 if self.config.filter_position_reports {
1480 return Vec::new();
1481 }
1482
1483 let mut unresolved = Vec::new();
1484
1485 for (instrument_id, reports) in mass_status.position_reports() {
1486 if !self.should_reconcile_instrument(&instrument_id) {
1487 continue;
1488 }
1489
1490 let reports = distinct_position_reports(reports);
1491
1492 if self
1493 .netting_position_reports_match((instrument_id, mass_status.account_id), &reports)
1494 {
1495 continue;
1496 }
1497
1498 for report in &reports {
1499 if let Some(reason) =
1500 self.unresolved_position_report(report, mass_status.account_id, &reports)
1501 {
1502 unresolved.push(reason);
1503 }
1504 }
1505 }
1506
1507 unresolved
1508 }
1509
1510 fn netting_position_reports_match(
1511 &self,
1512 key: InstrumentAccountKey,
1513 reports: &[PositionStatusReport],
1514 ) -> bool {
1515 let comparison = self.position_quantity_comparison(key, reports);
1516 let cache = self.cache.borrow();
1517
1518 !comparison.cached_positions.is_empty()
1519 && comparison
1520 .cached_positions
1521 .iter()
1522 .all(|position| cache.oms_type(&position.id) == Some(OmsType::Netting))
1523 && comparison.quantities_match(self.position_reconciliation_tolerance(key.1))
1524 && self.position_report_prices_match(&comparison.cached_positions, reports)
1525 }
1526
1527 fn unresolved_position_report(
1528 &self,
1529 report: &PositionStatusReport,
1530 account_id: AccountId,
1531 reports: &[PositionStatusReport],
1532 ) -> Option<String> {
1533 let cache = self.cache.borrow();
1534 let instrument_id = report.instrument_id;
1535
1536 let open_positions =
1537 cache.positions_open(None, Some(&instrument_id), None, Some(&account_id), None);
1538 let is_netting = !open_positions.is_empty()
1539 && open_positions
1540 .iter()
1541 .all(|position| cache.oms_type(&position.id) == Some(OmsType::Netting));
1542
1543 let venue_position_id = report.venue_position_id.filter(|_| !is_netting);
1544 let venue_qty = report.signed_decimal_qty;
1545
1546 let cached_positions: Vec<Position> = if let Some(position_id) = venue_position_id {
1547 cache
1548 .position(&position_id)
1549 .filter(|p| p.account_id == account_id && p.instrument_id == instrument_id)
1550 .into_iter()
1551 .map(|p| (*p).clone())
1552 .collect()
1553 } else {
1554 open_positions.into_iter().map(|p| (*p).clone()).collect()
1555 };
1556
1557 let cached_qty: Decimal = cached_positions
1558 .iter()
1559 .map(Position::signed_decimal_qty)
1560 .sum();
1561
1562 let quantities_match = if is_netting {
1563 self.position_quantity_comparison((instrument_id, account_id), reports)
1564 .quantities_match(self.position_reconciliation_tolerance(account_id))
1565 } else if venue_position_id.is_some() {
1566 cached_qty == venue_qty
1567 } else {
1568 (cached_qty - venue_qty).abs() <= self.position_reconciliation_tolerance(account_id)
1569 };
1570
1571 let price_reports = if is_netting {
1572 reports
1573 } else {
1574 std::slice::from_ref(report)
1575 };
1576
1577 let prices_match = self.position_report_prices_match(&cached_positions, price_reports);
1578
1579 if quantities_match && prices_match {
1580 return None;
1581 }
1582
1583 let reason = if cache.instrument(&instrument_id).is_none() {
1584 "instrument missing from cache"
1585 } else if cache.account(&account_id).is_none() {
1586 "account missing from cache"
1587 } else if !self.config.generate_missing_orders {
1588 "generate_missing_orders is disabled"
1589 } else if report.avg_px_open.is_none() && cached_qty == Decimal::ZERO {
1590 "missing avg_px_open for position recovery"
1591 } else if quantities_match {
1592 "position recovery did not restore the reported average entry price"
1593 } else {
1594 "position recovery did not restore the reported quantity"
1595 };
1596
1597 Some(format!(
1598 "account={account_id}, instrument={instrument_id}, venue_position_id={:?}, venue_quantity={venue_qty}: {reason}",
1599 report.venue_position_id,
1600 ))
1601 }
1602
1603 fn position_report_prices_match(
1604 &self,
1605 cached_positions: &[Position],
1606 reports: &[PositionStatusReport],
1607 ) -> bool {
1608 for negative in [false, true] {
1609 let reports: Vec<_> = reports
1610 .iter()
1611 .filter(|report| {
1612 !report.signed_decimal_qty.is_zero()
1613 && report.signed_decimal_qty.is_sign_negative() == negative
1614 })
1615 .collect();
1616
1617 if !reports.iter().any(|report| report.avg_px_open.is_some())
1618 || reports.iter().any(|report| {
1619 report.avg_px_open_reconciliation == AvgPxReconciliation::OpeningOnly
1620 })
1621 {
1622 continue;
1623 }
1624
1625 let Some((venue_value, venue_qty)) = reports.iter().try_fold(
1626 (Decimal::ZERO, Decimal::ZERO),
1627 |(value, quantity), report| {
1628 let price = report.avg_px_open.filter(|price| *price > Decimal::ZERO)?;
1629 let qty = report.signed_decimal_qty.abs();
1630 Some((value + price * qty, quantity + qty))
1631 },
1632 ) else {
1633 return false;
1634 };
1635
1636 let positions: Vec<&Position> = cached_positions
1637 .iter()
1638 .filter(|position| {
1639 !position.quantity.is_zero()
1640 && position.signed_decimal_qty().is_sign_negative() == negative
1641 })
1642 .collect();
1643
1644 if positions
1645 .iter()
1646 .any(|position| position.avg_px_open <= Decimal::ZERO)
1647 {
1648 return false;
1649 }
1650
1651 if positions.is_empty()
1652 && venue_qty <= self.position_reconciliation_tolerance(reports[0].account_id)
1653 {
1654 continue;
1655 }
1656
1657 let Some(cached_avg_px) = position_avg_px(positions) else {
1658 return false;
1659 };
1660
1661 let venue_precision = reports
1663 .iter()
1664 .filter_map(|report| report.avg_px_open_precision)
1665 .min();
1666
1667 if !position_prices_match(
1668 cached_avg_px,
1669 venue_value / venue_qty,
1670 None,
1671 venue_precision,
1672 ) {
1673 return false;
1674 }
1675 }
1676
1677 true
1678 }
1679
1680 fn retained_fill_state(&self) -> RetainedFillState {
1681 let cache = self.cache.borrow();
1682 let positions = cache.positions(None, None, None, None, None);
1683 let mut fill_keys = IndexSet::new();
1684 let mut missing_order_ids = IndexSet::new();
1685 let mut missing_venue_order_ids = IndexSet::new();
1686 let mut netting_lifecycle_starts = IndexMap::new();
1687
1688 for position in positions {
1689 for fill in &position.events {
1690 fill_keys.insert((position.account_id, position.instrument_id, fill.trade_id));
1691 if cache.order(&fill.client_order_id).is_none() {
1692 missing_order_ids.insert((
1693 position.account_id,
1694 position.instrument_id,
1695 fill.client_order_id,
1696 ));
1697 missing_venue_order_ids.insert((
1698 position.account_id,
1699 position.instrument_id,
1700 fill.venue_order_id,
1701 ));
1702 }
1703 }
1704
1705 if cache.oms_type(&position.id) == Some(OmsType::Netting) {
1706 netting_lifecycle_starts.insert(
1707 (
1708 position.account_id,
1709 position.instrument_id,
1710 position.strategy_id,
1711 ),
1712 position.ts_opened,
1713 );
1714 }
1715 }
1716
1717 RetainedFillState {
1718 fill_keys,
1719 missing_order_ids,
1720 missing_venue_order_ids,
1721 netting_lifecycle_starts,
1722 }
1723 }
1724
1725 fn order_only_ids(
1726 &self,
1727 mass_status: &ExecutionMassStatus,
1728 order_reports: &IndexMap<VenueOrderId, OrderStatusReport>,
1729 fill_reports: &IndexMap<VenueOrderId, Vec<FillReport>>,
1730 ) -> IndexSet<VenueOrderId> {
1731 if mass_status.lookback_start().is_none() {
1732 return IndexSet::new();
1733 }
1734
1735 let reported_instruments: IndexSet<InstrumentId> = if self.config.filter_position_reports {
1736 IndexSet::new()
1737 } else {
1738 mass_status
1739 .position_reports()
1740 .into_iter()
1741 .filter(|(instrument_id, reports)| {
1742 !reports.is_empty() && self.should_reconcile_instrument(instrument_id)
1743 })
1744 .map(|(instrument_id, _)| instrument_id)
1745 .collect()
1746 };
1747
1748 let mut venue_order_ids = IndexSet::new();
1749
1750 for (venue_order_id, report) in order_reports {
1751 if report.filled_qty.is_zero() || reported_instruments.contains(&report.instrument_id) {
1752 continue;
1753 }
1754
1755 venue_order_ids.insert(*venue_order_id);
1756 }
1757
1758 for (venue_order_id, fills) in fill_reports {
1759 let Some(fill) = fills.first() else {
1760 continue;
1761 };
1762
1763 if reported_instruments.contains(&fill.instrument_id) {
1764 continue;
1765 }
1766
1767 venue_order_ids.insert(*venue_order_id);
1768 }
1769
1770 venue_order_ids
1771 }
1772
1773 fn validate_mass_status_order_sources(&self, mass_status: &ExecutionMassStatus) {
1777 let cache = self.cache.borrow();
1778 let mut checked_client_order_ids = IndexSet::new();
1779 let mut missing_origins: Vec<ClientOrderId> = Vec::new();
1780 let mut mismatched_origins: Vec<(ClientOrderId, ClientId)> = Vec::new();
1781
1782 let mut validate_report_source =
1783 |direct_client_order_id: Option<ClientOrderId>, venue_order_id: VenueOrderId| {
1784 let direct_client_order_id = direct_client_order_id
1785 .filter(|client_order_id| cache.order_exists(client_order_id));
1786 let indexed_client_order_id = cache
1787 .client_order_id(&venue_order_id)
1788 .copied()
1789 .filter(|client_order_id| cache.order_exists(client_order_id));
1790
1791 for client_order_id in [direct_client_order_id, indexed_client_order_id]
1792 .into_iter()
1793 .flatten()
1794 .filter(|client_order_id| checked_client_order_ids.insert(*client_order_id))
1795 {
1796 match cache.client_id(&client_order_id) {
1797 Some(cached_client_id) if *cached_client_id == mass_status.client_id => {}
1798 Some(cached_client_id) => {
1799 mismatched_origins.push((client_order_id, *cached_client_id));
1800 }
1801 None => missing_origins.push(client_order_id),
1802 }
1803 }
1804 };
1805
1806 for report in mass_status.order_reports().values() {
1807 validate_report_source(report.client_order_id, report.venue_order_id);
1808 }
1809
1810 for fills in mass_status.fill_reports().values() {
1811 for fill in fills {
1812 validate_report_source(fill.client_order_id, fill.venue_order_id);
1813 }
1814 }
1815
1816 if !missing_origins.is_empty() {
1817 let samples = missing_origins
1818 .iter()
1819 .take(LOG_SAMPLES_MAX)
1820 .map(ToString::to_string)
1821 .collect::<Vec<_>>()
1822 .join(", ");
1823
1824 log::warn!(
1825 "Found {} cached order(s) without an execution client origin ({}): \
1826 continuing reconciliation against mass status client {} for compatibility \
1827 with existing cache data",
1828 missing_origins.len(),
1829 samples,
1830 mass_status.client_id,
1831 );
1832 }
1833
1834 if !mismatched_origins.is_empty() {
1835 let samples = mismatched_origins
1836 .iter()
1837 .take(LOG_SAMPLES_MAX)
1838 .map(|(client_order_id, cached)| format!("{client_order_id} -> {cached}"))
1839 .collect::<Vec<_>>()
1840 .join(", ");
1841
1842 log::warn!(
1843 "Found {} cached order(s) with an execution client origin conflicting with \
1844 mass status client {} ({}): continuing reconciliation for compatibility; \
1845 this conflict will become a startup error in a future release, verify cached \
1846 order ownership and execution client configuration",
1847 mismatched_origins.len(),
1848 mass_status.client_id,
1849 samples,
1850 );
1851 }
1852 }
1853
1854 pub fn check_inflight_orders(&mut self) -> InflightCheckResult {
1861 let mut result = InflightCheckResult::default();
1862 let now = dst::time::Instant::now();
1863 let threshold = Duration::from_millis(self.config().inflight_threshold_ms);
1864
1865 let mut to_check = Vec::new();
1866
1867 for (client_order_id, check) in &self.order_inflight_checks {
1868 if now
1869 .checked_duration_since(check.submitted_at)
1870 .is_some_and(|elapsed| elapsed > threshold)
1871 {
1872 to_check.push(*client_order_id);
1873 }
1874 }
1875
1876 for client_order_id in to_check {
1877 self.confirm_submission_outcome(&client_order_id);
1878
1879 if self
1880 .config
1881 .filtered_client_order_ids
1882 .contains(&client_order_id)
1883 {
1884 self.clear_recon_tracking(&client_order_id, true);
1885 continue;
1886 }
1887
1888 if self.order_query_pending.contains(&client_order_id) {
1889 continue;
1890 }
1891
1892 let Some(check) = self.order_inflight_checks.get_mut(&client_order_id) else {
1893 continue;
1894 };
1895
1896 if let Some(last_query_at) = check.last_query_at
1897 && now
1898 .checked_duration_since(last_query_at)
1899 .is_none_or(|elapsed| elapsed < threshold)
1900 {
1901 continue;
1902 }
1903
1904 check.retry_count += 1;
1905 check.last_query_at = Some(now);
1906 self.order_query_recency.mark(client_order_id);
1907 self.order_recon_retries
1908 .insert(client_order_id, check.retry_count);
1909
1910 if check.retry_count >= self.config.inflight_max_retries {
1911 let retry_count = check.retry_count;
1912
1913 if self.exhaust_submission(
1914 client_order_id,
1915 SubmissionRecoverySource::Inflight,
1916 retry_count,
1917 ) {
1918 continue;
1919 }
1920
1921 let ts_now = self.clock.borrow().timestamp_ns();
1922
1923 if let Some(order) = self.get_order(client_order_id) {
1924 match order.status() {
1925 OrderStatus::Submitted => {
1926 if let Some(event) = create_reconciliation_rejected(
1928 &order,
1929 Some("INFLIGHT_TIMEOUT"),
1930 ts_now,
1931 ) {
1932 result.events.push(event);
1933 }
1934 }
1935 OrderStatus::PendingUpdate | OrderStatus::PendingCancel => {
1936 result
1938 .events
1939 .push(create_reconciliation_canceled(&order, ts_now));
1940 }
1941 _ => {
1942 }
1944 }
1945 }
1946
1947 self.clear_recon_tracking(&client_order_id, true);
1948 } else if let Some(order) = self.get_order(client_order_id) {
1949 let ts_now = self.clock.borrow().timestamp_ns();
1951 let client_id = self.cache.borrow().client_id(&client_order_id).copied();
1952 let query = TradingCommand::QueryOrder(QueryOrder::new(
1953 order.trader_id(),
1954 client_id,
1955 order.strategy_id(),
1956 order.instrument_id(),
1957 order.client_order_id(),
1958 order.venue_order_id(),
1959 UUID4::new(),
1960 ts_now,
1961 None,
1962 None, ));
1964 result.queries.push(query);
1965 }
1966 }
1967
1968 result
1969 }
1970
1971 pub(crate) fn register_submission_retention(&mut self, client: &dyn ExecutionClient) {
1972 if client.retain_unresolved_submissions() {
1973 self.submission_retention
1974 .insert(client.client_id(), client.account_id());
1975 }
1976 }
1977
1978 fn submission_retention_enabled(
1979 &self,
1980 client_order_id: ClientOrderId,
1981 client_id: Option<ClientId>,
1982 ) -> bool {
1983 if self.config.submission_recovery_policy == SubmissionRecoveryPolicy::RetainUnresolved {
1984 return true;
1985 }
1986
1987 if self.submission_retention.is_empty() {
1988 return false;
1989 }
1990
1991 let cache = self.cache.borrow();
1992
1993 if let Some(client_id) = client_id.or_else(|| cache.client_id(&client_order_id).copied()) {
1994 return self.submission_retention.contains_key(&client_id);
1995 }
1996
1997 cache
1998 .order_ref(&client_order_id)
1999 .and_then(|order| order.account_id())
2000 .is_some_and(|account_id| {
2001 self.submission_retention
2002 .values()
2003 .any(|id| *id == account_id)
2004 })
2005 }
2006
2007 fn submission_is_retained(&self, client_order_id: ClientOrderId) -> bool {
2008 self.submissions
2009 .get(&client_order_id)
2010 .is_some_and(|submission| submission.exhausted)
2011 && self.submission_recovery_pending(client_order_id)
2012 }
2013
2014 fn filtered_open_orders_for_reconciliation(&self) -> Vec<OrderAny> {
2015 let cache = self.cache.borrow();
2016 let mut orders = cache.orders_open(None, None, None, None, None);
2017 orders.extend(cache.orders_inflight(None, None, None, None, None));
2018 let mut seen_client_order_ids = IndexSet::new();
2019
2020 orders
2021 .into_iter()
2022 .filter(|order| {
2023 seen_client_order_ids.insert(order.client_order_id())
2024 && !self.submission_is_retained(order.client_order_id())
2025 && !self
2026 .config
2027 .filtered_client_order_ids
2028 .contains(&order.client_order_id())
2029 && self.should_reconcile_instrument(&order.instrument_id())
2030 })
2031 .map(|order| order.clone())
2032 .collect()
2033 }
2034
2035 fn open_position_keys_for_reconciliation(&self) -> IndexSet<InstrumentAccountKey> {
2036 let cache = self.cache.borrow();
2037 let positions = cache.positions_open(None, None, None, None, None);
2038 let mut position_keys = IndexSet::new();
2039
2040 for position in positions {
2041 if !self.should_reconcile_instrument(&position.instrument_id) {
2042 continue;
2043 }
2044
2045 position_keys.insert((position.instrument_id, position.account_id));
2046 }
2047
2048 position_keys
2049 }
2050
2051 pub async fn check_open_orders(
2055 &mut self,
2056 clients: &[&dyn ExecutionClient],
2057 ) -> Vec<OrderEventAny> {
2058 log::debug!("Checking order consistency between cached-state and venues");
2059
2060 let check = self.prepare_open_order_report_check(UUID4::new(), clients);
2061 let mut all_reports = Vec::new();
2062 let mut queried_clients = IndexSet::new();
2063 let mut failed_clients = IndexSet::new();
2064
2065 for client in clients {
2066 let client_id = client.client_id();
2067 queried_clients.insert(client_id);
2068
2069 match client.generate_order_status_reports(&check.command).await {
2070 Ok(reports) => {
2071 all_reports.extend(
2072 reports
2073 .into_iter()
2074 .map(|report| SourcedOrderStatusReport { client_id, report }),
2075 );
2076 }
2077 Err(e) => {
2078 failed_clients.insert(client_id);
2079 log::warn!("Failed to query order reports from {client_id}: {e}");
2080 }
2081 }
2082 }
2083
2084 let result = self.reconcile_open_order_reports(
2085 &check,
2086 all_reports,
2087 &queried_clients,
2088 &failed_clients,
2089 clients,
2090 );
2091
2092 let mut events = result.events;
2093
2094 if !result.targeted_queries.is_empty() {
2095 let query_delay =
2096 Duration::from_millis(u64::from(self.config.single_order_query_delay_ms));
2097 let query_results =
2098 request_targeted_order_reports(result.targeted_queries, clients, query_delay).await;
2099 events.extend(self.reconcile_targeted_order_reports(query_results, clients));
2100 }
2101
2102 events
2103 }
2104
2105 pub(crate) fn prepare_open_order_report_check(
2107 &mut self,
2108 command_id: UUID4,
2109 clients: &[&dyn ExecutionClient],
2110 ) -> OpenOrderReportCheck {
2111 for client in clients {
2112 self.register_submission_retention(*client);
2113 }
2114
2115 let filtered_orders = self.filtered_open_orders_for_reconciliation();
2116 let active_order_ids: IndexSet<ClientOrderId> =
2117 filtered_orders.iter().map(Order::client_order_id).collect();
2118 self.order_coverage_warnings
2119 .retain(|client_order_id| active_order_ids.contains(client_order_id));
2120 self.order_lookback_warnings
2121 .retain(|client_order_id| active_order_ids.contains(client_order_id));
2122 self.order_coverage_unresolved
2123 .retain(|client_order_id| active_order_ids.contains(client_order_id));
2124
2125 let mut client_coverage = IndexMap::new();
2126
2127 for order in &filtered_orders {
2128 let client_order_id = order.client_order_id();
2129 let coverage = self.resolve_order_report_client_coverage(order, clients);
2130
2131 match &coverage {
2132 ReportClientCoverage::Resolved(_) => {
2133 if self
2134 .order_coverage_unresolved
2135 .shift_remove(&client_order_id)
2136 {
2137 self.order_coverage_warnings.shift_remove(&client_order_id);
2138 }
2139 }
2140 ReportClientCoverage::Unavailable(_) | ReportClientCoverage::Unresolved => {
2141 self.order_coverage_unresolved.insert(client_order_id);
2142 }
2143 }
2144
2145 client_coverage.insert(client_order_id, coverage);
2146 }
2147
2148 log::debug!(
2149 "Found {} order{} open in cache",
2150 filtered_orders.len(),
2151 if filtered_orders.len() == 1 { "" } else { "s" }
2152 );
2153
2154 let ts_now = self.clock.borrow().timestamp_ns();
2155 let start = self
2156 .config
2157 .open_check_lookback_mins
2158 .map(DurationNanos::from_mins)
2159 .map(|lookback| ts_now.saturating_sub(lookback));
2160
2161 let mut command = GenerateOrderStatusReports::new(
2162 command_id,
2163 ts_now,
2164 self.config.open_check_open_only,
2165 None,
2166 start,
2167 None,
2168 None,
2169 None,
2170 );
2171 command.log_receipt_level = LogLevel::Debug;
2172
2173 OpenOrderReportCheck {
2174 command,
2175 filtered_orders,
2176 client_coverage,
2177 }
2178 }
2179
2180 fn resolve_order_report_client_coverage(
2181 &self,
2182 order: &OrderAny,
2183 clients: &[&dyn ExecutionClient],
2184 ) -> ReportClientCoverage {
2185 if let Some(client_id) = self.cache.borrow().client_id(&order.client_order_id()) {
2186 return ReportClientCoverage::Resolved(IndexSet::from([*client_id]));
2187 }
2188
2189 let client_ids =
2190 responsible_client_ids(order.account_id(), order.instrument_id().venue, clients);
2191
2192 if client_ids.is_empty() {
2193 ReportClientCoverage::Unresolved
2194 } else {
2195 ReportClientCoverage::Resolved(client_ids)
2196 }
2197 }
2198
2199 pub fn check_open_order_queries(&mut self) -> Vec<TradingCommand> {
2201 let now = dst::time::Instant::now();
2202 let query_delay = Duration::from_millis(u64::from(self.config.single_order_query_delay_ms));
2203 let query_limit = self.config.max_single_order_queries_per_cycle as usize;
2204
2205 if query_limit == 0 {
2206 return Vec::new();
2207 }
2208
2209 let mut filtered_orders = self.filtered_open_orders_for_reconciliation();
2210 filtered_orders.sort_by_key(|order| {
2211 let client_order_id = order.client_order_id();
2212 (
2213 self.order_query_recency.last_marked(&client_order_id),
2214 client_order_id,
2215 )
2216 });
2217
2218 let mut queries = Vec::new();
2219
2220 for order in filtered_orders {
2221 if queries.len() >= query_limit {
2222 break;
2223 }
2224
2225 let client_order_id = order.client_order_id();
2226 let threshold = Duration::from(self.config.open_check_threshold_ns);
2227
2228 if let Some(elapsed) = self.order_activity.elapsed_at(&client_order_id, now)
2229 && elapsed < threshold
2230 {
2231 let elapsed_ms = elapsed.as_millis();
2232 let threshold_ms = threshold.as_millis();
2233 log::debug!(
2234 "Deferring open order query for {client_order_id}: recent local activity \
2235 ({elapsed_ms}ms < threshold={threshold_ms}ms)",
2236 );
2237 continue;
2238 }
2239
2240 if self
2241 .order_query_recency
2242 .within_at(&client_order_id, now, query_delay)
2243 {
2244 continue;
2245 }
2246
2247 self.order_query_recency.mark(client_order_id);
2248 let client_id = self.cache.borrow().client_id(&client_order_id).copied();
2249 let ts_now = self.clock.borrow().timestamp_ns();
2250
2251 let cmd = TradingCommand::QueryOrder(QueryOrder::new(
2252 order.trader_id(),
2253 client_id,
2254 order.strategy_id(),
2255 order.instrument_id(),
2256 client_order_id,
2257 order.venue_order_id(),
2258 UUID4::new(),
2259 ts_now,
2260 None,
2261 None,
2262 ));
2263 queries.push(cmd);
2264 }
2265
2266 queries
2267 }
2268
2269 pub(crate) fn reconcile_open_order_reports(
2271 &mut self,
2272 check: &OpenOrderReportCheck,
2273 mut all_reports: Vec<SourcedOrderStatusReport>,
2274 queried_clients: &IndexSet<ClientId>,
2275 failed_clients: &IndexSet<ClientId>,
2276 clients: &[&dyn ExecutionClient],
2277 ) -> OpenOrderReconciliationResult {
2278 all_reports.retain(|sourced| !self.should_skip_order_report(&sourced.report));
2279 let mut venue_reported_ids = IndexSet::new();
2280
2281 for sourced in &all_reports {
2282 let report = &sourced.report;
2283
2284 let client_order_id = report.client_order_id.or_else(|| {
2285 self.cache
2286 .borrow()
2287 .client_order_id(&report.venue_order_id)
2288 .copied()
2289 });
2290
2291 let Some(client_order_id) = client_order_id else {
2292 continue;
2293 };
2294
2295 venue_reported_ids.insert(client_order_id);
2296 self.order_coverage_warnings.shift_remove(&client_order_id);
2297 self.order_lookback_warnings.shift_remove(&client_order_id);
2298
2299 self.order_recon_retries.shift_remove(&client_order_id);
2303 }
2304
2305 let mut events = Vec::new();
2306 let mut targeted_candidates = Vec::new();
2307
2308 for sourced in all_reports {
2309 let report = sourced.report;
2310
2311 let order = match report.client_order_id {
2312 Some(client_order_id) => self.get_order(client_order_id),
2313 None => self.get_order_by_venue_order_id(report.venue_order_id),
2314 };
2315
2316 let Some(order) = order else {
2317 continue;
2318 };
2319
2320 let client_order_id = order.client_order_id();
2321
2322 let threshold = Duration::from(self.config.open_check_threshold_ns);
2324 let activity_elapsed = self.order_activity.elapsed(&client_order_id);
2325 self.confirm_submitted_report(&report);
2326
2327 if let Some(elapsed) = activity_elapsed
2328 && elapsed < threshold
2329 {
2330 let elapsed_ms = elapsed.as_millis();
2331 let threshold_ms = threshold.as_millis();
2332 log::debug!(
2333 "Deferring reconciliation for {client_order_id}: recent local activity ({elapsed_ms}ms < threshold={threshold_ms}ms)",
2334 );
2335 continue;
2336 }
2337
2338 let instrument = self.get_instrument(&report.instrument_id);
2339
2340 if terminal_report_has_missing_fills(&report, order.filled_qty()) {
2341 targeted_candidates.push((
2342 order,
2343 IndexSet::from([sourced.client_id]),
2344 Some(report),
2345 ));
2346 continue;
2347 }
2348
2349 let commission_client = clients
2350 .iter()
2351 .find(|client| client.client_id() == sourced.client_id)
2352 .copied();
2353
2354 match self.reconcile_order_report(
2355 &order,
2356 &report,
2357 instrument.as_ref(),
2358 commission_client,
2359 ) {
2360 Ok(order_events) => events.extend(order_events),
2361 Err(e) => log::error!(
2362 "Deferring reconciliation for {client_order_id}: order reconciliation failed: {e}"
2363 ),
2364 }
2365 }
2366
2367 if self.config.open_check_open_only {
2372 let cached_ids: IndexSet<ClientOrderId> = check
2373 .filtered_orders
2374 .iter()
2375 .map(Order::client_order_id)
2376 .collect();
2377 let missing_at_venue: IndexSet<ClientOrderId> = cached_ids
2378 .difference(&venue_reported_ids)
2379 .copied()
2380 .collect();
2381
2382 if !missing_at_venue.is_empty() {
2383 log::debug!(
2384 "{} cached open order{} not present in venue current response",
2385 missing_at_venue.len(),
2386 if missing_at_venue.len() == 1 {
2387 " is"
2388 } else {
2389 "s are"
2390 },
2391 );
2392
2393 for client_order_id in missing_at_venue {
2394 log::debug!("Cached open order missing from venue response: {client_order_id}");
2395 }
2396 }
2397 } else {
2398 let candidates: Vec<&OrderAny> = if let Some(cutoff) = check.command.start {
2399 let mut candidates = Vec::new();
2400
2401 for order in &check.filtered_orders {
2402 let client_order_id = order.client_order_id();
2403 if order.ts_last() >= cutoff {
2404 self.order_lookback_warnings.shift_remove(&client_order_id);
2405 candidates.push(order);
2406 } else if !venue_reported_ids.contains(&client_order_id)
2407 && self.order_lookback_warnings.insert(client_order_id)
2408 {
2409 log::warn!(
2410 "Skipping missing-order reconciliation for {client_order_id}: its last update predates the configured open-check lookback window; absence from the bulk response cannot be treated as evidence and no targeted query will be issued from it"
2411 );
2412 }
2413 }
2414
2415 candidates
2416 } else {
2417 check.filtered_orders.iter().collect()
2418 };
2419
2420 for order in candidates {
2421 let client_order_id = order.client_order_id();
2422 if venue_reported_ids.contains(&client_order_id) {
2423 continue;
2424 }
2425
2426 let responsible_clients = match check.client_coverage.get(&client_order_id) {
2427 Some(ReportClientCoverage::Resolved(responsible_clients))
2428 if !responsible_clients.is_empty() =>
2429 {
2430 responsible_clients
2431 }
2432 _ => {
2433 if self.order_coverage_warnings.insert(client_order_id) {
2434 log::warn!(
2435 "Skipping order reconciliation for {client_order_id}: responsible execution client coverage is unresolved"
2436 );
2437 }
2438
2439 continue;
2440 }
2441 };
2442
2443 let missing_clients = responsible_clients
2444 .difference(queried_clients)
2445 .copied()
2446 .collect::<IndexSet<_>>();
2447
2448 if !missing_clients.is_empty() {
2449 if self.order_coverage_warnings.insert(client_order_id) {
2450 log::warn!(
2451 "Skipping order reconciliation for {client_order_id}: responsible execution clients were not queried: {missing_clients:?}"
2452 );
2453 }
2454
2455 continue;
2456 }
2457
2458 let failed_responsible_clients = responsible_clients
2459 .intersection(failed_clients)
2460 .copied()
2461 .collect::<IndexSet<_>>();
2462
2463 if !failed_responsible_clients.is_empty() {
2464 log::warn!(
2465 "Skipping order reconciliation for {client_order_id}: failed to query responsible execution clients: {failed_responsible_clients:?}"
2466 );
2467 continue;
2468 }
2469
2470 self.order_coverage_warnings.shift_remove(&client_order_id);
2471 if let Some(order) = self.prepare_missing_order_query(client_order_id) {
2472 targeted_candidates.push((order, responsible_clients.clone(), None));
2473 }
2474 }
2475 }
2476
2477 targeted_candidates.sort_by_key(|(order, _, _)| {
2478 let client_order_id = order.client_order_id();
2479 (
2480 self.order_query_recency.last_marked(&client_order_id),
2481 client_order_id,
2482 )
2483 });
2484
2485 let query_limit = self.config.max_single_order_queries_per_cycle as usize;
2486 let mut planned_queries = 0usize;
2487 let mut cap_deferred_orders = 0usize;
2488 let mut targeted_queries = Vec::new();
2489
2490 for (order, responsible_clients, report) in targeted_candidates {
2491 let client_order_id = order.client_order_id();
2492
2493 let required_queries = responsible_clients.len();
2494 let exceeds_query_limit = planned_queries + required_queries > query_limit;
2495 let can_run_oversized_group = planned_queries == 0 && query_limit > 0;
2496 if required_queries == 0 || (exceeds_query_limit && !can_run_oversized_group) {
2497 cap_deferred_orders += 1;
2498 continue;
2499 }
2500
2501 if required_queries > query_limit {
2502 log::warn!(
2503 "Targeted order query for {client_order_id} requires {required_queries} responsible clients, exceeding the per-cycle limit {query_limit} to avoid indefinite deferral"
2504 );
2505 }
2506
2507 planned_queries += required_queries;
2508 self.order_query_recency.mark(client_order_id);
2509 self.order_query_pending.insert(client_order_id);
2510 let command_id = UUID4::new();
2511 let ts_now = self.clock.borrow().timestamp_ns();
2512
2513 let command = GenerateOrderStatusReport::new(
2514 command_id,
2515 ts_now,
2516 Some(order.instrument_id()),
2517 Some(client_order_id),
2518 order.venue_order_id(),
2519 None,
2520 None,
2521 );
2522 targeted_queries.push(TargetedOrderQuery {
2523 client_order_id,
2524 responsible_clients,
2525 report,
2526 filled_qty: order.filled_qty(),
2527 command,
2528 });
2529 }
2530
2531 if cap_deferred_orders > 0 {
2532 log::warn!(
2533 "Reached max single-order queries ({query_limit}) this cycle, deferring {cap_deferred_orders} order(s)"
2534 );
2535 }
2536
2537 OpenOrderReconciliationResult {
2538 events,
2539 targeted_queries,
2540 }
2541 }
2542
2543 pub(crate) fn reconcile_targeted_order_reports(
2545 &mut self,
2546 results: Vec<TargetedOrderReportResult>,
2547 clients: &[&dyn ExecutionClient],
2548 ) -> Vec<OrderEventAny> {
2549 let mut events = Vec::new();
2550 let mut fill_queue = ReconciliationFillQueue::default();
2551
2552 for result in results {
2553 let client_order_id = result.client_order_id;
2554 self.remove_targeted_order_queries(&[client_order_id]);
2555
2556 if let Some(report) = result.report {
2557 self.order_recon_retries.shift_remove(&client_order_id);
2558 self.order_coverage_warnings.shift_remove(&client_order_id);
2559
2560 let Some(order) = self.get_order(client_order_id) else {
2561 continue;
2562 };
2563
2564 let instrument = self.get_instrument(&report.instrument_id);
2565
2566 let commission_client = result.client_id.and_then(|client_id| {
2567 clients
2568 .iter()
2569 .find(|client| client.client_id() == client_id)
2570 .copied()
2571 });
2572
2573 log::info!(
2574 color = LogColor::Blue as u8;
2575 "Found {client_order_id} via targeted order status query: {}",
2576 report.order_status,
2577 );
2578
2579 let fills = result.fills.iter().collect::<Vec<_>>();
2580 events.extend(self.reconcile_order_with_fills(
2581 false,
2582 &order,
2583 &report,
2584 &fills,
2585 instrument.as_ref(),
2586 &mut fill_queue,
2587 commission_client,
2588 ));
2589 continue;
2590 }
2591
2592 if result.coverage_complete {
2593 events.extend(self.resolve_missing_order(client_order_id));
2594 } else {
2595 log::warn!(
2596 "Deferring missing-order resolution for {client_order_id}: targeted order status coverage was incomplete"
2597 );
2598 }
2599 }
2600
2601 events
2602 }
2603
2604 pub async fn check_positions_consistency(
2609 &mut self,
2610 clients: &[&dyn ExecutionClient],
2611 ) -> Vec<OrderEventAny> {
2612 let check = self.prepare_position_report_check(UUID4::new(), clients);
2613 let mut reports = Vec::new();
2614 let mut queried_clients = IndexSet::new();
2615 let mut failed_clients = IndexSet::new();
2616
2617 for client in clients {
2618 let client_id = client.client_id();
2619 queried_clients.insert(client_id);
2620 self.set_position_reconciliation_tolerance(
2621 client.account_id(),
2622 client.position_reconciliation_tolerance(),
2623 );
2624
2625 match client
2626 .generate_position_status_reports(&check.command)
2627 .await
2628 {
2629 Ok(client_reports) => {
2630 reports.extend(client_reports);
2631 }
2632 Err(e) => {
2633 failed_clients.insert(client_id);
2634 log::warn!("Failed to query position reports from {client_id}: {e}");
2635 }
2636 }
2637 }
2638
2639 let active_keys = self
2640 .open_position_keys_for_reconciliation()
2641 .into_iter()
2642 .chain(reports.iter().filter_map(|report| {
2643 (self.should_reconcile_instrument(&report.instrument_id)
2644 && report.signed_decimal_qty != Decimal::ZERO)
2645 .then_some((report.instrument_id, report.account_id))
2646 }))
2647 .collect();
2648
2649 let events =
2650 self.reconcile_position_reports(&check, reports, &queried_clients, &failed_clients);
2651
2652 self.retain_position_reconciliation(&active_keys);
2654
2655 events
2656 }
2657
2658 #[must_use]
2663 pub fn prepare_position_report_check(
2664 &self,
2665 command_id: UUID4,
2666 clients: &[&dyn ExecutionClient],
2667 ) -> PositionReportCheck {
2668 let position_keys = self.open_position_keys_for_reconciliation();
2669
2670 let client_coverage = position_keys
2671 .iter()
2672 .map(|key| (*key, resolve_position_report_client_coverage(*key, clients)))
2673 .collect();
2674
2675 let mut activity_revisions = self.position_activity_revisions.clone();
2676
2677 for key in &position_keys {
2678 activity_revisions
2679 .entry(*key)
2680 .or_insert_with(|| self.position_activity_revision(key));
2681 }
2682
2683 log::debug!(
2684 "Found {} unique instrument/account combination{} with open positions",
2685 position_keys.len(),
2686 if position_keys.len() == 1 { "" } else { "s" }
2687 );
2688
2689 let ts_now = self.clock.borrow().timestamp_ns();
2690
2691 let mut command = GeneratePositionStatusReports::new(
2692 command_id, ts_now, None, None, None, None, None, );
2698 command.log_receipt_level = LogLevel::Debug;
2699
2700 PositionReportCheck {
2701 command,
2702 client_coverage,
2703 activity_revisions,
2704 }
2705 }
2706
2707 pub fn plan_position_fill_reports(
2712 &mut self,
2713 check: &mut PositionReportCheck,
2714 reports: &[PositionStatusReport],
2715 queried_clients: &IndexSet<ClientId>,
2716 failed_clients: &IndexSet<ClientId>,
2717 clients: &[&dyn ExecutionClient],
2718 ) -> PositionFillReportPlan {
2719 let venue_positions = self.group_position_reports(reports.iter().cloned());
2720
2721 let keys = check
2722 .client_coverage
2723 .keys()
2724 .copied()
2725 .chain(venue_positions.iter().filter_map(|(key, reports)| {
2726 reports
2727 .iter()
2728 .any(|report| report.signed_decimal_qty != Decimal::ZERO)
2729 .then_some(*key)
2730 }))
2731 .collect::<IndexSet<_>>();
2732
2733 let query_end = self.timestamp_ns();
2734 let lookback = DurationNanos::from_mins(self.config.position_check_lookback_mins);
2735 let query_start = query_end.saturating_sub(lookback);
2736 let mut discrepancy_keys = IndexSet::new();
2737 let mut queries = Vec::new();
2738
2739 for &key in &keys {
2740 let coverage = check
2741 .client_coverage
2742 .entry(key)
2743 .or_insert_with(|| resolve_position_report_client_coverage(key, clients));
2744 let prepared_revision = *check.activity_revisions.entry(key).or_default();
2745 let venue_reports = venue_positions
2746 .get(&key)
2747 .map(Vec::as_slice)
2748 .unwrap_or_default();
2749 let comparison = self.position_quantity_comparison(key, venue_reports);
2750 let tolerance = self.position_reconciliation_tolerance(key.1);
2751
2752 if comparison.quantities_match(tolerance) {
2753 self.clear_position_reconciliation(&key);
2754 continue;
2755 }
2756
2757 discrepancy_keys.insert(key);
2758
2759 if self.position_activity_revision(&key) > prepared_revision
2760 || self.position_activity_is_recent(&key)
2761 {
2762 continue;
2763 }
2764
2765 let report_shape = comparison.report_shape();
2766 let retries = self.position_reconciliation_retries(&key, report_shape);
2767 if retries >= self.config().position_check_retries {
2768 continue;
2769 }
2770
2771 let responsible_clients = match coverage {
2772 ReportClientCoverage::Resolved(responsible_clients) => responsible_clients,
2773
2774 ReportClientCoverage::Unavailable(responsible_clients)
2776 if !venue_reports.is_empty() =>
2777 {
2778 responsible_clients
2779 }
2780 ReportClientCoverage::Unavailable(_) => {
2781 log::debug!(
2782 "Skipping fill report query for {}/{}: responsible execution client coverage is unavailable",
2783 key.0,
2784 key.1,
2785 );
2786 continue;
2787 }
2788 ReportClientCoverage::Unresolved => {
2789 log::warn!(
2790 "Skipping fill report query for {}/{}: responsible execution client coverage is unresolved",
2791 key.0,
2792 key.1,
2793 );
2794 continue;
2795 }
2796 };
2797
2798 if responsible_clients.is_empty()
2799 || !responsible_clients.is_subset(queried_clients)
2800 || !responsible_clients.is_disjoint(failed_clients)
2801 {
2802 log::warn!(
2803 "Skipping fill report query for {}/{}: responsible position report coverage is incomplete",
2804 key.0,
2805 key.1,
2806 );
2807 continue;
2808 }
2809
2810 for client_id in responsible_clients.iter() {
2811 let mut command = GenerateFillReports::new(
2812 UUID4::new(),
2813 query_end,
2814 Some(key.0),
2815 None,
2816 Some(query_start),
2817 Some(query_end),
2818 None,
2819 Some(check.command.command_id),
2820 );
2821 command.log_receipt_level = LogLevel::Debug;
2822 queries.push(PositionFillReportQuery {
2823 key,
2824 client_id: *client_id,
2825 command,
2826 });
2827 }
2828 }
2829
2830 self.retain_position_reconciliation(&keys);
2831
2832 PositionFillReportPlan {
2833 queries,
2834 discrepancy_keys,
2835 }
2836 }
2837
2838 #[must_use]
2840 pub fn position_report_check_is_current(
2841 &self,
2842 check: &PositionReportCheck,
2843 key: &InstrumentAccountKey,
2844 ) -> bool {
2845 check
2846 .activity_revisions
2847 .get(key)
2848 .is_some_and(|revision| self.position_activity_revision(key) == *revision)
2849 }
2850
2851 pub fn prepare_position_fill_report(
2858 &self,
2859 report: &mut FillReport,
2860 venue_reports: &[PositionStatusReport],
2861 ) -> anyhow::Result<PositionFillReportPreparation> {
2862 let cache = self.cache();
2863 let venue_client_order_id = cache.client_order_id(&report.venue_order_id).copied();
2864 if let (Some(report_client_order_id), Some(venue_client_order_id)) =
2865 (report.client_order_id, venue_client_order_id)
2866 {
2867 anyhow::ensure!(
2868 report_client_order_id == venue_client_order_id,
2869 "fill {} client order ID {report_client_order_id} conflicts with venue order mapping {venue_client_order_id}",
2870 report.trade_id,
2871 );
2872 }
2873
2874 let client_order_id = report.client_order_id.or(venue_client_order_id);
2875 let order = client_order_id.and_then(|id| cache.order(&id));
2876 if let Some(order) = &order {
2877 anyhow::ensure!(
2878 order.instrument_id() == report.instrument_id
2879 && order.order_side() == report.order_side
2880 && order
2881 .account_id()
2882 .is_none_or(|account_id| account_id == report.account_id)
2883 && order
2884 .venue_order_id()
2885 .is_none_or(|venue_order_id| venue_order_id == report.venue_order_id),
2886 "fill {} conflicts with cached order {}",
2887 report.trade_id,
2888 order.client_order_id(),
2889 );
2890 }
2891
2892 let hedge_context = report.venue_position_id.is_some()
2893 || venue_reports
2894 .iter()
2895 .any(|venue_report| venue_report.venue_position_id.is_some());
2896 let flip = client_order_id
2897 .map(|id| cache.hedging_flip_position_ids(id))
2898 .transpose()?
2899 .flatten();
2900
2901 let mapped_position_id = flip.map(|ids| ids[1]).or_else(|| {
2902 client_order_id
2903 .and_then(|client_order_id| cache.position_id(&client_order_id))
2904 .copied()
2905 });
2906
2907 if hedge_context
2908 && let (Some(venue_position_id), Some(mapped_position_id)) =
2909 (report.venue_position_id, mapped_position_id)
2910 {
2911 anyhow::ensure!(
2912 venue_position_id == mapped_position_id
2913 || flip.is_some_and(|ids| ids.contains(&venue_position_id)),
2914 "fill {} position ID {venue_position_id} conflicts with cached order position {mapped_position_id}",
2915 report.trade_id,
2916 );
2917 }
2918
2919 if let Some(order) = order
2920 && has_active_inferred_fill(&order)?
2921 {
2922 return Ok(PositionFillReportPreparation::InferredOverlap);
2923 }
2924
2925 if fill_precedes_snapshot_reconciled_position(&cache, report) {
2926 return Ok(PositionFillReportPreparation::SnapshotOverlap);
2927 }
2928
2929 if report.venue_position_id.is_none() && flip.is_some() {
2930 report.venue_position_id = mapped_position_id;
2931 return Ok(PositionFillReportPreparation::Ready);
2932 }
2933
2934 if !hedge_context {
2935 return Ok(PositionFillReportPreparation::Ready);
2936 }
2937
2938 if report.venue_position_id.is_some() {
2939 return Ok(PositionFillReportPreparation::Ready);
2940 }
2941
2942 let Some(position_id) = mapped_position_id else {
2943 return Ok(PositionFillReportPreparation::Unattributed);
2944 };
2945
2946 let position = cache.position(&position_id).ok_or_else(|| {
2947 anyhow::anyhow!(
2948 "fill {} maps to position {position_id}, which is not cached",
2949 report.trade_id,
2950 )
2951 })?;
2952
2953 anyhow::ensure!(
2954 position.account_id == report.account_id
2955 && position.instrument_id == report.instrument_id,
2956 "fill {} maps to position {position_id} with a different account or instrument",
2957 report.trade_id,
2958 );
2959 anyhow::ensure!(
2960 position.is_open(),
2961 "fill {} maps to non-open position {position_id}",
2962 report.trade_id,
2963 );
2964 anyhow::ensure!(
2965 !position.is_opposite_side(report.order_side) || report.last_qty <= position.quantity,
2966 "fill {} without a venue position ID would cross position {position_id}",
2967 report.trade_id,
2968 );
2969
2970 report.venue_position_id = Some(position_id);
2971 Ok(PositionFillReportPreparation::Ready)
2972 }
2973
2974 pub fn record_unapplied_fill_report(&mut self, report: &FillReport) {
2976 let fill_key = (report.account_id, report.instrument_id, report.trade_id);
2977 if !self.fills_unapplied.contains_key(&fill_key) {
2978 self.fills_unapplied.mark(fill_key);
2979 }
2980
2981 let ttl = Duration::from_mins(self.config.position_check_lookback_mins)
2982 .max(Duration::from_mins(1));
2983 self.fills_unapplied.prune_older_than(ttl);
2984 }
2985
2986 #[must_use]
2990 pub fn is_unapplied_fill_report_expired(&self, report: &FillReport) -> bool {
2991 let fill_key = (report.account_id, report.instrument_id, report.trade_id);
2992 self.fills_unapplied.contains_key(&fill_key)
2993 && !self.fills_unapplied.within(
2994 &fill_key,
2995 Duration::from(self.config.position_check_threshold_ns),
2996 )
2997 }
2998
2999 #[must_use]
3001 pub fn position_contains_fill_report(&self, report: &FillReport) -> bool {
3002 let cache = self.cache();
3003 let positions = cache.positions_refs(
3004 None,
3005 Some(&report.instrument_id),
3006 None,
3007 Some(&report.account_id),
3008 None,
3009 );
3010 position_fill_report_matches(
3011 &cache,
3012 report,
3013 &positions,
3014 |id| cache.hedging_flip_position_ids(id),
3015 None,
3016 )
3017 }
3018
3019 #[cfg(feature = "node")]
3023 pub(crate) fn position_fill_reports_contained_prefix(&self, reports: &[FillReport]) -> usize {
3024 let Some(first) = reports.first() else {
3025 return 0;
3026 };
3027
3028 if reports.len() == 1 {
3029 return usize::from(self.position_contains_fill_report(first));
3030 }
3031
3032 let cache = self.cache();
3033 let positions = cache.positions_refs(
3034 None,
3035 Some(&first.instrument_id),
3036 None,
3037 Some(&first.account_id),
3038 None,
3039 );
3040 let mut replay = IndexMap::new();
3041
3042 let mut flips = IndexMap::new();
3044 reports
3045 .iter()
3046 .take_while(|report| {
3047 report.instrument_id == first.instrument_id
3048 && report.account_id == first.account_id
3049 && position_fill_report_matches(
3050 &cache,
3051 report,
3052 &positions,
3053 |id| {
3054 if let Some(flip) = flips.get(&id) {
3055 return Ok(*flip);
3056 }
3057
3058 let flip = cache.hedging_flip_position_ids(id)?;
3059 flips.insert(id, flip);
3060 Ok(flip)
3061 },
3062 Some(&mut replay),
3063 )
3064 })
3065 .count()
3066 }
3067
3068 #[must_use]
3074 pub fn reconcile_position_reports(
3075 &mut self,
3076 check: &PositionReportCheck,
3077 reports: Vec<PositionStatusReport>,
3078 queried_clients: &IndexSet<ClientId>,
3079 failed_clients: &IndexSet<ClientId>,
3080 ) -> Vec<OrderEventAny> {
3081 log::debug!("Checking position consistency between cached-state and venues");
3082
3083 let venue_positions = self.group_position_reports(reports);
3084 let mut events = Vec::new();
3085
3086 for (key, coverage) in &check.client_coverage {
3087 let prepared_revision = check
3088 .activity_revisions
3089 .get(key)
3090 .copied()
3091 .unwrap_or_default();
3092
3093 if self.position_activity_revision(key) > prepared_revision {
3094 log::debug!(
3095 "Deferring position reconciliation for {}/{}: local activity recorded during report request",
3096 key.0,
3097 key.1,
3098 );
3099 continue;
3100 }
3101
3102 let venue_reports = venue_positions
3103 .get(key)
3104 .map(Vec::as_slice)
3105 .unwrap_or_default();
3106
3107 if venue_reports.is_empty() {
3108 match coverage {
3109 ReportClientCoverage::Resolved(responsible_clients)
3110 if responsible_clients.is_empty() =>
3111 {
3112 log::warn!(
3113 "Skipping position reconciliation for {}/{}: responsible execution client coverage is unresolved",
3114 key.0,
3115 key.1,
3116 );
3117 continue;
3118 }
3119 ReportClientCoverage::Resolved(responsible_clients)
3120 if !responsible_clients.is_subset(queried_clients) =>
3121 {
3122 log::warn!(
3123 "Skipping position reconciliation for {}/{}: responsible execution clients were not all queried",
3124 key.0,
3125 key.1,
3126 );
3127 continue;
3128 }
3129 ReportClientCoverage::Resolved(responsible_clients)
3130 if !responsible_clients.is_disjoint(failed_clients) =>
3131 {
3132 let failed_responsible_clients = responsible_clients
3133 .intersection(failed_clients)
3134 .copied()
3135 .collect::<IndexSet<_>>();
3136 log::warn!(
3137 "Skipping position reconciliation for {}/{}: failed to query responsible execution clients: {failed_responsible_clients:?}",
3138 key.0,
3139 key.1,
3140 );
3141 continue;
3142 }
3143 ReportClientCoverage::Resolved(_) => {}
3144 ReportClientCoverage::Unavailable(responsible_clients) => {
3145 log::debug!(
3146 "Skipping position reconciliation for {}/{}: complete bulk position coverage is unavailable from responsible execution clients: {responsible_clients:?}",
3147 key.0,
3148 key.1,
3149 );
3150 continue;
3151 }
3152 ReportClientCoverage::Unresolved => {
3153 log::warn!(
3154 "Skipping position reconciliation for {}/{}: responsible execution client coverage is unresolved",
3155 key.0,
3156 key.1,
3157 );
3158 continue;
3159 }
3160 }
3161 }
3162
3163 if let Some(discrepancy_events) = self.check_position_discrepancy(*key, venue_reports) {
3164 events.extend(discrepancy_events);
3165 }
3166 }
3167
3168 let current_position_keys = self.open_position_keys_for_reconciliation();
3169
3170 for (key, venue_reports) in &venue_positions {
3171 if check.client_coverage.contains_key(key)
3172 || venue_reports
3173 .iter()
3174 .all(|report| report.signed_decimal_qty == Decimal::ZERO)
3175 {
3176 continue;
3177 }
3178
3179 if current_position_keys.contains(key) {
3180 log::debug!(
3181 "Deferring position reconciliation for {}/{}: position opened after client coverage was recorded",
3182 key.0,
3183 key.1,
3184 );
3185 continue;
3186 }
3187
3188 if let Some(discrepancy_events) = self.check_position_discrepancy(*key, venue_reports) {
3189 events.extend(discrepancy_events);
3190 }
3191 }
3192
3193 events
3194 }
3195
3196 fn group_position_reports(
3197 &self,
3198 reports: impl IntoIterator<Item = PositionStatusReport>,
3199 ) -> IndexMap<InstrumentAccountKey, Vec<PositionStatusReport>> {
3200 let mut venue_positions: IndexMap<InstrumentAccountKey, Vec<PositionStatusReport>> =
3201 IndexMap::new();
3202
3203 for report in reports {
3204 if !self.should_reconcile_instrument(&report.instrument_id) {
3205 continue;
3206 }
3207
3208 venue_positions
3209 .entry((report.instrument_id, report.account_id))
3210 .or_default()
3211 .push(report);
3212 }
3213
3214 venue_positions
3215 .into_iter()
3216 .map(|(key, reports)| (key, distinct_position_reports(reports)))
3217 .collect()
3218 }
3219
3220 #[must_use]
3222 pub fn get_external_order_claim(&self, instrument_id: &InstrumentId) -> Option<StrategyId> {
3223 self.cache.borrow().external_order_claim(instrument_id)
3224 }
3225
3226 pub fn claim_external_orders(
3232 &mut self,
3233 instrument_id: InstrumentId,
3234 strategy_id: StrategyId,
3235 ) -> anyhow::Result<()> {
3236 self.cache
3237 .borrow_mut()
3238 .register_external_order_claims(strategy_id, &[instrument_id])
3239 }
3240
3241 pub fn observe_order_event(&mut self, event: &OrderEventAny) {
3251 match event {
3252 OrderEventAny::Filled(fill) => {
3253 self.record_position_activity(fill.instrument_id, fill.account_id);
3254 }
3255 OrderEventAny::Accepted(_)
3256 | OrderEventAny::Rejected(_)
3257 | OrderEventAny::Canceled(_)
3258 | OrderEventAny::Expired(_)
3259 | OrderEventAny::Denied(_)
3260 | OrderEventAny::Updated(_)
3261 | OrderEventAny::ModifyRejected(_)
3262 | OrderEventAny::CancelRejected(_) => {
3263 self.clear_recon_tracking(&event.client_order_id(), true);
3264 }
3265 _ => {}
3266 }
3267
3268 self.record_local_activity(event.client_order_id());
3269 }
3270
3271 pub fn observe_execution_report(&mut self, report: &ExecutionReport) {
3284 match report {
3285 ExecutionReport::Order(order_report) => {
3286 self.observe_order_report(order_report);
3287 }
3288 ExecutionReport::Fill(fill_report) => {
3289 let client_order_id = fill_report.client_order_id.or_else(|| {
3290 self.cache
3291 .borrow()
3292 .client_order_id(&fill_report.venue_order_id)
3293 .copied()
3294 });
3295
3296 if let Some(coid) = client_order_id {
3297 self.record_local_activity(coid);
3298 }
3299
3300 self.record_position_activity(fill_report.instrument_id, fill_report.account_id);
3301 }
3302 ExecutionReport::OrderWithFills(order_report, fills) => {
3303 self.observe_order_report(order_report);
3304
3305 for fill_report in fills {
3306 self.record_position_activity(
3307 fill_report.instrument_id,
3308 fill_report.account_id,
3309 );
3310 }
3311 }
3312 ExecutionReport::Position(position_report) => {
3313 self.record_position_activity(
3314 position_report.instrument_id,
3315 position_report.account_id,
3316 );
3317 }
3318 ExecutionReport::MassStatus(_) => {
3319 }
3321 }
3322 }
3323
3324 fn confirm_submitted_report(&mut self, report: &OrderStatusReport) -> bool {
3325 if report.order_status == OrderStatus::Submitted
3326 && let Some(client_order_id) = report.client_order_id
3327 && self.submission_retention_enabled(client_order_id, None)
3328 && let Some(order) = self.get_order(client_order_id)
3329 && self
3330 .submissions
3331 .get(&client_order_id)
3332 .is_none_or(|submission| {
3333 submission.trader_id == order.trader_id()
3334 && submission.strategy_id == order.strategy_id()
3335 && submission.instrument_id == order.instrument_id()
3336 })
3337 && report.instrument_id == order.instrument_id()
3338 && order.account_id() == Some(report.account_id)
3339 && order
3340 .venue_order_id()
3341 .is_none_or(|id| id == report.venue_order_id)
3342 && self
3343 .cache
3344 .borrow()
3345 .client_order_id(&report.venue_order_id)
3346 .is_none_or(|id| *id == client_order_id)
3347 {
3348 if Self::submission_is_unacknowledged(&order) {
3349 let client_id = self.cache.borrow().client_id(&client_order_id).copied();
3350 self.track_submission(order.init_event(), client_id);
3351 }
3352
3353 if let Some(submission) = self.submissions.get_mut(&client_order_id) {
3354 submission.venue_confirmed = true;
3355 }
3356
3357 self.clear_recon_tracking(&client_order_id, false);
3358 self.record_local_activity(client_order_id);
3359 return true;
3360 }
3361
3362 false
3363 }
3364
3365 fn observe_order_report(&mut self, report: &OrderStatusReport) {
3366 let Some(client_order_id) = report.client_order_id else {
3367 return;
3368 };
3369
3370 if self.confirm_submitted_report(report) {
3371 return;
3372 }
3373
3374 let accepted_during_pending_command = report.order_status == OrderStatus::Accepted
3375 && self.get_order(client_order_id).is_some_and(|order| {
3376 matches!(
3377 order.status(),
3378 OrderStatus::PendingUpdate | OrderStatus::PendingCancel
3379 )
3380 });
3381
3382 if !matches!(
3383 report.order_status,
3384 OrderStatus::PendingUpdate | OrderStatus::PendingCancel
3385 ) && !accepted_during_pending_command
3386 {
3387 self.clear_recon_tracking(&client_order_id, report.order_status.is_closed());
3388 }
3389
3390 self.record_local_activity(client_order_id);
3394 }
3395
3396 pub fn purge_closed_orders(&mut self) {
3398 let Some(buffer_mins) = self.config.purge_closed_orders_buffer_mins else {
3399 return;
3400 };
3401
3402 let ts_now = self.timestamp_ns();
3403 let buffer_secs = mins_to_secs(u64::from(buffer_mins));
3404
3405 self.cache
3406 .borrow_mut()
3407 .purge_closed_orders(ts_now, buffer_secs);
3408 }
3409
3410 pub fn purge_closed_positions(&mut self) {
3412 let Some(buffer_mins) = self.config.purge_closed_positions_buffer_mins else {
3413 return;
3414 };
3415
3416 let ts_now = self.clock.borrow().timestamp_ns();
3417 let buffer_secs = mins_to_secs(u64::from(buffer_mins));
3418
3419 self.cache
3420 .borrow_mut()
3421 .purge_closed_positions(ts_now, buffer_secs);
3422 }
3423
3424 pub fn purge_account_events(&mut self) {
3426 let Some(lookback_mins) = self.config.purge_account_events_lookback_mins else {
3427 return;
3428 };
3429
3430 let ts_now = self.clock.borrow().timestamp_ns();
3431 let lookback_secs = mins_to_secs(u64::from(lookback_mins));
3432
3433 self.cache
3434 .borrow_mut()
3435 .purge_account_events(ts_now, lookback_secs);
3436 }
3437
3438 fn get_order(&self, client_order_id: ClientOrderId) -> Option<OrderAny> {
3439 self.cache().order(&client_order_id).map(|o| o.clone())
3440 }
3441
3442 fn get_order_by_venue_order_id(&self, venue_order_id: VenueOrderId) -> Option<OrderAny> {
3443 let cache = self.cache();
3444 cache
3445 .client_order_id(&venue_order_id)
3446 .and_then(|client_order_id| cache.order(client_order_id).map(|o| o.clone()))
3447 }
3448
3449 fn get_instrument(&self, instrument_id: &InstrumentId) -> Option<InstrumentAny> {
3450 self.cache().instrument(instrument_id).cloned()
3451 }
3452
3453 fn should_skip_order_report(&self, report: &OrderStatusReport) -> bool {
3454 let client_order_id = report.client_order_id.or_else(|| {
3455 self.cache
3456 .borrow()
3457 .client_order_id(&report.venue_order_id)
3458 .copied()
3459 });
3460
3461 if let Some(client_order_id) = client_order_id
3462 && self
3463 .config
3464 .filtered_client_order_ids
3465 .contains(&client_order_id)
3466 {
3467 log::debug!(
3468 "Skipping order report {client_order_id}: in filtered_client_order_ids list"
3469 );
3470 return true;
3471 }
3472
3473 if !self.should_reconcile_instrument(&report.instrument_id) {
3474 log::debug!(
3475 "Skipping order report for {}: not in reconciliation_instrument_ids",
3476 report.instrument_id
3477 );
3478 return true;
3479 }
3480
3481 false
3482 }
3483
3484 fn should_reconcile_instrument(&self, instrument_id: &InstrumentId) -> bool {
3486 self.config.reconciliation_instrument_ids.is_empty()
3487 || self
3488 .config
3489 .reconciliation_instrument_ids
3490 .contains(instrument_id)
3491 }
3492
3493 fn prepare_missing_order_query(&mut self, client_order_id: ClientOrderId) -> Option<OrderAny> {
3494 let order = self.get_order(client_order_id)?;
3495
3496 if self.submission_is_retained(client_order_id) {
3497 return None;
3498 }
3499
3500 if order.status().is_closed() {
3504 log::debug!(
3505 "Skipping missing-order resolution for {client_order_id}: already {}",
3506 order.status()
3507 );
3508 self.clear_recon_tracking(&client_order_id, true);
3509 return None;
3510 }
3511
3512 if self.order_activity.within(
3516 &client_order_id,
3517 Duration::from(self.config.open_check_threshold_ns),
3518 ) {
3519 return None;
3520 }
3521
3522 if order.is_inflight() && Self::submission_is_unacknowledged(&order) {
3523 let client_id = self.cache.borrow().client_id(&client_order_id).copied();
3524 self.track_submission(order.init_event(), client_id);
3525 }
3526
3527 let retries = self.order_recon_retries.entry(client_order_id).or_insert(0);
3528 *retries = retries.saturating_add(1);
3529
3530 if *retries < self.config.open_check_missing_retries {
3531 log::debug!(
3532 "Order {} not found at venue, retry {}/{}",
3533 client_order_id,
3534 retries,
3535 self.config.open_check_missing_retries
3536 );
3537 return None;
3538 }
3539
3540 Some(order)
3541 }
3542
3543 fn resolve_missing_order(&mut self, client_order_id: ClientOrderId) -> Vec<OrderEventAny> {
3544 let mut events = Vec::new();
3545
3546 let Some(order) = self.get_order(client_order_id) else {
3547 return events;
3548 };
3549
3550 if order.status().is_closed() {
3551 log::debug!(
3552 "Skipping missing-order resolution for {client_order_id}: already {}",
3553 order.status()
3554 );
3555 self.clear_recon_tracking(&client_order_id, true);
3556 return events;
3557 }
3558
3559 if self.order_activity.within(
3560 &client_order_id,
3561 Duration::from(self.config.open_check_threshold_ns),
3562 ) {
3563 log::debug!(
3564 "Deferring missing-order resolution for {client_order_id}: recent local activity"
3565 );
3566 return events;
3567 }
3568
3569 let retries = self.recon_check_retry_count(&client_order_id);
3570 let ts_now = self.clock.borrow().timestamp_ns();
3571
3572 let exhausted_retries = retries.max(self.config.open_check_missing_retries);
3575
3576 if self.submission_recovery_pending(client_order_id)
3577 && !self.order_inflight_checks.contains_key(&client_order_id)
3578 {
3579 self.exhaust_submission(
3580 client_order_id,
3581 SubmissionRecoverySource::MissingOrder,
3582 exhausted_retries,
3583 );
3584 return events;
3585 }
3586
3587 match order.status() {
3588 OrderStatus::Accepted | OrderStatus::Submitted => {
3589 if order.status() == OrderStatus::Submitted
3590 && self.exhaust_submission(
3591 client_order_id,
3592 SubmissionRecoverySource::MissingOrder,
3593 exhausted_retries,
3594 )
3595 {
3596 return events;
3597 }
3598 log::warn!(
3599 "Order {client_order_id} not found at venue after {retries} retries and a targeted query, marking as REJECTED"
3600 );
3601
3602 if let Some(rejected) =
3603 create_reconciliation_rejected(&order, Some("NOT_FOUND_AT_VENUE"), ts_now)
3604 {
3605 events.push(rejected);
3606 }
3607 }
3608 OrderStatus::PartiallyFilled => {
3609 log::warn!(
3610 "Order {client_order_id} not found at venue after {retries} retries and a targeted query, marking as CANCELED"
3611 );
3612 events.push(create_reconciliation_canceled(&order, ts_now));
3613 }
3614 OrderStatus::PendingUpdate | OrderStatus::PendingCancel => {
3615 log::debug!(
3616 "Deferring resolution for {client_order_id}: still inflight as {}",
3617 order.status()
3618 );
3619
3620 if self.submission_recovery_pending(client_order_id) {
3621 return events;
3622 }
3623
3624 self.order_recon_retries.shift_remove(&client_order_id);
3633 if let Some(check) = self.order_inflight_checks.get_mut(&client_order_id) {
3634 check.retry_count = 0;
3635 check.last_query_at = Some(dst::time::Instant::now());
3636 }
3637
3638 self.order_query_recency.mark(client_order_id);
3639 return events;
3640 }
3641 status => {
3642 log::warn!(
3643 "Skipping missing-order resolution for {client_order_id}: unexpected status {status}"
3644 );
3645 }
3646 }
3647
3648 self.clear_recon_tracking(&client_order_id, true);
3649 events
3650 }
3651
3652 fn position_quantity_comparison(
3654 &self,
3655 key: InstrumentAccountKey,
3656 venue_reports: &[PositionStatusReport],
3657 ) -> PositionQuantityComparison {
3658 let (instrument_id, account_id) = key;
3659
3660 let cached_positions = {
3661 let cache = self.cache.borrow();
3662 cache
3663 .positions_open(None, Some(&instrument_id), None, Some(&account_id), None)
3664 .into_iter()
3665 .map(|position| (*position).clone())
3666 .collect::<Vec<_>>()
3667 };
3668
3669 let (cached_signed_qty, cached_long_qty, cached_short_qty) =
3670 position_qty_aggregates(cached_positions.iter().map(Position::signed_decimal_qty));
3671 let (venue_signed_qty, venue_long_qty, venue_short_qty) =
3672 position_qty_aggregates(venue_reports.iter().map(|report| report.signed_decimal_qty));
3673 let nonflat_count = venue_reports
3674 .iter()
3675 .filter(|report| report.signed_decimal_qty != Decimal::ZERO)
3676 .count();
3677 let venue_report = venue_reports
3678 .iter()
3679 .find(|report| report.signed_decimal_qty != Decimal::ZERO)
3680 .or_else(|| venue_reports.last())
3681 .cloned();
3682 let venue_has_side_reports = venue_reports.iter().any(PositionStatusReport::is_long)
3683 && venue_reports.iter().any(PositionStatusReport::is_short);
3684
3685 PositionQuantityComparison {
3686 cached_positions,
3687 cached_signed_qty,
3688 cached_long_qty,
3689 cached_short_qty,
3690 venue_signed_qty,
3691 venue_long_qty,
3692 venue_short_qty,
3693 nonflat_count,
3694 venue_report,
3695 venue_has_side_reports,
3696 }
3697 }
3698
3699 fn check_position_discrepancy(
3700 &mut self,
3701 key: InstrumentAccountKey,
3702 venue_reports: &[PositionStatusReport],
3703 ) -> Option<Vec<OrderEventAny>> {
3704 let (instrument_id, account_id) = key;
3705 let comparison = self.position_quantity_comparison(key, venue_reports);
3706 let tolerance = self.position_reconciliation_tolerance(account_id);
3707 let quantities_match = comparison.quantities_match(tolerance);
3708 let report_shape = comparison.report_shape();
3709 let PositionQuantityComparison {
3710 cached_positions,
3711 cached_signed_qty,
3712 cached_long_qty,
3713 cached_short_qty,
3714 venue_signed_qty,
3715 venue_long_qty,
3716 venue_short_qty,
3717 venue_report,
3718 ..
3719 } = comparison;
3720
3721 if quantities_match {
3722 self.clear_position_reconciliation(&key);
3723 return None;
3724 }
3725
3726 if !self.config.generate_missing_orders {
3727 log::debug!(
3728 "Discrepancy for {instrument_id} position when `generate_missing_orders` disabled, skipping"
3729 );
3730 return None;
3731 }
3732
3733 let ts_now = self.clock.borrow().timestamp_ns();
3734
3735 if self.position_activity_is_recent(&key) {
3737 log::debug!(
3738 "Skipping position reconciliation for {instrument_id}: recent activity within threshold"
3739 );
3740 return None;
3741 }
3742
3743 let retries = self.position_reconciliation_retries(&key, report_shape);
3744
3745 if retries >= self.config.position_check_retries {
3746 return None;
3747 }
3748
3749 if report_shape == PositionReportShape::MultiLeg {
3750 let new_retries = retries + 1;
3751 self.set_position_reconciliation_retries(key, report_shape, new_retries);
3752 log::warn!(
3753 "Deferring position reconciliation for {instrument_id}/{account_id}: venue reports have ambiguous side aggregates (cached net={cached_signed_qty}, long={cached_long_qty}, short={cached_short_qty}; venue net={venue_signed_qty}, long={venue_long_qty}, short={venue_short_qty})"
3754 );
3755
3756 if new_retries >= self.config.position_check_retries {
3757 log::error!(
3758 "Position discrepancy for {instrument_id}/{account_id} unresolved after {} attempts; no further reconciliation attempts will be made for the current report shape",
3759 self.config.position_check_retries,
3760 );
3761 }
3762
3763 return None;
3764 }
3765
3766 log::warn!(
3767 "Position discrepancy detected for {instrument_id}: cached_signed_qty={cached_signed_qty}, venue_signed_qty={venue_signed_qty}"
3768 );
3769
3770 let Some(instrument) = self.get_instrument(&instrument_id) else {
3771 log::debug!("Cannot reconcile position for {instrument_id}: instrument not in cache");
3772 let new_retries = retries + 1;
3773 self.set_position_reconciliation_retries(key, report_shape, new_retries);
3774 if new_retries >= self.config.position_check_retries {
3775 log::error!(
3776 "Position discrepancy for {instrument_id} unresolved after {} attempts \
3777 (cached_qty={cached_signed_qty}, venue_qty={venue_signed_qty}); \
3778 no further reconciliation attempts will be made for the current report shape",
3779 self.config.position_check_retries,
3780 );
3781 }
3782
3783 return None;
3784 };
3785
3786 let cached_avg_px = position_avg_px(&cached_positions);
3787 let venue_avg_px = venue_report.as_ref().and_then(|r| r.avg_px_open);
3788 let venue_position_id = venue_report
3789 .as_ref()
3790 .and_then(|report| report.venue_position_id);
3791 let venue_ts_last = venue_report.as_ref().map_or(ts_now, |r| r.ts_last);
3792
3793 let crosses_zero = (cached_signed_qty > Decimal::ZERO && venue_signed_qty < Decimal::ZERO)
3794 || (cached_signed_qty < Decimal::ZERO && venue_signed_qty > Decimal::ZERO);
3795
3796 let result = if crosses_zero {
3797 let position_ids = match venue_position_id {
3798 Some(open_position_id) => match cached_positions.as_slice() {
3799 [position] => Some((Some(position.id), Some(open_position_id))),
3800 _ => {
3801 log::warn!(
3802 "Deferring hedge cross-zero reconciliation for {instrument_id}/{account_id}: cached and venue position identities are ambiguous"
3803 );
3804 None
3805 }
3806 },
3807 None => Some((None, None)),
3808 };
3809
3810 position_ids.and_then(|(close_position_id, open_position_id)| {
3811 self.reconcile_cross_zero_position(
3812 &instrument,
3813 account_id,
3814 cached_signed_qty,
3815 cached_avg_px,
3816 venue_signed_qty,
3817 venue_avg_px,
3818 close_position_id,
3819 open_position_id,
3820 ts_now,
3821 venue_ts_last,
3822 )
3823 })
3824 } else {
3825 let qty_diff = venue_signed_qty - cached_signed_qty;
3826
3827 let order_side = if qty_diff > Decimal::ZERO {
3828 OrderSide::Buy
3829 } else {
3830 OrderSide::Sell
3831 };
3832
3833 let reconciliation_px = calculate_reconciliation_price(
3834 cached_signed_qty,
3835 cached_avg_px,
3836 venue_signed_qty,
3837 venue_avg_px,
3838 venue_report
3839 .as_ref()
3840 .map(|report| report.avg_px_open_reconciliation)
3841 .unwrap_or_default(),
3842 venue_report
3843 .as_ref()
3844 .and_then(|report| report.avg_px_open_precision),
3845 );
3846
3847 reconciliation_px
3848 .or(venue_avg_px)
3849 .or(cached_avg_px)
3850 .and_then(|fill_px| {
3851 let quantity =
3852 Quantity::from_decimal_dp(qty_diff.abs(), instrument.size_precision())
3853 .ok()?;
3854 let order_report = create_position_reconciliation_report(
3855 &instrument,
3856 account_id,
3857 order_side,
3858 quantity,
3859 fill_px,
3860 venue_position_id,
3861 None,
3862 ts_now,
3863 venue_ts_last,
3864 );
3865
3866 log::info!(
3867 color = LogColor::Blue as u8;
3868 "Generating synthetic fill for position reconciliation {instrument_id}: side={order_side:?}, qty={}, px={fill_px}", qty_diff.abs(),
3869 );
3870
3871 let (events, _) = self.handle_external_order(
3872 &order_report,
3873 account_id,
3874 &instrument,
3875 &[],
3876 true,
3877 None,
3878 None,
3879 );
3880 Some(events)
3881 })
3882 };
3883
3884 if result.as_ref().is_none_or(Vec::is_empty) {
3886 let new_retries = retries + 1;
3887 self.set_position_reconciliation_retries(key, report_shape, new_retries);
3888 if new_retries >= self.config.position_check_retries {
3889 log::error!(
3890 "Position discrepancy for {} unresolved after {} attempts \
3891 (cached_qty={}, venue_qty={}); \
3892 no further reconciliation attempts will be made for the current report shape",
3893 instrument_id,
3894 self.config.position_check_retries,
3895 cached_signed_qty,
3896 venue_signed_qty,
3897 );
3898 }
3899 } else {
3900 self.clear_position_reconciliation(&key);
3901 }
3902
3903 result
3904 }
3905
3906 #[expect(clippy::too_many_arguments)]
3909 fn reconcile_cross_zero_position(
3910 &self,
3911 instrument: &InstrumentAny,
3912 account_id: AccountId,
3913 cached_signed_qty: Decimal,
3914 cached_avg_px: Option<Decimal>,
3915 venue_signed_qty: Decimal,
3916 venue_avg_px: Option<Decimal>,
3917 close_position_id: Option<PositionId>,
3918 open_position_id: Option<PositionId>,
3919 ts_now: UnixNanos,
3920 venue_ts_last: UnixNanos,
3921 ) -> Option<Vec<OrderEventAny>> {
3922 let instrument_id = instrument.id();
3923
3924 log::info!(
3925 color = LogColor::Blue as u8;
3926 "Position crosses zero for {instrument_id}: cached={cached_signed_qty}, venue={venue_signed_qty}. Splitting into two fills",
3927 );
3928
3929 let close_qty = cached_signed_qty.abs();
3930
3931 let close_side = if cached_signed_qty < Decimal::ZERO {
3932 OrderSide::Buy } else {
3934 OrderSide::Sell };
3936
3937 let open_qty = venue_signed_qty.abs();
3938
3939 let open_side = if venue_signed_qty > Decimal::ZERO {
3940 OrderSide::Buy } else {
3942 OrderSide::Sell };
3944
3945 let Some(close_px) = cached_avg_px else {
3946 log::warn!("Cannot close position for {instrument_id}: no cached average price");
3947 return None;
3948 };
3949
3950 let open_report = match venue_avg_px {
3951 Some(open_px) => {
3952 let quantity =
3953 Quantity::from_decimal_dp(open_qty, instrument.size_precision()).ok()?;
3954 let report = create_position_reconciliation_report(
3955 instrument,
3956 account_id,
3957 open_side,
3958 quantity,
3959 open_px,
3960 open_position_id,
3961 Some("OPEN"),
3962 ts_now,
3963 venue_ts_last,
3964 );
3965 Some((report, open_px))
3966 }
3967 None => None,
3968 };
3969
3970 let quantity = Quantity::from_decimal_dp(close_qty, instrument.size_precision()).ok()?;
3971 let close_report = create_position_reconciliation_report(
3972 instrument,
3973 account_id,
3974 close_side,
3975 quantity,
3976 close_px,
3977 close_position_id,
3978 Some("CLOSE"),
3979 ts_now,
3980 venue_ts_last,
3981 );
3982
3983 log::info!(
3984 color = LogColor::Blue as u8;
3985 "Generating close fill for cross-zero {instrument_id}: side={close_side:?}, qty={close_qty}, px={close_px}",
3986 );
3987
3988 let (close_events, _) = self.handle_external_order(
3989 &close_report,
3990 account_id,
3991 instrument,
3992 &[],
3993 true,
3994 None,
3995 None,
3996 );
3997 let mut all_events = close_events;
3998
3999 if let Some((open_report, open_px)) = open_report {
4000 log::info!(
4001 color = LogColor::Blue as u8;
4002 "Generating open fill for cross-zero {instrument_id}: side={open_side:?}, qty={open_qty}, px={open_px}",
4003 );
4004
4005 let (open_events, _) = self.handle_external_order(
4006 &open_report,
4007 account_id,
4008 instrument,
4009 &[],
4010 true,
4011 None,
4012 None,
4013 );
4014 all_events.extend(open_events);
4015 } else {
4016 log::warn!("Cannot open new position for {instrument_id}: no venue average price");
4017 }
4018
4019 Some(all_events)
4020 }
4021
4022 fn reconcile_position_report(
4023 &self,
4024 report: &PositionStatusReport,
4025 account_id: AccountId,
4026 instruments_with_unattributed_fills: &IndexSet<InstrumentId>,
4027 ) -> Option<Vec<OrderEventAny>> {
4028 match report.venue_position_id {
4029 Some(venue_position_id) => self.reconcile_position_report_hedging(
4030 report,
4031 venue_position_id,
4032 account_id,
4033 instruments_with_unattributed_fills,
4034 ),
4035 None => self.reconcile_position_report_netting(report, account_id),
4036 }
4037 }
4038
4039 fn reconcile_position_report_hedging(
4040 &self,
4041 report: &PositionStatusReport,
4042 venue_position_id: PositionId,
4043 account_id: AccountId,
4044 instruments_with_unattributed_fills: &IndexSet<InstrumentId>,
4045 ) -> Option<Vec<OrderEventAny>> {
4046 if instruments_with_unattributed_fills.contains(&report.instrument_id) {
4049 log::debug!(
4050 "Skipping hedge position {venue_position_id} reconciliation: unattributed fills in batch"
4051 );
4052 return None;
4053 }
4054
4055 log::debug!(
4056 "Reconciling HEDGE position for {}, venue_position_id={}",
4057 report.instrument_id,
4058 venue_position_id
4059 );
4060
4061 let position = {
4062 let cache = self.cache.borrow();
4063 cache.position_owned(&venue_position_id)
4064 };
4065
4066 match position {
4067 Some(position) => {
4068 let cached_signed_qty = position.signed_decimal_qty();
4069 let venue_signed_qty = report.signed_decimal_qty;
4070
4071 if cached_signed_qty == venue_signed_qty {
4072 log::debug!(
4073 "Hedge position {venue_position_id} matches venue: qty={cached_signed_qty}"
4074 );
4075 return None;
4076 }
4077
4078 if !self.config.generate_missing_orders {
4079 log::error!(
4080 "Cannot reconcile {} {}: position net qty {} != reported net qty {} \
4081 and `generate_missing_orders` is disabled",
4082 report.instrument_id,
4083 venue_position_id,
4084 cached_signed_qty,
4085 venue_signed_qty
4086 );
4087 return None;
4088 }
4089
4090 self.reconcile_hedge_position_discrepancy(
4091 report,
4092 venue_position_id,
4093 account_id,
4094 &position,
4095 cached_signed_qty,
4096 )
4097 }
4098 None => {
4099 if report.signed_decimal_qty == Decimal::ZERO {
4100 return None;
4101 }
4102
4103 if !self.config.generate_missing_orders {
4104 log::error!(
4105 "Cannot reconcile position: {venue_position_id} not found and `generate_missing_orders` is disabled"
4106 );
4107 return None;
4108 }
4109
4110 self.reconcile_missing_hedge_position(report, venue_position_id, account_id)
4111 }
4112 }
4113 }
4114
4115 fn reconcile_hedge_position_discrepancy(
4116 &self,
4117 report: &PositionStatusReport,
4118 venue_position_id: PositionId,
4119 account_id: AccountId,
4120 position: &Position,
4121 cached_signed_qty: Decimal,
4122 ) -> Option<Vec<OrderEventAny>> {
4123 let instrument = self.get_instrument(&report.instrument_id)?;
4124 let venue_signed_qty = report.signed_decimal_qty;
4125
4126 let diff = (cached_signed_qty - venue_signed_qty).abs();
4127 let diff_qty = Quantity::from_decimal_dp(diff, instrument.size_precision()).ok()?;
4128
4129 if diff_qty.is_zero() {
4130 log::debug!(
4131 "Difference quantity rounds to zero for {}, skipping",
4132 instrument.id()
4133 );
4134 return None;
4135 }
4136
4137 log::warn!(
4138 "Hedge position discrepancy for {} {}: cached={}, venue={}, generating reconciliation order",
4139 report.instrument_id,
4140 venue_position_id,
4141 cached_signed_qty,
4142 venue_signed_qty
4143 );
4144
4145 let current_avg_px = if position.avg_px_open > Decimal::ZERO {
4146 Some(position.avg_px_open)
4147 } else {
4148 None
4149 };
4150
4151 self.create_position_reconciliation_order(
4152 report,
4153 account_id,
4154 &instrument,
4155 cached_signed_qty,
4156 diff_qty,
4157 current_avg_px,
4158 )
4159 }
4160
4161 fn reconcile_missing_hedge_position(
4162 &self,
4163 report: &PositionStatusReport,
4164 venue_position_id: PositionId,
4165 account_id: AccountId,
4166 ) -> Option<Vec<OrderEventAny>> {
4167 let instrument = self.get_instrument(&report.instrument_id)?;
4168 let venue_signed_qty = report.signed_decimal_qty;
4169
4170 let qty = venue_signed_qty.abs();
4171 let diff_qty = Quantity::from_decimal_dp(qty, instrument.size_precision()).ok()?;
4172
4173 if diff_qty.is_zero() {
4174 return None;
4175 }
4176
4177 log::warn!(
4178 "Missing hedge position for {} {}: venue reports {}, generating reconciliation order",
4179 report.instrument_id,
4180 venue_position_id,
4181 venue_signed_qty
4182 );
4183
4184 self.create_position_reconciliation_order(
4185 report,
4186 account_id,
4187 &instrument,
4188 Decimal::ZERO,
4189 diff_qty,
4190 None,
4191 )
4192 }
4193
4194 fn reconcile_position_report_netting(
4195 &self,
4196 report: &PositionStatusReport,
4197 account_id: AccountId,
4198 ) -> Option<Vec<OrderEventAny>> {
4199 let instrument_id = report.instrument_id;
4200
4201 log::debug!("Reconciling NET position for {instrument_id}");
4202
4203 let instrument = self.get_instrument(&instrument_id)?;
4204
4205 let (cached_signed_qty, cached_avg_px) = {
4206 let cache = self.cache.borrow();
4207 let positions =
4208 cache.positions_open(None, Some(&instrument_id), None, Some(&account_id), None);
4209 let cached_signed_qty = positions
4210 .iter()
4211 .map(|position| position.signed_decimal_qty())
4212 .sum::<Decimal>();
4213 let cached_avg_px = position_avg_px(positions.iter().map(|position| &**position));
4214
4215 (cached_signed_qty, cached_avg_px)
4216 };
4217
4218 let venue_signed_qty = report.signed_decimal_qty;
4219
4220 log::debug!("venue_signed_qty={venue_signed_qty}, cached_signed_qty={cached_signed_qty}");
4221
4222 let diff = (cached_signed_qty - venue_signed_qty).abs();
4223 let tolerance = self.position_reconciliation_tolerance(account_id);
4224 if diff <= tolerance {
4225 log::debug!("Position quantities match for {instrument_id}, no reconciliation needed");
4226 return None;
4227 }
4228
4229 if !self.config.generate_missing_orders {
4230 log::debug!(
4231 "Discrepancy for {instrument_id} position when `generate_missing_orders` disabled, skipping"
4232 );
4233 return None;
4234 }
4235
4236 let diff_qty = Quantity::from_decimal_dp(diff, instrument.size_precision()).ok()?;
4237
4238 if diff_qty.is_zero() {
4239 log::debug!(
4240 "Difference quantity rounds to zero for {instrument_id}, skipping order generation"
4241 );
4242 return None;
4243 }
4244
4245 let crosses_zero = (cached_signed_qty > Decimal::ZERO && venue_signed_qty < Decimal::ZERO)
4246 || (cached_signed_qty < Decimal::ZERO && venue_signed_qty > Decimal::ZERO);
4247
4248 if crosses_zero {
4249 let ts_now = self.clock.borrow().timestamp_ns();
4250 return self.reconcile_cross_zero_position(
4251 &instrument,
4252 account_id,
4253 cached_signed_qty,
4254 cached_avg_px,
4255 venue_signed_qty,
4256 report.avg_px_open,
4257 None,
4258 None,
4259 ts_now,
4260 report.ts_last,
4261 );
4262 }
4263
4264 if cached_signed_qty == Decimal::ZERO {
4265 return self.create_position_from_report(report, account_id, &instrument);
4266 }
4267
4268 self.create_position_reconciliation_order(
4269 report,
4270 account_id,
4271 &instrument,
4272 cached_signed_qty,
4273 diff_qty,
4274 cached_avg_px,
4275 )
4276 }
4277
4278 fn create_position_from_report(
4283 &self,
4284 report: &PositionStatusReport,
4285 account_id: AccountId,
4286 instrument: &InstrumentAny,
4287 ) -> Option<Vec<OrderEventAny>> {
4288 let instrument_id = report.instrument_id;
4289 let venue_signed_qty = report.signed_decimal_qty;
4290
4291 if venue_signed_qty == Decimal::ZERO {
4292 return None;
4293 }
4294
4295 let order_side = if venue_signed_qty > Decimal::ZERO {
4296 OrderSide::Buy
4297 } else {
4298 OrderSide::Sell
4299 };
4300
4301 let qty_abs = venue_signed_qty.abs();
4302 let venue_avg_px = report.avg_px_open?;
4303
4304 let ts_now = self.clock.borrow().timestamp_ns();
4305 let order_qty = Quantity::from_decimal_dp(qty_abs, instrument.size_precision()).ok()?;
4306 let order_report = create_position_reconciliation_report(
4307 instrument,
4308 account_id,
4309 order_side,
4310 order_qty,
4311 venue_avg_px,
4312 report.venue_position_id,
4313 None,
4314 ts_now,
4315 report.ts_last,
4316 );
4317
4318 log::info!(
4319 color = LogColor::Blue as u8;
4320 "Creating position from venue report for {instrument_id}: side={order_side:?}, qty={qty_abs}, avg_px={venue_avg_px}",
4321 );
4322
4323 let (events, _) = self.handle_external_order(
4324 &order_report,
4325 account_id,
4326 instrument,
4327 &[],
4328 true,
4329 None,
4330 None,
4331 );
4332 Some(events)
4333 }
4334
4335 fn create_position_reconciliation_order(
4336 &self,
4337 report: &PositionStatusReport,
4338 account_id: AccountId,
4339 instrument: &InstrumentAny,
4340 cached_signed_qty: Decimal,
4341 diff_qty: Quantity,
4342 current_avg_px: Option<Decimal>,
4343 ) -> Option<Vec<OrderEventAny>> {
4344 let venue_signed_qty = report.signed_decimal_qty;
4345 let instrument_id = report.instrument_id;
4346
4347 let order_side = if venue_signed_qty > cached_signed_qty {
4348 OrderSide::Buy
4349 } else {
4350 OrderSide::Sell
4351 };
4352
4353 let reconciliation_px = calculate_reconciliation_price(
4354 cached_signed_qty,
4355 current_avg_px,
4356 venue_signed_qty,
4357 report.avg_px_open,
4358 report.avg_px_open_reconciliation,
4359 report.avg_px_open_precision,
4360 );
4361
4362 let fill_px = reconciliation_px
4363 .or(report.avg_px_open)
4364 .or(current_avg_px)?;
4365
4366 let ts_now = self.clock.borrow().timestamp_ns();
4367 let order_report = create_position_reconciliation_report(
4368 instrument,
4369 account_id,
4370 order_side,
4371 diff_qty,
4372 fill_px,
4373 report.venue_position_id,
4374 None,
4375 ts_now,
4376 report.ts_last,
4377 );
4378
4379 log::info!(
4380 color = LogColor::Blue as u8;
4381 "Generating reconciliation order for {instrument_id}: side={order_side:?}, qty={diff_qty}, px={fill_px}",
4382 );
4383
4384 let (events, _) = self.handle_external_order(
4385 &order_report,
4386 account_id,
4387 instrument,
4388 &[],
4389 true,
4390 None,
4391 None,
4392 );
4393 Some(events)
4394 }
4395
4396 fn reconcile_order_report(
4397 &self,
4398 order: &OrderAny,
4399 report: &OrderStatusReport,
4400 instrument: Option<&InstrumentAny>,
4401 commission_client: Option<&dyn ExecutionClient>,
4402 ) -> anyhow::Result<Vec<OrderEventAny>> {
4403 let has_missing_fills = terminal_report_has_missing_fills(report, order.filled_qty());
4404 anyhow::ensure!(
4405 !has_missing_fills,
4406 "terminal report for {} has unaccounted fills; waiting for fill reports",
4407 order.client_order_id(),
4408 );
4409 let ts_now = self.clock.borrow().timestamp_ns();
4410
4411 let mut events = Vec::new();
4412 let mut amended = None;
4413
4414 if order.status() == OrderStatus::PendingUpdate
4415 && matches!(
4416 report.order_status,
4417 OrderStatus::Accepted | OrderStatus::Triggered | OrderStatus::PartiallyFilled
4418 )
4419 && should_reconciliation_update(order, report)
4420 {
4421 events = generate_reconciliation_order_pre_fill_events(order, report, ts_now);
4422 let mut working = order.clone();
4423 for event in &events {
4424 working
4425 .apply(event.clone())
4426 .context("failed to project reconciled amendment")?;
4427 }
4428
4429 amended = Some(working);
4430 }
4431
4432 let order = amended.as_ref().unwrap_or(order);
4433 let commission = if matches!(
4434 report.order_status,
4435 OrderStatus::PartiallyFilled | OrderStatus::Filled
4436 ) && report.filled_qty > order.filled_qty()
4437 && let Some(instrument) = instrument
4438 {
4439 let fill_qty = report.filled_qty - order.filled_qty();
4440 let price_and_liquidity =
4441 incremental_inferred_fill_price_and_liquidity(order, report, instrument);
4442
4443 resolve_inferred_fill_commission(
4444 fill_qty,
4445 price_and_liquidity,
4446 instrument,
4447 commission_client,
4448 )?
4449 } else {
4450 None
4451 };
4452
4453 if matches!(
4454 report.order_status,
4455 OrderStatus::Canceled | OrderStatus::Expired
4456 ) && order.status() == OrderStatus::Filled
4457 && report.filled_qty == order.filled_qty()
4458 {
4459 return Ok(Vec::new());
4460 }
4461
4462 events.extend(reconcile_order_report_with_commission(
4463 order, report, instrument, ts_now, commission,
4464 ));
4465 Ok(events)
4466 }
4467
4468 #[expect(
4470 clippy::too_many_arguments,
4471 reason = "Snapshot and continuous reports share fill projection"
4472 )]
4473 fn reconcile_order_with_fills(
4474 &mut self,
4475 is_snapshot: bool,
4476 order: &OrderAny,
4477 report: &OrderStatusReport,
4478 fills: &[&FillReport],
4479 instrument: Option<&InstrumentAny>,
4480 fill_queue: &mut ReconciliationFillQueue,
4481 commission_client: Option<&dyn ExecutionClient>,
4482 ) -> Vec<OrderEventAny> {
4483 self.confirm_submitted_report(report);
4484 let mut events = Vec::new();
4485 let mut working = order.clone();
4486 let mut sorted_fills: Vec<&FillReport> = fills.to_vec();
4487 sorted_fills.sort_by_key(|f| f.ts_event);
4488
4489 let ts_now = self.clock.borrow().timestamp_ns();
4490
4491 if matches!(
4492 report.order_status,
4493 OrderStatus::Canceled | OrderStatus::Expired
4494 ) && report.ts_triggered.is_some()
4495 && working.status() != OrderStatus::Triggered
4496 && TRIGGERABLE_ORDER_TYPES.contains(&working.order_type())
4497 {
4498 let triggered = create_reconciliation_triggered(&working, report, ts_now);
4499 if working.apply(triggered.clone()).is_ok() {
4500 events.push(triggered);
4501 }
4502 }
4503
4504 let requires_snapshot_projection = !sorted_fills.is_empty()
4505 || is_snapshot
4506 && (report.order_status == OrderStatus::Voided
4507 || report.filled_qty < working.filled_qty()
4508 || report.order_status == OrderStatus::Filled
4509 && report.quantity < working.quantity());
4510 if !requires_snapshot_projection {
4511 match self.reconcile_order_report(&working, report, instrument, commission_client) {
4512 Ok(order_events) => events.extend(order_events),
4513 Err(e) => log::error!(
4514 "Deferring order reconciliation for {}: {e}",
4515 order.client_order_id(),
4516 ),
4517 }
4518
4519 return events;
4520 }
4521
4522 for event in generate_reconciliation_order_pre_fill_events(&working, report, ts_now) {
4523 if let Err(e) = working.apply(event.clone()) {
4524 log::warn!(
4525 "Cannot project reconciliation event for {}: {e}",
4526 order.client_order_id()
4527 );
4528 return events;
4529 }
4530
4531 events.push(event);
4532 }
4533
4534 if let Some(inst) = instrument {
4535 for fill in sorted_fills {
4536 let Some((event, fill_key)) =
4537 self.create_order_fill(&working, fill, inst, &fill_queue.pending_fill_keys)
4538 else {
4539 continue;
4540 };
4541
4542 if let Err(e) = working.apply(OrderEventAny::Filled(event.clone())) {
4543 if self.is_fill_applied(&event, fill_key) {
4544 self.fills_processed.mark(fill_key);
4545 continue;
4546 }
4547
4548 log::warn!(
4549 "Cannot project reconciliation fill for {}: {e}",
4550 order.client_order_id()
4551 );
4552 return events;
4553 }
4554
4555 fill_queue.push(&mut events, event, fill_key);
4556 }
4557 }
4558
4559 if !is_snapshot {
4561 match self.reconcile_order_report(&working, report, instrument, commission_client) {
4562 Ok(order_events) => events.extend(order_events),
4563 Err(e) => log::warn!("Deferring order reconciliation: {e}"),
4564 }
4565
4566 return events;
4567 }
4568
4569 if terminal_report_has_missing_fills(report, working.filled_qty()) {
4570 log::warn!(
4571 "Deferring terminal reconciliation for {}: fill reports are incomplete",
4572 order.client_order_id(),
4573 );
4574 return events;
4575 }
4576
4577 let commission = if report.filled_qty > working.filled_qty()
4578 && let Some(instrument) = instrument
4579 {
4580 let fill_qty = report.filled_qty - working.filled_qty();
4581
4582 let price_and_liquidity =
4583 incremental_inferred_fill_price_and_liquidity(&working, report, instrument);
4584
4585 match resolve_inferred_fill_commission(
4586 fill_qty,
4587 price_and_liquidity,
4588 instrument,
4589 commission_client,
4590 ) {
4591 Ok(commission) => commission,
4592 Err(e) => {
4593 log::error!(
4594 "Deferring inferred fill for {}: venue commission calculation failed: {e}",
4595 order.client_order_id(),
4596 );
4597 return events;
4598 }
4599 }
4600 } else {
4601 None
4602 };
4603
4604 for event in generate_reconciliation_order_snapshot_events_with_commission(
4605 &working, report, instrument, ts_now, commission,
4606 ) {
4607 if let Err(e) = working.apply(event.clone()) {
4608 log::warn!(
4609 "Cannot project reconciliation snapshot event for {}: {e}",
4610 order.client_order_id()
4611 );
4612 break;
4613 }
4614
4615 events.push(event);
4616 }
4617
4618 events
4619 }
4620
4621 #[expect(clippy::too_many_arguments)]
4622 fn handle_external_order(
4623 &self,
4624 report: &OrderStatusReport,
4625 account_id: AccountId,
4626 instrument: &InstrumentAny,
4627 fills: &[&FillReport],
4628 is_synthetic: bool,
4629 fill_queue: Option<&mut ReconciliationFillQueue>,
4630 commission_client: Option<&dyn ExecutionClient>,
4631 ) -> (Vec<OrderEventAny>, Option<ExternalOrderMetadata>) {
4632 let claimed_strategy = self
4633 .cache
4634 .borrow()
4635 .external_order_claim(&report.instrument_id);
4636
4637 let (strategy_id, tags) = if let Some(claimed_strategy) = claimed_strategy {
4638 let order_id = report
4639 .client_order_id
4640 .map_or_else(|| report.venue_order_id.to_string(), |id| id.to_string());
4641 log::info!(
4642 color = LogColor::Blue as u8;
4643 "External order {} for {} claimed by strategy {}",
4644 order_id,
4645 report.instrument_id,
4646 claimed_strategy,
4647 );
4648
4649 (
4651 claimed_strategy,
4652 is_synthetic.then(|| vec![*TAG_RECONCILIATION]),
4653 )
4654 } else {
4655 let tag = if is_synthetic {
4657 *TAG_RECONCILIATION
4658 } else {
4659 *TAG_VENUE
4660 };
4661
4662 (StrategyId::from("EXTERNAL"), Some(vec![tag]))
4663 };
4664
4665 if self.config.filter_unclaimed_external && claimed_strategy.is_none() && !is_synthetic {
4667 return (Vec::new(), None);
4668 }
4669
4670 let client_order_id = report
4671 .client_order_id
4672 .unwrap_or_else(|| ClientOrderId::from(report.venue_order_id.as_str()));
4673
4674 if !report.quantity.is_positive() {
4675 log::error!(
4676 "Skipping external order {} ({}) for {}: non-positive quantity in report {:?}",
4677 client_order_id,
4678 report.venue_order_id,
4679 report.instrument_id,
4680 report,
4681 );
4682 return (Vec::new(), None);
4683 }
4684
4685 let ts_now = self.clock.borrow().timestamp_ns();
4686
4687 let Some(order_side) = report.order_side else {
4688 log::error!(
4689 "Skipping external order {} ({}) for {}: order side is not specified",
4690 client_order_id,
4691 report.venue_order_id,
4692 report.instrument_id,
4693 );
4694 return (Vec::new(), None);
4695 };
4696
4697 let initialized = match OrderInitialized::new_checked(
4698 self.config.trader_id,
4699 strategy_id,
4700 report.instrument_id,
4701 client_order_id,
4702 order_side,
4703 report.order_type,
4704 report.quantity,
4705 report.time_in_force,
4706 report.post_only,
4707 report.reduce_only,
4708 false, true, UUID4::new(),
4711 ts_now,
4712 ts_now,
4713 report.price,
4714 report.activation_price,
4715 report.trigger_price,
4716 report.trigger_type,
4717 report.limit_offset,
4718 report.trailing_offset,
4719 report.trailing_offset_type,
4720 report.expire_time,
4721 report.display_qty,
4722 None, None, report.contingency_type,
4725 report.order_list_id,
4726 report.linked_order_ids.clone(),
4727 report.parent_order_id,
4728 None, None, None, tags,
4732 ) {
4733 Ok(initialized) => initialized,
4734 Err(e) => {
4735 log::error!("Failed to create order from report: {e}");
4736 return (Vec::new(), None);
4737 }
4738 };
4739
4740 let initialized = OrderEventAny::Initialized(initialized);
4741
4742 let order = match OrderAny::from_events(vec![initialized.clone()]) {
4743 Ok(order) => order,
4744 Err(e) => {
4745 log::error!("Failed to create order from report: {e}");
4746 return (Vec::new(), None);
4747 }
4748 };
4749
4750 let replace_inferred_fill = !fills.is_empty()
4751 && matches!(
4752 report.order_status,
4753 OrderStatus::Accepted
4754 | OrderStatus::Canceled
4755 | OrderStatus::Expired
4756 | OrderStatus::Filled
4757 | OrderStatus::PartiallyFilled
4758 | OrderStatus::Triggered
4759 );
4760 let mut prepared_fills = Vec::new();
4761 let mut prepared_fill_keys = fill_queue
4762 .as_deref()
4763 .map(|queue| queue.pending_fill_keys.clone())
4764 .unwrap_or_default();
4765 let mut real_fill_total = Decimal::ZERO;
4766
4767 if replace_inferred_fill {
4768 let mut sorted_fills: Vec<&FillReport> = fills.to_vec();
4769 sorted_fills.sort_by_key(|fill| fill.ts_event);
4770
4771 if fill_queue.is_none() {
4772 log::error!(
4773 "Cannot reconcile external order {client_order_id}: fill queue is unavailable"
4774 );
4775 return (Vec::new(), None);
4776 }
4777
4778 for fill in sorted_fills {
4779 if let Some((fill_event, fill_key)) =
4780 self.create_order_fill(&order, fill, instrument, &prepared_fill_keys)
4781 {
4782 real_fill_total += fill.last_qty.as_decimal();
4783 prepared_fill_keys.insert(fill_key);
4784 prepared_fills.push((fill_event, fill_key));
4785 }
4786 }
4787 }
4788
4789 let report_filled = report.filled_qty.as_decimal();
4790
4791 let inferred_qty = if report_filled.is_zero()
4792 || matches!(
4793 report.order_status,
4794 OrderStatus::Accepted | OrderStatus::Triggered
4795 ) {
4796 None
4797 } else if replace_inferred_fill {
4798 if real_fill_total < report_filled {
4799 match Quantity::from_decimal_dp(
4800 report_filled - real_fill_total,
4801 instrument.size_precision(),
4802 ) {
4803 Ok(quantity) => Some(quantity),
4804 Err(e) => {
4805 log::error!(
4806 "Cannot reconcile external order {client_order_id}: residual fill quantity is invalid: {e}"
4807 );
4808 return (Vec::new(), None);
4809 }
4810 }
4811 } else {
4812 None
4813 }
4814 } else if matches!(
4815 report.order_status,
4816 OrderStatus::PartiallyFilled
4817 | OrderStatus::Filled
4818 | OrderStatus::Canceled
4819 | OrderStatus::Expired
4820 | OrderStatus::Voided
4821 ) {
4822 Some(report.filled_qty)
4823 } else {
4824 None
4825 };
4826
4827 let defer_terminal = !is_synthetic
4828 && claimed_strategy.is_some()
4829 && matches!(
4830 report.order_status,
4831 OrderStatus::Canceled | OrderStatus::Expired
4832 )
4833 && inferred_qty.is_some();
4834
4835 if defer_terminal {
4836 log::warn!(
4837 "Deferring terminal reconciliation for claimed order {client_order_id}: fill reports are incomplete"
4838 );
4839
4840 if prepared_fills.is_empty() {
4841 return (Vec::new(), None);
4842 }
4843 }
4844
4845 let inferred_commission = if is_synthetic || defer_terminal {
4846 None
4847 } else if let Some(inferred_qty) = inferred_qty {
4848 let price_and_liquidity = inferred_fill_price_and_liquidity(&order, report, instrument);
4849
4850 match resolve_inferred_fill_commission(
4851 inferred_qty,
4852 price_and_liquidity,
4853 instrument,
4854 commission_client,
4855 ) {
4856 Ok(commission) => commission,
4857 Err(e) => {
4858 log::error!(
4859 "Deferring external order {client_order_id}: venue commission calculation failed: {e}"
4860 );
4861 return (Vec::new(), None);
4862 }
4863 }
4864 } else {
4865 None
4866 };
4867
4868 {
4869 let mut cache = self.cache.borrow_mut();
4870
4871 let source_client_id = if is_synthetic {
4872 None
4873 } else {
4874 commission_client.map(ExecutionClient::client_id)
4875 };
4876
4877 if let Err(e) = cache.add_order(order.clone(), None, source_client_id, false) {
4878 match cache.order(&client_order_id) {
4882 Some(existing) if is_synthetic && existing.is_closed() => {
4883 log::debug!(
4884 "Skipping synthetic reconciliation order {client_order_id} for {}: \
4885 replay deduped (cached status={:?})",
4886 report.instrument_id,
4887 existing.status(),
4888 );
4889 }
4890 Some(existing) if is_synthetic => {
4891 log::warn!(
4892 "Synthetic reconciliation order {client_order_id} for {} exists in \
4893 cache in non-terminal state {:?}; fill not regenerated",
4894 report.instrument_id,
4895 existing.status(),
4896 );
4897 }
4898 _ => {
4899 log::error!("Failed to add external order to cache: {e}");
4900 }
4901 }
4902
4903 return (Vec::new(), None);
4904 }
4905
4906 if let Err(e) = cache.index_venue_order_id(&client_order_id, &report.venue_order_id) {
4907 log::warn!("Failed to index venue order ID: {e}");
4908 }
4909 }
4910
4911 Self::publish_order_event(&initialized);
4912
4913 log::info!(
4914 color = LogColor::Blue as u8;
4915 "Created external order {} ({}) for {} [{}]",
4916 client_order_id,
4917 report.venue_order_id,
4918 report.instrument_id,
4919 report.order_status,
4920 );
4921
4922 let ts_now = self.clock.borrow().timestamp_ns();
4923 let mut order_events = generate_external_order_status_events_with_commission(
4924 &order,
4925 report,
4926 &account_id,
4927 instrument,
4928 ts_now,
4929 inferred_commission,
4930 );
4931
4932 if replace_inferred_fill {
4933 let terminal_event = if order_events.last().is_some_and(|event| {
4934 matches!(
4935 event,
4936 OrderEventAny::Canceled(_) | OrderEventAny::Expired(_),
4937 )
4938 }) {
4939 order_events.pop()
4940 } else {
4941 None
4942 };
4943
4944 if order_events
4945 .last()
4946 .is_some_and(|event| matches!(event, OrderEventAny::Filled(_)))
4947 {
4948 order_events.pop();
4949 }
4950
4951 let fill_queue =
4952 fill_queue.expect("fill queue availability was checked before cache mutation");
4953
4954 for (fill_event, fill_key) in prepared_fills {
4955 fill_queue.push(&mut order_events, fill_event, fill_key);
4956 }
4957
4958 if !defer_terminal
4959 && let Some(inferred_qty) = inferred_qty
4960 && let Some(inferred_fill) = create_inferred_fill_for_qty(
4961 &order,
4962 report,
4963 &account_id,
4964 instrument,
4965 inferred_qty,
4966 ts_now,
4967 inferred_commission,
4968 )
4969 {
4970 order_events.push(inferred_fill);
4971 }
4972
4973 if !defer_terminal && let Some(event) = terminal_event {
4974 order_events.push(event);
4975 }
4976 }
4977
4978 let metadata = ExternalOrderMetadata {
4979 client_order_id,
4980 venue_order_id: report.venue_order_id,
4981 instrument_id: report.instrument_id,
4982 strategy_id,
4983 ts_init: ts_now,
4984 };
4985
4986 (order_events, Some(metadata))
4987 }
4988
4989 fn publish_order_event(event: &OrderEventAny) {
4990 let topic = switchboard::get_event_order_topic(event.strategy_id());
4991 msgbus::publish_order_event(topic, event);
4992 }
4993
4994 fn adjust_mass_status_fills(&self, mass_status: &ExecutionMassStatus) -> AdjustedReports {
4999 let mut final_orders: IndexMap<VenueOrderId, OrderStatusReport> =
5000 mass_status.order_reports();
5001 let mut final_fills: IndexMap<VenueOrderId, Vec<FillReport>> = mass_status.fill_reports();
5002 let mut order_only_ids = IndexSet::new();
5003 let mut order_only_fill_keys = IndexSet::new();
5004
5005 final_fills.retain(|_, fills| {
5006 fills.retain(|fill| {
5007 if fill.last_qty.is_zero() {
5008 log::warn!("Skipping zero-quantity fill report: {fill}");
5009 return false;
5010 }
5011
5012 true
5013 });
5014
5015 !fills.is_empty()
5016 });
5017
5018 if self.config.filter_position_reports {
5019 return AdjustedReports {
5020 orders: final_orders,
5021 fills: final_fills,
5022 order_only_ids,
5023 order_only_fill_keys,
5024 };
5025 }
5026
5027 let mut instruments_to_adjust = Vec::new();
5028
5029 for (instrument_id, position_reports) in mass_status.position_reports() {
5030 if !self.should_reconcile_instrument(&instrument_id) {
5031 log::debug!(
5032 "Skipping fill adjustment for {instrument_id}: not in reconciliation_instrument_ids"
5033 );
5034 continue;
5035 }
5036
5037 let is_hedge_mode = position_reports
5040 .iter()
5041 .any(|r| r.venue_position_id.is_some());
5042
5043 if is_hedge_mode {
5044 log::debug!(
5045 "Skipping fill adjustment for {instrument_id}: hedge mode (has venue_position_id)"
5046 );
5047 continue;
5048 }
5049
5050 let has_retained_position = {
5051 let cache = self.cache.borrow();
5052 !cache
5053 .positions_open(
5054 None,
5055 Some(&instrument_id),
5056 None,
5057 Some(&mass_status.account_id),
5058 None,
5059 )
5060 .is_empty()
5061 };
5062
5063 if has_retained_position {
5064 log::debug!(
5065 "Skipping fill adjustment for {instrument_id}: retained open position in cache"
5066 );
5067 continue;
5068 }
5069
5070 if let Some(instrument) = self.get_instrument(&instrument_id) {
5071 instruments_to_adjust.push(instrument);
5072 } else {
5073 log::debug!(
5074 "Skipping fill adjustment for {instrument_id}: instrument not found in cache"
5075 );
5076 }
5077 }
5078
5079 if instruments_to_adjust.is_empty() {
5080 return AdjustedReports {
5081 orders: final_orders,
5082 fills: final_fills,
5083 order_only_ids,
5084 order_only_fill_keys,
5085 };
5086 }
5087
5088 log_info!(
5089 "Adjusting fills for {} instrument(s) with position reports",
5090 instruments_to_adjust.len(),
5091 color = LogColor::Blue
5092 );
5093
5094 for instrument in &instruments_to_adjust {
5095 let instrument_id = instrument.id();
5096
5097 let result = if self.config.generate_missing_orders {
5098 process_mass_status_for_reconciliation(mass_status, instrument, None)
5099 } else {
5100 process_mass_status_for_reconciliation_without_synthetic_reports(
5101 mass_status,
5102 instrument,
5103 None,
5104 )
5105 };
5106
5107 match result {
5108 Ok(result) => {
5109 order_only_ids.extend(result.order_only_ids);
5110 order_only_fill_keys.extend(result.order_only_fill_keys);
5111 final_orders.retain(|_, order| order.instrument_id != instrument_id);
5112 final_fills.retain(|_, fills| {
5113 fills
5114 .first()
5115 .is_none_or(|f| f.instrument_id != instrument_id)
5116 });
5117
5118 final_orders.extend(result.orders);
5119 final_fills.extend(result.fills);
5120 }
5121 Err(e) => {
5122 log::warn!("Failed to adjust fills for {instrument_id}: {e}");
5123 }
5124 }
5125 }
5126
5127 log_info!(
5128 "After adjustment: {} order(s), {} fill group(s)",
5129 final_orders.len(),
5130 final_fills.len(),
5131 color = LogColor::Blue
5132 );
5133
5134 AdjustedReports {
5135 orders: final_orders,
5136 fills: final_fills,
5137 order_only_ids,
5138 order_only_fill_keys,
5139 }
5140 }
5141
5142 fn is_fill_applied(&self, fill: &OrderFilled, fill_key: FillKey) -> bool {
5143 if fill.last_qty.is_zero() {
5144 return false;
5145 }
5146
5147 let cache = self.cache();
5148 cache
5149 .order(&fill.client_order_id)
5150 .or_else(|| {
5151 cache
5152 .client_order_id(&fill.venue_order_id)
5153 .and_then(|client_order_id| cache.order(client_order_id))
5154 })
5155 .is_some_and(|order| {
5156 order.account_id() == Some(fill_key.0)
5157 && order.instrument_id() == fill_key.1
5158 && order.trade_ids().contains(&&fill_key.2)
5159 })
5160 }
5161
5162 fn create_order_fill(
5163 &self,
5164 order: &OrderAny,
5165 fill: &FillReport,
5166 instrument: &InstrumentAny,
5167 pending_fill_keys: &IndexSet<FillKey>,
5168 ) -> Option<(OrderFilled, FillKey)> {
5169 if fill.last_qty.is_zero() {
5170 log::warn!("Skipping zero-quantity fill report: {fill}");
5171 return None;
5172 }
5173
5174 let fill_key = (fill.account_id, fill.instrument_id, fill.trade_id);
5175 if self.fills_processed.contains_key(&fill_key) || pending_fill_keys.contains(&fill_key) {
5176 return None;
5177 }
5178
5179 let order_side = order.order_side();
5180 if fill.order_side != order_side {
5181 log::warn!(
5182 "Fill side mismatch for {}: cached={:?}, venue={:?}",
5183 order.client_order_id(),
5184 order_side,
5185 fill.order_side,
5186 );
5187 }
5188
5189 let ts_now = self.clock.borrow().timestamp_ns();
5190
5191 let event = OrderFilled::new(
5192 order.trader_id(),
5193 order.strategy_id(),
5194 order.instrument_id(),
5195 order.client_order_id(),
5196 fill.venue_order_id,
5197 fill.account_id,
5198 fill.trade_id,
5199 fill.order_side,
5200 order.order_type(),
5201 fill.last_qty,
5202 fill.last_px,
5203 instrument.quote_currency(),
5204 fill.liquidity_side,
5205 fill.report_id,
5206 fill.ts_event,
5207 ts_now,
5208 true, fill.venue_position_id,
5210 Some(fill.commission),
5211 None,
5212 );
5213
5214 Some((event, fill_key))
5215 }
5216}
5217
5218fn position_fill_report_matches(
5219 cache: &Cache,
5220 report: &FillReport,
5221 positions: &[PositionRef<'_>],
5222 mut flip_for_order: impl FnMut(ClientOrderId) -> anyhow::Result<Option<[PositionId; 2]>>,
5223 mut replay: Option<&mut IndexMap<PositionId, IndexMap<TradeId, Vec<usize>>>>,
5224) -> bool {
5225 let client_order_id = report
5226 .client_order_id
5227 .or_else(|| cache.client_order_id(&report.venue_order_id).copied());
5228
5229 let flip = match client_order_id.map(&mut flip_for_order).transpose() {
5230 Ok(flip) => flip.flatten(),
5231 Err(_) => return false,
5232 };
5233
5234 if let (Some(ids), Some(id)) = (flip, report.venue_position_id)
5235 && !ids.contains(&id)
5236 {
5237 return false;
5238 }
5239
5240 let mut matched = false;
5241 let mut quantity = Quantity::zero(report.last_qty.precision);
5242 let mut commission = Money::zero(report.commission.currency);
5243
5244 for position in positions {
5245 if flip.map_or_else(
5246 || report.venue_position_id.is_some_and(|id| position.id != id),
5247 |ids| !ids.contains(&position.id),
5248 ) {
5249 continue;
5250 }
5251
5252 let totals = if let Some(replay) = replay.as_deref_mut() {
5253 let indexes = replay
5254 .entry(position.id)
5255 .or_insert_with(|| position_fill_report_indexes(position))
5256 .get(&report.trade_id);
5257 position_fill_report_totals(
5258 position,
5259 report,
5260 client_order_id,
5261 flip.is_some(),
5262 indexes
5263 .into_iter()
5264 .flatten()
5265 .map(|&index| &position.replay_events[index]),
5266 )
5267 } else {
5268 position_fill_report_totals(
5269 position,
5270 report,
5271 client_order_id,
5272 flip.is_some(),
5273 position.replay_events.iter(),
5274 )
5275 };
5276
5277 let Some((position_quantity, position_commission)) = totals else {
5278 return false;
5279 };
5280
5281 let Some(next_quantity) = quantity.checked_add(position_quantity) else {
5282 return false;
5283 };
5284
5285 let Some(next_commission) = commission.checked_add(position_commission) else {
5286 return false;
5287 };
5288
5289 matched |= position_quantity.non_zero();
5290 quantity = next_quantity;
5291 commission = next_commission;
5292 }
5293
5294 matched && quantity == report.last_qty && commission == report.commission
5295}
5296
5297fn position_fill_report_indexes(position: &Position) -> IndexMap<TradeId, Vec<usize>> {
5298 let mut replay = IndexMap::<_, Vec<usize>>::new();
5299
5300 for (index, event) in position.replay_events.iter().enumerate() {
5301 if let PositionReplayEvent::Filled(fill) = event {
5302 replay.entry(fill.trade_id).or_default().push(index);
5303 }
5304 }
5305
5306 replay
5307}
5308
5309fn position_fill_report_totals<'a>(
5310 position: &Position,
5311 report: &FillReport,
5312 client_order_id: Option<ClientOrderId>,
5313 apply_voids: bool,
5314 replay: impl Iterator<Item = &'a PositionReplayEvent>,
5315) -> Option<(Quantity, Money)> {
5316 let mut fills = replay.filter_map(|event| match event {
5317 PositionReplayEvent::Filled(fill)
5318 if fill.position_id == Some(position.id)
5319 && fill.account_id == report.account_id
5320 && fill.instrument_id == report.instrument_id
5321 && fill.venue_order_id == report.venue_order_id
5322 && fill.trade_id == report.trade_id
5323 && fill.order_side == report.order_side
5324 && fill.last_px == report.last_px
5325 && fill.liquidity_side == report.liquidity_side
5326 && client_order_id.is_none_or(|id| fill.client_order_id == id) =>
5327 {
5328 Some(fill)
5329 }
5330 _ => None,
5331 });
5332
5333 let (mut position_quantity, mut position_commission) = fills.try_fold(
5334 (
5335 Quantity::zero(report.last_qty.precision),
5336 Money::zero(report.commission.currency),
5337 ),
5338 |(quantity, commission), fill| {
5339 let fill_commission = fill.commission?;
5340 if fill_commission.currency != report.commission.currency {
5341 return None;
5342 }
5343
5344 Some((
5345 quantity.checked_add(fill.last_qty)?,
5346 commission.checked_add(fill_commission)?,
5347 ))
5348 },
5349 )?;
5350
5351 if apply_voids
5352 && let Some(correction) = position.fill_voids.iter().rev().find(|correction| {
5353 correction.event.trade_id == report.trade_id
5354 && correction.event.venue_order_id == report.venue_order_id
5355 && client_order_id.is_none_or(|id| correction.event.client_order_id == id)
5356 })
5357 {
5358 let corrected_quantity = position_quantity.checked_sub(correction.voided_qty)?;
5359 let corrected_commission = position_commission.checked_sub(
5360 correction
5361 .commission_voided
5362 .unwrap_or(Money::zero(report.commission.currency)),
5363 )?;
5364 position_quantity = corrected_quantity;
5365 position_commission = corrected_commission;
5366 }
5367
5368 Some((position_quantity, position_commission))
5369}
5370
5371#[cfg(test)]
5372mod tests {
5373 use nautilus_common::{clock::VirtualClock, config::ConfigError};
5374 use nautilus_core::{DurationNanos, Params};
5375 use nautilus_execution::reconciliation::generate_reconciliation_order_events;
5376 use nautilus_model::{
5377 accounts::AccountAny,
5378 enums::{LiquiditySide, OmsType, OrderType, PositionSide, TimeInForce},
5379 events::order::spec::{OrderPendingCancelSpec, OrderPendingUpdateSpec, OrderUpdatedSpec},
5380 identifiers::{Symbol, Venue},
5381 instruments::{
5382 CurrencyPair, Instrument,
5383 stubs::{btcusd_bybit, crypto_perpetual_ethusdt},
5384 },
5385 orders::{OrderTestBuilder, stubs::TestOrderEventStubs},
5386 types::{AccountBalance, Currency, MarginBalance, Money, Price, fixed::FIXED_PRECISION},
5387 };
5388 use rstest::rstest;
5389 use rust_decimal_macros::dec;
5390
5391 use super::{
5392 super::reconciliation::tests::{CommissionOutcome, CommissionStubClient},
5393 *,
5394 };
5395
5396 #[rstest]
5397 fn test_new_validates_open_check_lookback_mins_boundaries() {
5398 let create_manager = |mins| {
5399 ExecutionManager::new(
5400 Rc::new(RefCell::new(VirtualClock::new())),
5401 Rc::new(RefCell::new(Cache::default())),
5402 ExecutionManagerConfig {
5403 open_check_lookback_mins: Some(mins),
5404 ..Default::default()
5405 },
5406 )
5407 };
5408
5409 assert!(create_manager(307_445_734).is_ok());
5410 assert!(matches!(
5411 create_manager(307_445_735),
5412 Err(ConfigError::Range { field, .. })
5413 if field == "ExecutionManagerConfig.open_check_lookback_mins"
5414 ));
5415 }
5416
5417 #[rstest]
5418 fn test_new_validates_reconciliation_lookback_mins_boundaries() {
5419 let create_manager = |mins| {
5420 ExecutionManager::new(
5421 Rc::new(RefCell::new(VirtualClock::new())),
5422 Rc::new(RefCell::new(Cache::default())),
5423 ExecutionManagerConfig {
5424 lookback_mins: Some(mins),
5425 ..Default::default()
5426 },
5427 )
5428 };
5429
5430 assert!(create_manager(307_445_734_561_825_860).is_ok());
5431 assert!(matches!(
5432 create_manager(307_445_734_561_825_861),
5433 Err(ConfigError::Range { field, .. })
5434 if field == "ExecutionManagerConfig.lookback_mins"
5435 ));
5436 }
5437
5438 #[rstest]
5439 fn test_new_reports_every_invalid_lookback_field() {
5440 let error = ExecutionManager::new(
5441 Rc::new(RefCell::new(VirtualClock::new())),
5442 Rc::new(RefCell::new(Cache::default())),
5443 ExecutionManagerConfig {
5444 lookback_mins: Some(307_445_734_561_825_861),
5445 open_check_lookback_mins: Some(307_445_735),
5446 position_check_lookback_mins: 307_445_735,
5447 ..Default::default()
5448 },
5449 )
5450 .expect_err("all lookback fields are out of range");
5451
5452 let ConfigError::Multiple { errors } = error else {
5453 panic!("expected a `Multiple` error, was {error:?}");
5454 };
5455
5456 let fields = errors
5457 .iter()
5458 .map(|e| match e {
5459 ConfigError::Range { field, .. } => field.as_str(),
5460 other => panic!("expected a `Range` error, was {other:?}"),
5461 })
5462 .collect::<Vec<_>>();
5463
5464 assert_eq!(
5465 fields,
5466 [
5467 "ExecutionManagerConfig.lookback_mins",
5468 "ExecutionManagerConfig.open_check_lookback_mins",
5469 "ExecutionManagerConfig.position_check_lookback_mins",
5470 ]
5471 );
5472 }
5473
5474 struct PositionCoverageStubClient;
5475
5476 #[async_trait::async_trait(?Send)]
5477 impl ExecutionClient for PositionCoverageStubClient {
5478 fn is_connected(&self) -> bool {
5479 true
5480 }
5481
5482 fn client_id(&self) -> ClientId {
5483 ClientId::from("BYBIT")
5484 }
5485
5486 fn account_id(&self) -> AccountId {
5487 AccountId::from("TEST-001")
5488 }
5489
5490 fn venue(&self) -> Venue {
5491 Venue::from("BYBIT")
5492 }
5493
5494 fn oms_type(&self) -> OmsType {
5495 OmsType::Netting
5496 }
5497
5498 fn get_account(&self) -> Option<AccountAny> {
5499 None
5500 }
5501
5502 fn provides_bulk_position_coverage(&self, instrument_id: InstrumentId) -> bool {
5503 !instrument_id.symbol.as_str().ends_with("-SPOT")
5504 }
5505
5506 fn generate_account_state(
5507 &self,
5508 _balances: Vec<AccountBalance>,
5509 _margins: Vec<MarginBalance>,
5510 _reported: bool,
5511 _ts_event: UnixNanos,
5512 _info: Option<Params>,
5513 ) -> anyhow::Result<()> {
5514 Ok(())
5515 }
5516
5517 fn start(&mut self) -> anyhow::Result<()> {
5518 Ok(())
5519 }
5520
5521 fn stop(&mut self) -> anyhow::Result<()> {
5522 Ok(())
5523 }
5524 }
5525
5526 fn cached_commission_fixtures() -> (
5527 ExecutionManager,
5528 Rc<RefCell<Cache>>,
5529 OrderAny,
5530 OrderStatusReport,
5531 InstrumentAny,
5532 ) {
5533 let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
5534 let clock = Rc::new(RefCell::new(VirtualClock::new()));
5535 let cache = Rc::new(RefCell::new(Cache::default()));
5536 cache
5537 .borrow_mut()
5538 .add_instrument(instrument.clone())
5539 .expect("instrument is cacheable");
5540 let client_order_id = ClientOrderId::from("O-COMMISSION-CACHED");
5541 let venue_order_id = VenueOrderId::from("V-COMMISSION-CACHED");
5542 insert_accepted_limit_order(
5543 &cache,
5544 client_order_id,
5545 venue_order_id,
5546 instrument.id(),
5547 ClientId::from("STUB"),
5548 );
5549 let order = cache
5550 .borrow()
5551 .order_owned(&client_order_id)
5552 .expect("accepted order is cached");
5553 let report = OrderStatusReport::new(
5554 AccountId::from("TEST-001"),
5555 instrument.id(),
5556 Some(client_order_id),
5557 venue_order_id,
5558 OrderSide::Buy.into(),
5559 OrderType::Limit,
5560 TimeInForce::Gtc,
5561 OrderStatus::Filled,
5562 Quantity::from("10.0"),
5563 Quantity::from("10.0"),
5564 UnixNanos::from(1),
5565 UnixNanos::from(1),
5566 UnixNanos::from(1),
5567 None,
5568 )
5569 .with_avg_px(dec!(100.0));
5570 let manager =
5571 ExecutionManager::new(clock, cache.clone(), ExecutionManagerConfig::default())
5572 .expect("valid config");
5573
5574 (manager, cache, order, report, instrument)
5575 }
5576
5577 fn external_report_with_partial_fill(
5578 instrument: &InstrumentAny,
5579 ) -> (OrderStatusReport, FillReport) {
5580 let account_id = AccountId::from("STUB-001");
5581 let venue_order_id = VenueOrderId::from("V-EXT-1");
5582 let report = OrderStatusReport::new(
5583 account_id,
5584 instrument.id(),
5585 None,
5586 venue_order_id,
5587 OrderSide::Buy.into(),
5588 OrderType::Limit,
5589 TimeInForce::Gtc,
5590 OrderStatus::Filled,
5591 Quantity::from("10.0"),
5592 Quantity::from("10.0"),
5593 UnixNanos::from(1),
5594 UnixNanos::from(1),
5595 UnixNanos::from(1),
5596 None,
5597 )
5598 .with_price(Price::from("100.00"))
5599 .with_avg_px(dec!(100.0));
5600
5601 let fill = FillReport::new(
5602 account_id,
5603 instrument.id(),
5604 venue_order_id,
5605 TradeId::from("T-EXT-1"),
5606 OrderSide::Buy,
5607 Quantity::from("4.0"),
5608 Price::from("100.00"),
5609 Money::new(0.1, Currency::USDT()),
5610 LiquiditySide::Taker,
5611 None,
5612 None,
5613 UnixNanos::from(1),
5614 UnixNanos::from(1),
5615 None,
5616 );
5617
5618 (report, fill)
5619 }
5620
5621 fn inferred_fills(events: &[OrderEventAny]) -> Vec<OrderFilled> {
5622 events
5623 .iter()
5624 .filter_map(|event| match event {
5625 OrderEventAny::Filled(filled) if filled.last_qty == Quantity::from("6.0") => {
5626 Some(filled.clone())
5627 }
5628 _ => None,
5629 })
5630 .collect()
5631 }
5632
5633 #[rstest]
5634 fn test_handle_external_order_applies_venue_commission_to_inferred_fill() {
5635 let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
5636 let clock = Rc::new(RefCell::new(VirtualClock::new()));
5637 let cache = Rc::new(RefCell::new(Cache::default()));
5638 cache
5639 .borrow_mut()
5640 .add_instrument(instrument.clone())
5641 .expect("instrument is cacheable");
5642 let manager = ExecutionManager::new(clock, cache, ExecutionManagerConfig::default())
5643 .expect("valid config");
5644 let (report, fill) = external_report_with_partial_fill(&instrument);
5645 let expected = Money::new(2.5, Currency::USDT());
5646 let client = CommissionStubClient::new(CommissionOutcome::Value(expected));
5647 let mut fill_queue = ReconciliationFillQueue::default();
5648
5649 let (events, _) = manager.handle_external_order(
5650 &report,
5651 AccountId::from("STUB-001"),
5652 &instrument,
5653 &[&fill],
5654 false,
5655 Some(&mut fill_queue),
5656 Some(&client),
5657 );
5658
5659 let inferred = inferred_fills(&events);
5660 assert_eq!(inferred.len(), 1, "one inferred fill covers the 6.0 gap");
5661 assert_eq!(inferred[0].commission, Some(expected));
5662 }
5663
5664 #[rstest]
5665 fn test_handle_external_order_skips_inferred_fill_when_commission_fails() {
5666 let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
5667 let clock = Rc::new(RefCell::new(VirtualClock::new()));
5668 let cache = Rc::new(RefCell::new(Cache::default()));
5669 cache
5670 .borrow_mut()
5671 .add_instrument(instrument.clone())
5672 .expect("instrument is cacheable");
5673 let manager =
5674 ExecutionManager::new(clock, cache.clone(), ExecutionManagerConfig::default())
5675 .expect("valid config");
5676 let (report, fill) = external_report_with_partial_fill(&instrument);
5677 let client = CommissionStubClient::new(CommissionOutcome::Failure);
5678 let mut fill_queue = ReconciliationFillQueue::default();
5679
5680 let (events, metadata) = manager.handle_external_order(
5681 &report,
5682 AccountId::from("STUB-001"),
5683 &instrument,
5684 &[&fill],
5685 false,
5686 Some(&mut fill_queue),
5687 Some(&client),
5688 );
5689
5690 assert!(events.is_empty());
5691 assert!(metadata.is_none());
5692 assert!(fill_queue.pending_fill_keys.is_empty());
5693 assert!(
5694 cache
5695 .borrow()
5696 .order(&ClientOrderId::from(report.venue_order_id.as_str()))
5697 .is_none(),
5698 "commission failure must precede external order cache mutation"
5699 );
5700
5701 let expected = Money::new(2.5, Currency::USDT());
5702 let retry_client = CommissionStubClient::new(CommissionOutcome::Value(expected));
5703 let (retry_events, retry_metadata) = manager.handle_external_order(
5704 &report,
5705 AccountId::from("STUB-001"),
5706 &instrument,
5707 &[&fill],
5708 false,
5709 Some(&mut fill_queue),
5710 Some(&retry_client),
5711 );
5712 let inferred = inferred_fills(&retry_events);
5713
5714 assert!(retry_metadata.is_some());
5715 assert_eq!(inferred.len(), 1);
5716 assert_eq!(inferred[0].commission, Some(expected));
5717 assert_eq!(fill_queue.pending_fill_keys.len(), 1);
5718 assert!(
5719 cache
5720 .borrow()
5721 .order(&ClientOrderId::from(report.venue_order_id.as_str()))
5722 .is_some()
5723 );
5724 }
5725
5726 #[rstest]
5727 fn test_handle_external_order_without_explicit_fills_resolves_commission_before_cache() {
5728 let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
5729 let clock = Rc::new(RefCell::new(VirtualClock::new()));
5730 let cache = Rc::new(RefCell::new(Cache::default()));
5731 cache
5732 .borrow_mut()
5733 .add_instrument(instrument.clone())
5734 .expect("instrument is cacheable");
5735 let manager =
5736 ExecutionManager::new(clock, cache.clone(), ExecutionManagerConfig::default())
5737 .expect("valid config");
5738 let (report, _) = external_report_with_partial_fill(&instrument);
5739 let failing_client = CommissionStubClient::new(CommissionOutcome::Failure);
5740
5741 let (failed_events, failed_metadata) = manager.handle_external_order(
5742 &report,
5743 AccountId::from("STUB-001"),
5744 &instrument,
5745 &[],
5746 false,
5747 None,
5748 Some(&failing_client),
5749 );
5750
5751 assert!(failed_events.is_empty());
5752 assert!(failed_metadata.is_none());
5753 assert!(
5754 cache
5755 .borrow()
5756 .order(&ClientOrderId::from(report.venue_order_id.as_str()))
5757 .is_none()
5758 );
5759
5760 let expected = Money::new(4.0, Currency::USDT());
5761 let retry_client = CommissionStubClient::new(CommissionOutcome::Value(expected));
5762 let (retry_events, retry_metadata) = manager.handle_external_order(
5763 &report,
5764 AccountId::from("STUB-001"),
5765 &instrument,
5766 &[],
5767 false,
5768 None,
5769 Some(&retry_client),
5770 );
5771
5772 let fills: Vec<_> = retry_events
5773 .iter()
5774 .filter_map(|event| match event {
5775 OrderEventAny::Filled(fill) => Some(fill),
5776 _ => None,
5777 })
5778 .collect();
5779
5780 assert!(retry_metadata.is_some());
5781 assert_eq!(fills.len(), 1);
5782 assert_eq!(fills[0].last_qty, Quantity::from("10.0"));
5783 assert_eq!(fills[0].commission, Some(expected));
5784 }
5785
5786 #[rstest]
5787 fn test_handle_external_order_with_no_override_emits_fill_without_commission() {
5788 let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
5789 let clock = Rc::new(RefCell::new(VirtualClock::new()));
5790 let cache = Rc::new(RefCell::new(Cache::default()));
5791 cache
5792 .borrow_mut()
5793 .add_instrument(instrument.clone())
5794 .expect("instrument is cacheable");
5795 let manager = ExecutionManager::new(clock, cache, ExecutionManagerConfig::default())
5796 .expect("valid config");
5797 let (report, fill) = external_report_with_partial_fill(&instrument);
5798 let client = CommissionStubClient::new(CommissionOutcome::NoOverride);
5799 let mut fill_queue = ReconciliationFillQueue::default();
5800
5801 let (events, _) = manager.handle_external_order(
5802 &report,
5803 AccountId::from("STUB-001"),
5804 &instrument,
5805 &[&fill],
5806 false,
5807 Some(&mut fill_queue),
5808 Some(&client),
5809 );
5810
5811 let inferred = inferred_fills(&events);
5812 assert_eq!(inferred.len(), 1);
5813 assert_eq!(inferred[0].commission, None);
5814 }
5815
5816 #[rstest]
5817 #[case::filled(
5818 OrderStatus::Filled,
5819 "10.0",
5820 "6.0",
5821 Price::from_decimal_dp(dec!(33.3333333333333333), FIXED_PRECISION).unwrap(),
5822 1
5823 )]
5824 #[case::canceled(OrderStatus::Canceled, "8.0", "4.0", "20.00", 2)]
5825 #[case::expired(OrderStatus::Expired, "8.0", "4.0", "20.00", 2)]
5826 fn test_cached_reconciliation_applies_explicit_fill_and_defers_failed_residual(
5827 #[case] status: OrderStatus,
5828 #[case] filled_qty: Quantity,
5829 #[case] residual_qty: Quantity,
5830 #[case] residual_px: Price,
5831 #[case] event_count: usize,
5832 ) {
5833 let (mut manager, _cache, order, mut report, instrument) = cached_commission_fixtures();
5834 report.order_status = status;
5835 report.filled_qty = filled_qty;
5836 report.avg_px = Some(dec!(60.0));
5837
5838 let explicit_fill = FillReport::new(
5839 report.account_id,
5840 report.instrument_id,
5841 report.venue_order_id,
5842 TradeId::from("T-COMMISSION-EXPLICIT"),
5843 OrderSide::Buy,
5844 Quantity::from("4.0"),
5845 Price::from("100.0"),
5846 Money::new(0.25, Currency::USDT()),
5847 LiquiditySide::Taker,
5848 report.client_order_id,
5849 None,
5850 UnixNanos::from(1),
5851 UnixNanos::from(1),
5852 None,
5853 );
5854 let failing_client = CommissionStubClient::new(CommissionOutcome::Failure);
5855 let mut fill_queue = ReconciliationFillQueue::default();
5856
5857 let first_events = manager.reconcile_order_with_fills(
5858 true,
5859 &order,
5860 &report,
5861 &[&explicit_fill],
5862 Some(&instrument),
5863 &mut fill_queue,
5864 Some(&failing_client),
5865 );
5866 let mut working = order;
5867
5868 for event in &first_events {
5869 working
5870 .apply(event.clone())
5871 .expect("explicit fill projects cleanly");
5872 }
5873
5874 assert_eq!(first_events.len(), 1);
5875
5876 let OrderEventAny::Filled(explicit) = &first_events[0] else {
5877 panic!("expected the valid explicit fill");
5878 };
5879
5880 assert_eq!(explicit.last_qty, Quantity::from("4.0"));
5881 assert_eq!(
5882 explicit.commission,
5883 Some(Money::new(0.25, Currency::USDT()))
5884 );
5885 assert_eq!(working.status(), OrderStatus::PartiallyFilled);
5886
5887 let expected = Money::new(1.5, Currency::USDT());
5888 let retry_client = CommissionStubClient::new(CommissionOutcome::Value(expected));
5889 let mut reported_residual = explicit_fill.clone();
5890 reported_residual.trade_id = TradeId::from("T-COMMISSION-RESIDUAL");
5891 reported_residual.last_qty = residual_qty;
5892 reported_residual.last_px = residual_px;
5893 reported_residual.commission = expected;
5894
5895 let residual_reports = if status == OrderStatus::Filled {
5896 Vec::new()
5897 } else {
5898 vec![&reported_residual]
5899 };
5900
5901 let retry_events = manager.reconcile_order_with_fills(
5902 true,
5903 &working,
5904 &report,
5905 &residual_reports,
5906 Some(&instrument),
5907 &mut fill_queue,
5908 Some(&retry_client),
5909 );
5910
5911 assert_eq!(retry_events.len(), event_count);
5912
5913 let OrderEventAny::Filled(residual) = &retry_events[0] else {
5914 panic!("expected the residual fill");
5915 };
5916
5917 assert_eq!(residual.last_qty, residual_qty);
5918 assert_eq!(residual.last_px, residual_px);
5919 assert_eq!(residual.commission, Some(expected));
5920 assert_eq!(
5921 retry_client.seen(),
5922 (status == OrderStatus::Filled).then_some((
5923 residual_qty,
5924 residual.last_px,
5925 residual.liquidity_side
5926 )),
5927 "commission must use the exact price and liquidity carried by the residual fill"
5928 );
5929
5930 for event in &retry_events {
5931 working
5932 .apply(event.clone())
5933 .expect("residual precedes terminal status");
5934 }
5935
5936 retry_client.clear_seen();
5937 let replay = manager.reconcile_order_with_fills(
5938 true,
5939 &working,
5940 &report,
5941 &[],
5942 Some(&instrument),
5943 &mut fill_queue,
5944 Some(&retry_client),
5945 );
5946
5947 assert_eq!(working.status(), status);
5948 assert_eq!(working.filled_qty(), filled_qty);
5949 assert_eq!(
5950 working.commissions().get(&Currency::USDT()),
5951 Some(&Money::from("1.75 USDT"))
5952 );
5953 assert!(replay.is_empty());
5954 assert_eq!(retry_client.seen(), None);
5955 }
5956
5957 #[rstest]
5958 fn test_cached_reconciliation_preserves_explicit_fill_side() {
5959 let (mut manager, _cache, order, mut report, instrument) = cached_commission_fixtures();
5960 report.order_status = OrderStatus::PartiallyFilled;
5961 report.filled_qty = Quantity::from("4.0");
5962 let trade_id = TradeId::from("T-CONFLICTING-SIDE");
5963
5964 let explicit_fill = FillReport::new(
5965 report.account_id,
5966 report.instrument_id,
5967 report.venue_order_id,
5968 trade_id,
5969 OrderSide::Sell,
5970 Quantity::from("4.0"),
5971 Price::from("100.0"),
5972 Money::new(0.25, Currency::USDT()),
5973 LiquiditySide::Taker,
5974 report.client_order_id,
5975 None,
5976 UnixNanos::from(1),
5977 UnixNanos::from(1),
5978 None,
5979 );
5980 let mut fill_queue = ReconciliationFillQueue::default();
5981
5982 let events = manager.reconcile_order_with_fills(
5983 true,
5984 &order,
5985 &report,
5986 &[&explicit_fill],
5987 Some(&instrument),
5988 &mut fill_queue,
5989 None,
5990 );
5991
5992 assert_eq!(events.len(), 1);
5993
5994 let OrderEventAny::Filled(fill) = &events[0] else {
5995 panic!("expected the explicit fill");
5996 };
5997
5998 assert_eq!(fill.trade_id, trade_id);
5999 assert_eq!(fill.order_side, OrderSide::Sell);
6000 assert_eq!(fill.last_qty, Quantity::from("4.0"));
6001 assert_eq!(fill.last_px, Price::from("100.0"));
6002 }
6003
6004 #[rstest]
6005 fn test_continuous_report_preserves_newer_fills() {
6006 let (mut manager, _cache, mut order, mut report, instrument) = cached_commission_fixtures();
6007 let fill = TestOrderEventStubs::filled(
6008 &order,
6009 &instrument,
6010 Some(TradeId::from("T-NEWER-STREAM")),
6011 None,
6012 Some(Price::from("100.00")),
6013 Some(Quantity::from("2.0")),
6014 Some(LiquiditySide::Maker),
6015 None,
6016 None,
6017 Some(AccountId::from("TEST-001")),
6018 );
6019 order.apply(fill).unwrap();
6020 report.order_status = OrderStatus::PartiallyFilled;
6021 report.filled_qty = Quantity::from("1.0");
6022 let mut fill_queue = ReconciliationFillQueue::default();
6023
6024 let events = manager.reconcile_order_with_fills(
6025 false,
6026 &order,
6027 &report,
6028 &[],
6029 Some(&instrument),
6030 &mut fill_queue,
6031 None,
6032 );
6033
6034 assert!(events.is_empty());
6035 assert!(fill_queue.pending_fill_keys.is_empty());
6036 }
6037
6038 #[rstest]
6039 #[case::with_fills(true)]
6040 #[case::without_fills(false)]
6041 fn test_cached_snapshot_without_instrument_defers_unaccounted_fills(#[case] has_fills: bool) {
6042 let (mut manager, _cache, order, mut report, _instrument) = cached_commission_fixtures();
6043 report.order_status = OrderStatus::Canceled;
6044
6045 let explicit_fill = FillReport::new(
6046 report.account_id,
6047 report.instrument_id,
6048 report.venue_order_id,
6049 TradeId::from("T-MISSING-INSTRUMENT"),
6050 OrderSide::Buy,
6051 Quantity::from("4.0"),
6052 Price::from("100.0"),
6053 Money::new(0.25, Currency::USDT()),
6054 LiquiditySide::Taker,
6055 report.client_order_id,
6056 None,
6057 UnixNanos::from(1),
6058 UnixNanos::from(1),
6059 None,
6060 );
6061 let mut fill_queue = ReconciliationFillQueue::default();
6062
6063 let fills = if has_fills {
6064 vec![&explicit_fill]
6065 } else {
6066 Vec::new()
6067 };
6068
6069 let events = manager.reconcile_order_with_fills(
6070 true,
6071 &order,
6072 &report,
6073 &fills,
6074 None,
6075 &mut fill_queue,
6076 None,
6077 );
6078
6079 assert!(events.is_empty());
6080 assert_eq!(order.status(), OrderStatus::Accepted);
6081 assert_eq!(order.filled_qty(), Quantity::from("0.0"));
6082 assert!(fill_queue.pending_fill_keys.is_empty());
6083 }
6084
6085 #[rstest]
6086 #[case::canceled(OrderStatus::Canceled)]
6087 #[case::expired(OrderStatus::Expired)]
6088 fn test_terminal_order_report_does_not_void_cached_fills(#[case] status: OrderStatus) {
6089 let (manager, _cache, mut order, mut report, instrument) = cached_commission_fixtures();
6090 let fill = create_inferred_fill_for_qty(
6091 &order,
6092 &report,
6093 &report.account_id,
6094 &instrument,
6095 Quantity::from("4.0"),
6096 UnixNanos::from(1),
6097 None,
6098 )
6099 .unwrap();
6100 order.apply(fill).unwrap();
6101 report.order_status = status;
6102 report.filled_qty = Quantity::from("2.0");
6103 let client = CommissionStubClient::new(CommissionOutcome::Failure);
6104
6105 let events = manager
6106 .reconcile_order_report(&order, &report, Some(&instrument), Some(&client))
6107 .unwrap();
6108
6109 for event in &events {
6110 order.apply(event.clone()).unwrap();
6111 }
6112
6113 assert_eq!(events.len(), 1);
6114 assert_eq!(order.status(), status);
6115 assert_eq!(order.filled_qty(), Quantity::from("4.0"));
6116 assert_eq!(client.seen(), None);
6117 }
6118
6119 #[rstest]
6120 fn test_filled_order_ignores_superseded_cancel_report() {
6121 let (manager, cache, order, mut report, instrument) = cached_commission_fixtures();
6122 let venue_order_id = VenueOrderId::from("V-REPLACEMENT");
6123 let updated = OrderUpdatedSpec::builder()
6124 .trader_id(order.trader_id())
6125 .strategy_id(order.strategy_id())
6126 .instrument_id(order.instrument_id())
6127 .client_order_id(order.client_order_id())
6128 .account_id(report.account_id)
6129 .venue_order_id(venue_order_id)
6130 .quantity(order.quantity())
6131 .build();
6132 let order = cache
6133 .borrow_mut()
6134 .update_order(&OrderEventAny::Updated(updated))
6135 .unwrap();
6136 let mut fill_report = report.clone();
6137 fill_report.venue_order_id = venue_order_id;
6138 let commission = Money::from("1.25 USDT");
6139 let fill = create_inferred_fill_for_qty(
6140 &order,
6141 &fill_report,
6142 &report.account_id,
6143 &instrument,
6144 Quantity::from("10.0"),
6145 UnixNanos::from(2),
6146 Some(commission),
6147 )
6148 .unwrap();
6149 let order = cache.borrow_mut().update_order(&fill).unwrap();
6150 report.order_status = OrderStatus::Canceled;
6151 report.filled_qty = Quantity::from("0.0");
6152 report.avg_px = None;
6153 let client = CommissionStubClient::new(CommissionOutcome::Failure);
6154
6155 let events = manager
6156 .reconcile_order_report(&order, &report, Some(&instrument), Some(&client))
6157 .unwrap();
6158
6159 assert!(events.is_empty());
6160 assert_eq!(order.status(), OrderStatus::Filled);
6161 assert_eq!(order.venue_order_id(), Some(venue_order_id));
6162 assert_eq!(order.filled_qty(), Quantity::from("10.0"));
6163 assert_eq!(order.avg_px(), Some(dec!(100.0)));
6164 assert_eq!(
6165 order.commissions().get(&Currency::USDT()),
6166 Some(&commission)
6167 );
6168 assert_eq!(client.seen(), None);
6169 }
6170
6171 #[rstest]
6172 fn test_continuous_reconciliation_uses_source_client_and_retries_commission() {
6173 let (mut manager, _cache, order, report, _instrument) = cached_commission_fixtures();
6174 let client_id = ClientId::from("STUB");
6175
6176 let check = OpenOrderReportCheck {
6177 command: GenerateOrderStatusReports::new(
6178 UUID4::new(),
6179 UnixNanos::from(1),
6180 true,
6181 None,
6182 None,
6183 None,
6184 None,
6185 None,
6186 ),
6187 filtered_orders: vec![order],
6188 client_coverage: IndexMap::from([(
6189 report.client_order_id.unwrap(),
6190 ReportClientCoverage::Resolved(IndexSet::from([client_id])),
6191 )]),
6192 };
6193
6194 let queried_clients = IndexSet::from([client_id]);
6195 let failed_clients = IndexSet::new();
6196 let failing_client = CommissionStubClient::new(CommissionOutcome::Failure);
6197
6198 let failed = manager.reconcile_open_order_reports(
6199 &check,
6200 vec![SourcedOrderStatusReport {
6201 client_id,
6202 report: report.clone(),
6203 }],
6204 &queried_clients,
6205 &failed_clients,
6206 &[&failing_client],
6207 );
6208
6209 assert!(failed.events.is_empty());
6210
6211 let expected = Money::new(1.5, Currency::USDT());
6212 let retry_client = CommissionStubClient::new(CommissionOutcome::Value(expected));
6213 let retry = manager.reconcile_open_order_reports(
6214 &check,
6215 vec![SourcedOrderStatusReport { client_id, report }],
6216 &queried_clients,
6217 &failed_clients,
6218 &[&retry_client],
6219 );
6220
6221 assert_eq!(retry.events.len(), 1);
6222
6223 let OrderEventAny::Filled(fill) = &retry.events[0] else {
6224 panic!("expected inferred fill on valid retry");
6225 };
6226
6227 assert_eq!(fill.last_qty, Quantity::from("10.0"));
6228 assert_eq!(fill.commission, Some(expected));
6229 }
6230
6231 #[rstest]
6232 fn test_open_check_lookback_exclusion_warns_once_without_reconciliation_actions() {
6233 let client_order_id = ClientOrderId::from("O-LOOKBACK-OLD");
6234 let venue_order_id = VenueOrderId::from("V-LOOKBACK-OLD");
6235 let client_id = ClientId::from("BINANCE");
6236 let instrument_id = crypto_perpetual_ethusdt().id();
6237 let clock = Rc::new(RefCell::new(VirtualClock::new()));
6238 let cache = Rc::new(RefCell::new(Cache::default()));
6239 insert_accepted_limit_order(
6240 &cache,
6241 client_order_id,
6242 venue_order_id,
6243 instrument_id,
6244 client_id,
6245 );
6246 let order = cache.borrow().order_owned(&client_order_id).unwrap();
6247 let cutoff = order.ts_last().saturating_add(DurationNanos::new(1));
6248
6249 let check = OpenOrderReportCheck {
6250 command: GenerateOrderStatusReports::new(
6251 UUID4::new(),
6252 UnixNanos::from(1),
6253 false,
6254 None,
6255 Some(cutoff),
6256 None,
6257 None,
6258 None,
6259 ),
6260 filtered_orders: vec![order],
6261 client_coverage: IndexMap::from([(
6262 client_order_id,
6263 ReportClientCoverage::Resolved(IndexSet::from([client_id])),
6264 )]),
6265 };
6266
6267 let queried_clients = IndexSet::from([client_id]);
6268
6269 let mut manager = ExecutionManager::new(
6270 clock,
6271 cache.clone(),
6272 ExecutionManagerConfig {
6273 open_check_open_only: false,
6274 ..Default::default()
6275 },
6276 )
6277 .expect("valid config");
6278
6279 for _ in 0..2 {
6280 let result = manager.reconcile_open_order_reports(
6281 &check,
6282 Vec::new(),
6283 &queried_clients,
6284 &IndexSet::new(),
6285 &[],
6286 );
6287
6288 assert!(result.events.is_empty());
6289 assert!(result.targeted_queries.is_empty());
6290 assert_eq!(
6291 cache.borrow().order(&client_order_id).unwrap().status(),
6292 OrderStatus::Accepted
6293 );
6294 assert!(!manager.order_recon_retries.contains_key(&client_order_id));
6295 assert!(!manager.order_query_recency.contains_key(&client_order_id));
6296 assert!(!manager.order_query_pending.contains(&client_order_id));
6297 assert_eq!(
6298 manager.order_lookback_warnings,
6299 IndexSet::from([client_order_id])
6300 );
6301 assert_eq!(manager.order_lookback_warnings.len(), 1);
6302 }
6303 }
6304
6305 #[rstest]
6306 fn test_open_check_lookback_warning_clears_at_boundary_and_rearms() {
6307 let client_order_id = ClientOrderId::from("O-LOOKBACK-REARM");
6308 let client_id = ClientId::from("BINANCE");
6309 let cache = Rc::new(RefCell::new(Cache::default()));
6310 insert_accepted_limit_order(
6311 &cache,
6312 client_order_id,
6313 VenueOrderId::from("V-LOOKBACK-REARM"),
6314 crypto_perpetual_ethusdt().id(),
6315 client_id,
6316 );
6317 let order = cache.borrow().order_owned(&client_order_id).unwrap();
6318 let old_cutoff = order.ts_last().saturating_add(DurationNanos::new(1));
6319
6320 let make_check = |start| OpenOrderReportCheck {
6321 command: GenerateOrderStatusReports::new(
6322 UUID4::new(),
6323 UnixNanos::from(1),
6324 false,
6325 None,
6326 Some(start),
6327 None,
6328 None,
6329 None,
6330 ),
6331 filtered_orders: vec![order.clone()],
6332 client_coverage: IndexMap::from([(
6333 client_order_id,
6334 ReportClientCoverage::Resolved(IndexSet::from([client_id])),
6335 )]),
6336 };
6337
6338 let queried_clients = IndexSet::new();
6339
6340 let mut manager = ExecutionManager::new(
6341 Rc::new(RefCell::new(VirtualClock::new())),
6342 cache,
6343 ExecutionManagerConfig {
6344 open_check_open_only: false,
6345 ..Default::default()
6346 },
6347 )
6348 .expect("valid config");
6349
6350 manager.reconcile_open_order_reports(
6351 &make_check(old_cutoff),
6352 Vec::new(),
6353 &queried_clients,
6354 &IndexSet::new(),
6355 &[],
6356 );
6357 assert!(manager.order_lookback_warnings.contains(&client_order_id));
6358
6359 let boundary = order.ts_last();
6360 let boundary_result = manager.reconcile_open_order_reports(
6361 &make_check(boundary),
6362 Vec::new(),
6363 &queried_clients,
6364 &IndexSet::new(),
6365 &[],
6366 );
6367 assert!(boundary_result.targeted_queries.is_empty());
6368 assert!(!manager.order_lookback_warnings.contains(&client_order_id));
6369 assert!(manager.order_coverage_warnings.contains(&client_order_id));
6370
6371 manager.reconcile_open_order_reports(
6372 &make_check(old_cutoff),
6373 Vec::new(),
6374 &queried_clients,
6375 &IndexSet::new(),
6376 &[],
6377 );
6378 assert_eq!(
6379 manager.order_lookback_warnings,
6380 IndexSet::from([client_order_id])
6381 );
6382 }
6383
6384 #[rstest]
6385 fn test_venue_order_id_mapped_report_clears_old_order_lookback_warning() {
6386 let client_order_id = ClientOrderId::from("O-LOOKBACK-MAPPED");
6387 let venue_order_id = VenueOrderId::from("V-LOOKBACK-MAPPED");
6388 let client_id = ClientId::from("BINANCE");
6389 let instrument_id = crypto_perpetual_ethusdt().id();
6390 let cache = Rc::new(RefCell::new(Cache::default()));
6391 insert_accepted_limit_order(
6392 &cache,
6393 client_order_id,
6394 venue_order_id,
6395 instrument_id,
6396 client_id,
6397 );
6398 let order = cache.borrow().order_owned(&client_order_id).unwrap();
6399 let cutoff = order.ts_last().saturating_add(DurationNanos::new(1));
6400
6401 let check = OpenOrderReportCheck {
6402 command: GenerateOrderStatusReports::new(
6403 UUID4::new(),
6404 UnixNanos::from(1),
6405 false,
6406 None,
6407 Some(cutoff),
6408 None,
6409 None,
6410 None,
6411 ),
6412 filtered_orders: vec![order],
6413 client_coverage: IndexMap::from([(
6414 client_order_id,
6415 ReportClientCoverage::Resolved(IndexSet::from([client_id])),
6416 )]),
6417 };
6418
6419 let mut manager = ExecutionManager::new(
6420 Rc::new(RefCell::new(VirtualClock::new())),
6421 cache,
6422 ExecutionManagerConfig {
6423 open_check_open_only: false,
6424 ..Default::default()
6425 },
6426 )
6427 .expect("valid config");
6428
6429 manager.order_lookback_warnings.insert(client_order_id);
6430
6431 let report = OrderStatusReport::new(
6434 AccountId::from("TEST-001"),
6435 instrument_id,
6436 None,
6437 venue_order_id,
6438 OrderSide::Buy.into(),
6439 OrderType::Limit,
6440 TimeInForce::Gtc,
6441 OrderStatus::Accepted,
6442 Quantity::from("10.0"),
6443 Quantity::from("0.0"),
6444 UnixNanos::from(0),
6445 UnixNanos::from(0),
6446 UnixNanos::from(0),
6447 None,
6448 );
6449
6450 let result = manager.reconcile_open_order_reports(
6451 &check,
6452 vec![SourcedOrderStatusReport { client_id, report }],
6453 &IndexSet::from([client_id]),
6454 &IndexSet::new(),
6455 &[],
6456 );
6457
6458 assert!(result.targeted_queries.is_empty());
6459 assert!(!manager.order_lookback_warnings.contains(&client_order_id));
6460 }
6461
6462 #[rstest]
6463 fn test_positive_report_clears_old_order_lookback_warning_without_reinserting_it() {
6464 let client_order_id = ClientOrderId::from("O-LOOKBACK-REPORTED");
6465 let venue_order_id = VenueOrderId::from("V-LOOKBACK-REPORTED");
6466 let client_id = ClientId::from("BINANCE");
6467 let instrument_id = crypto_perpetual_ethusdt().id();
6468 let cache = Rc::new(RefCell::new(Cache::default()));
6469 insert_accepted_limit_order(
6470 &cache,
6471 client_order_id,
6472 venue_order_id,
6473 instrument_id,
6474 client_id,
6475 );
6476 let order = cache.borrow().order_owned(&client_order_id).unwrap();
6477 let cutoff = order.ts_last().saturating_add(DurationNanos::new(1));
6478
6479 let check = OpenOrderReportCheck {
6480 command: GenerateOrderStatusReports::new(
6481 UUID4::new(),
6482 UnixNanos::from(1),
6483 false,
6484 None,
6485 Some(cutoff),
6486 None,
6487 None,
6488 None,
6489 ),
6490 filtered_orders: vec![order],
6491 client_coverage: IndexMap::from([(
6492 client_order_id,
6493 ReportClientCoverage::Resolved(IndexSet::from([client_id])),
6494 )]),
6495 };
6496
6497 let mut manager = ExecutionManager::new(
6498 Rc::new(RefCell::new(VirtualClock::new())),
6499 cache,
6500 ExecutionManagerConfig {
6501 open_check_open_only: false,
6502 ..Default::default()
6503 },
6504 )
6505 .expect("valid config");
6506
6507 manager.order_lookback_warnings.insert(client_order_id);
6508
6509 let report = OrderStatusReport::new(
6510 AccountId::from("TEST-001"),
6511 instrument_id,
6512 Some(client_order_id),
6513 venue_order_id,
6514 OrderSide::Buy.into(),
6515 OrderType::Limit,
6516 TimeInForce::Gtc,
6517 OrderStatus::Accepted,
6518 Quantity::from("10.0"),
6519 Quantity::from("0.0"),
6520 UnixNanos::from(0),
6521 UnixNanos::from(0),
6522 UnixNanos::from(0),
6523 None,
6524 );
6525
6526 let result = manager.reconcile_open_order_reports(
6527 &check,
6528 vec![SourcedOrderStatusReport { client_id, report }],
6529 &IndexSet::from([client_id]),
6530 &IndexSet::new(),
6531 &[],
6532 );
6533
6534 assert!(result.targeted_queries.is_empty());
6535 assert!(!manager.order_lookback_warnings.contains(&client_order_id));
6536 }
6537
6538 #[rstest]
6539 fn test_targeted_reconciliation_uses_source_client_and_retries_commission() {
6540 let (mut manager, _cache, _order, report, _instrument) = cached_commission_fixtures();
6541 let client_order_id = report.client_order_id.unwrap();
6542 let client_id = ClientId::from("STUB");
6543 let failing_client = CommissionStubClient::new(CommissionOutcome::Failure);
6544
6545 let failed = manager.reconcile_targeted_order_reports(
6546 vec![TargetedOrderReportResult {
6547 client_order_id,
6548 client_id: Some(client_id),
6549 report: Some(report.clone()),
6550 fills: Vec::new(),
6551 coverage_complete: true,
6552 }],
6553 &[&failing_client],
6554 );
6555
6556 assert!(failed.is_empty());
6557
6558 let expected = Money::new(1.5, Currency::USDT());
6559 let retry_client = CommissionStubClient::new(CommissionOutcome::Value(expected));
6560 let retry = manager.reconcile_targeted_order_reports(
6561 vec![TargetedOrderReportResult {
6562 client_order_id,
6563 client_id: Some(client_id),
6564 report: Some(report),
6565 fills: Vec::new(),
6566 coverage_complete: true,
6567 }],
6568 &[&retry_client],
6569 );
6570
6571 assert_eq!(retry.len(), 1);
6572
6573 let OrderEventAny::Filled(fill) = &retry[0] else {
6574 panic!("expected inferred fill on valid targeted retry");
6575 };
6576
6577 assert_eq!(fill.last_qty, Quantity::from("10.0"));
6578 assert_eq!(fill.commission, Some(expected));
6579 }
6580
6581 #[rstest]
6582 fn test_clear_recon_tracking_removes_targeted_query() {
6583 let clock = Rc::new(RefCell::new(VirtualClock::new()));
6584 let cache = Rc::new(RefCell::new(Cache::default()));
6585 let mut manager = ExecutionManager::new(clock, cache, ExecutionManagerConfig::default())
6586 .expect("valid config");
6587 let client_order_id = ClientOrderId::from("O-TARGETED-CLEAR");
6588 manager.order_query_pending.insert(client_order_id);
6589 manager.order_lookback_warnings.insert(client_order_id);
6590
6591 manager.clear_recon_tracking(&client_order_id, true);
6592
6593 assert!(manager.order_query_pending.is_empty());
6594 assert!(manager.order_lookback_warnings.is_empty());
6595 }
6596
6597 #[rstest]
6598 fn test_submission_confirmation_skips_untracked_order_cache(
6599 #[values(false, true)] previously_registered: bool,
6600 ) {
6601 let clock = Rc::new(RefCell::new(VirtualClock::new()));
6602 let cache = Rc::new(RefCell::new(Cache::default()));
6603 let mut manager = ExecutionManager::new(
6604 clock,
6605 cache.clone(),
6606 ExecutionManagerConfig {
6607 submission_recovery_policy: SubmissionRecoveryPolicy::RetainUnresolved,
6608 ..Default::default()
6609 },
6610 )
6611 .expect("valid config");
6612 let order = OrderTestBuilder::new(OrderType::Limit)
6613 .instrument_id(InstrumentId::from("ETHUSDT.BYBIT"))
6614 .quantity(Quantity::from("10.0"))
6615 .price(Price::from("100.0"))
6616 .build();
6617 let client_order_id = order.client_order_id();
6618 let account_id = AccountId::from("TEST-001");
6619 let submitted = TestOrderEventStubs::submitted(&order, account_id);
6620 if previously_registered {
6621 manager.register_submission(order.init_event(), None);
6622 }
6623 cache
6624 .borrow_mut()
6625 .add_order(order, None, None, false)
6626 .unwrap();
6627 let order = cache.borrow_mut().update_order(&submitted).unwrap();
6628 let accepted =
6629 TestOrderEventStubs::accepted(&order, account_id, VenueOrderId::from("V-UNTRACKED"));
6630 cache.borrow_mut().update_order(&accepted).unwrap();
6631
6632 if previously_registered {
6633 manager.confirm_submission_outcome(&client_order_id);
6634 }
6635 assert!(!manager.submissions.contains_key(&client_order_id));
6636 assert!(!manager.order_inflight_checks.contains_key(&client_order_id));
6637
6638 let cached = cache.borrow_mut();
6640 manager.confirm_submission_outcome(&client_order_id);
6641
6642 assert_eq!(
6643 cached.order(&client_order_id).unwrap().status(),
6644 OrderStatus::Accepted,
6645 );
6646 }
6647
6648 #[rstest]
6649 fn test_register_inflight_skips_filtered_order() {
6650 let client_order_id = ClientOrderId::from("O-FILTERED-REGISTER");
6651 let clock = Rc::new(RefCell::new(VirtualClock::new()));
6652 let cache = Rc::new(RefCell::new(Cache::default()));
6653
6654 let mut manager = ExecutionManager::new(
6655 clock,
6656 cache,
6657 ExecutionManagerConfig {
6658 filtered_client_order_ids: IndexSet::from([client_order_id]),
6659 ..Default::default()
6660 },
6661 )
6662 .expect("valid config");
6663
6664 manager.register_inflight(client_order_id);
6665
6666 assert!(!manager.order_inflight_checks.contains_key(&client_order_id));
6667 assert!(!manager.order_recon_retries.contains_key(&client_order_id));
6668 }
6669
6670 #[rstest]
6671 #[cfg_attr(
6672 not(all(feature = "simulation", madsim)),
6673 tokio::test(start_paused = true)
6674 )]
6675 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
6676 async fn test_inflight_check_retires_order_filtered_after_registration() {
6677 let client_order_id = ClientOrderId::from("O-FILTERED-LATE");
6678 let clock = Rc::new(RefCell::new(VirtualClock::new()));
6679 let cache = Rc::new(RefCell::new(Cache::default()));
6680
6681 let mut manager = ExecutionManager::new(
6682 clock,
6683 cache,
6684 ExecutionManagerConfig {
6685 inflight_threshold_ms: 100,
6686 ..Default::default()
6687 },
6688 )
6689 .expect("valid config");
6690
6691 manager.register_inflight(client_order_id);
6692 manager
6693 .config
6694 .filtered_client_order_ids
6695 .insert(client_order_id);
6696 dst::time::sleep(Duration::from_millis(101)).await;
6697
6698 let first = manager.check_inflight_orders();
6699
6700 assert!(first.events.is_empty());
6701 assert!(first.queries.is_empty());
6702 assert!(!manager.order_inflight_checks.contains_key(&client_order_id));
6703 assert!(!manager.order_recon_retries.contains_key(&client_order_id));
6704
6705 dst::time::sleep(Duration::from_millis(101)).await;
6706 let second = manager.check_inflight_orders();
6707 assert!(second.events.is_empty());
6708 assert!(second.queries.is_empty());
6709 assert!(!manager.order_inflight_checks.contains_key(&client_order_id));
6710 }
6711
6712 #[rstest]
6713 #[case(false, OrderStatus::PendingUpdate, true, true, true)]
6714 #[case(false, OrderStatus::Accepted, false, true, true)]
6715 #[case(false, OrderStatus::Canceled, false, true, false)]
6716 #[case(true, OrderStatus::PendingCancel, true, true, true)]
6717 #[case(true, OrderStatus::Accepted, false, true, true)]
6718 #[case(true, OrderStatus::Filled, false, true, false)]
6719 fn test_observe_order_status_report_tracking_matrix(
6720 #[case] with_fills: bool,
6721 #[case] status: OrderStatus,
6722 #[case] expect_inflight: bool,
6723 #[case] expect_activity: bool,
6724 #[case] expect_last_query: bool,
6725 ) {
6726 let client_order_id = ClientOrderId::from("O-STATUS-MATRIX");
6727 let clock = Rc::new(RefCell::new(VirtualClock::new()));
6728 let cache = Rc::new(RefCell::new(Cache::default()));
6729 let mut manager = ExecutionManager::new(clock, cache, ExecutionManagerConfig::default())
6730 .expect("valid config");
6731 manager.register_inflight(client_order_id);
6732 manager.order_query_recency.mark(client_order_id);
6733 manager.order_coverage_warnings.insert(client_order_id);
6734 manager.order_coverage_unresolved.insert(client_order_id);
6735 manager.order_query_pending.insert(client_order_id);
6736
6737 let order_report = OrderStatusReport::new(
6738 AccountId::from("TEST-001"),
6739 crypto_perpetual_ethusdt().id(),
6740 Some(client_order_id),
6741 VenueOrderId::from("V-STATUS-MATRIX"),
6742 OrderSide::Buy.into(),
6743 OrderType::Limit,
6744 TimeInForce::Gtc,
6745 status,
6746 Quantity::from("10.0"),
6747 Quantity::from("0.0"),
6748 UnixNanos::from(1_000),
6749 UnixNanos::from(1_000),
6750 UnixNanos::from(1_000),
6751 None,
6752 );
6753
6754 let report = if with_fills {
6755 ExecutionReport::OrderWithFills(Box::new(order_report), Vec::new())
6756 } else {
6757 ExecutionReport::Order(Box::new(order_report))
6758 };
6759
6760 manager.observe_execution_report(&report);
6761
6762 assert_eq!(
6763 manager.order_inflight_checks.contains_key(&client_order_id),
6764 expect_inflight,
6765 );
6766 assert_eq!(
6767 manager.order_recon_retries.contains_key(&client_order_id),
6768 expect_inflight,
6769 );
6770 assert_eq!(
6771 manager.order_activity.contains_key(&client_order_id),
6772 expect_activity,
6773 );
6774 assert_eq!(
6775 manager.order_query_recency.contains_key(&client_order_id),
6776 expect_last_query,
6777 );
6778 assert_eq!(
6779 manager.order_coverage_warnings.contains(&client_order_id),
6780 expect_inflight,
6781 );
6782 assert_eq!(
6783 manager.order_coverage_unresolved.contains(&client_order_id),
6784 expect_inflight,
6785 );
6786 assert_eq!(
6787 manager.order_query_pending.contains(&client_order_id),
6788 expect_inflight,
6789 );
6790 }
6791
6792 #[rstest]
6793 #[case(OrderStatus::PendingUpdate)]
6794 #[case(OrderStatus::PendingCancel)]
6795 fn test_accepted_report_during_pending_command_preserves_inflight_tracking(
6796 #[case] pending_status: OrderStatus,
6797 ) {
6798 let client_order_id = ClientOrderId::from("O-PENDING-COMMAND");
6799 let venue_order_id = VenueOrderId::from("V-PENDING-COMMAND");
6800 let account_id = AccountId::from("TEST-001");
6801 let client_id = ClientId::from("TEST");
6802 let instrument_id = crypto_perpetual_ethusdt().id();
6803 let clock = Rc::new(RefCell::new(VirtualClock::new()));
6804 let cache = Rc::new(RefCell::new(Cache::default()));
6805 insert_accepted_limit_order(
6806 &cache,
6807 client_order_id,
6808 venue_order_id,
6809 instrument_id,
6810 client_id,
6811 );
6812
6813 let order = cache.borrow().order_owned(&client_order_id).unwrap();
6814
6815 let event = match pending_status {
6816 OrderStatus::PendingUpdate => OrderEventAny::PendingUpdate(
6817 OrderPendingUpdateSpec::builder()
6818 .trader_id(order.trader_id())
6819 .strategy_id(order.strategy_id())
6820 .instrument_id(instrument_id)
6821 .client_order_id(client_order_id)
6822 .account_id(account_id)
6823 .venue_order_id(venue_order_id)
6824 .build(),
6825 ),
6826 OrderStatus::PendingCancel => OrderEventAny::PendingCancel(
6827 OrderPendingCancelSpec::builder()
6828 .trader_id(order.trader_id())
6829 .strategy_id(order.strategy_id())
6830 .instrument_id(instrument_id)
6831 .client_order_id(client_order_id)
6832 .account_id(account_id)
6833 .venue_order_id(venue_order_id)
6834 .build(),
6835 ),
6836 _ => unreachable!(),
6837 };
6838
6839 cache.borrow_mut().update_order(&event).unwrap();
6840
6841 let mut manager =
6842 ExecutionManager::new(clock, cache.clone(), ExecutionManagerConfig::default())
6843 .expect("valid config");
6844 manager.register_inflight(client_order_id);
6845 manager.order_query_recency.mark(client_order_id);
6846 manager.order_coverage_warnings.insert(client_order_id);
6847 manager.order_coverage_unresolved.insert(client_order_id);
6848 manager.order_query_pending.insert(client_order_id);
6849 let report = OrderStatusReport::new(
6850 account_id,
6851 instrument_id,
6852 Some(client_order_id),
6853 venue_order_id,
6854 OrderSide::Buy.into(),
6855 OrderType::Limit,
6856 TimeInForce::Gtc,
6857 OrderStatus::Accepted,
6858 Quantity::from("10.0"),
6859 Quantity::from("0.0"),
6860 UnixNanos::from(1_000),
6861 UnixNanos::from(1_000),
6862 UnixNanos::from(1_000),
6863 None,
6864 )
6865 .with_price(Price::from("100.0"));
6866
6867 manager.observe_execution_report(&ExecutionReport::Order(Box::new(report.clone())));
6868 let order = cache.borrow().order_owned(&client_order_id).unwrap();
6869 let events =
6870 generate_reconciliation_order_events(&order, &report, None, UnixNanos::from(1_000));
6871
6872 assert!(events.is_empty());
6873 assert_eq!(order.status(), pending_status);
6874 assert!(manager.order_inflight_checks.contains_key(&client_order_id));
6875 assert!(manager.order_recon_retries.contains_key(&client_order_id));
6876 assert!(manager.order_query_recency.contains_key(&client_order_id));
6877 assert!(manager.order_activity.contains_key(&client_order_id));
6878 assert!(manager.order_coverage_warnings.contains(&client_order_id));
6879 assert!(manager.order_coverage_unresolved.contains(&client_order_id));
6880 assert!(manager.order_query_pending.contains(&client_order_id));
6881 }
6882
6883 #[rstest]
6884 fn test_superseded_cancel_report_preserves_missing_order_grace() {
6885 let client_order_id = ClientOrderId::from("O-CANCEL-REPLACE");
6886 let old_venue_order_id = VenueOrderId::from("V-CANCEL-REPLACE-OLD");
6887 let new_venue_order_id = VenueOrderId::from("V-CANCEL-REPLACE-NEW");
6888 let account_id = AccountId::from("TEST-001");
6889 let client_id = ClientId::from("TEST");
6890 let instrument_id = crypto_perpetual_ethusdt().id();
6891 let clock = Rc::new(RefCell::new(VirtualClock::new()));
6892 let cache = Rc::new(RefCell::new(Cache::default()));
6893 insert_accepted_limit_order(
6894 &cache,
6895 client_order_id,
6896 old_venue_order_id,
6897 instrument_id,
6898 client_id,
6899 );
6900
6901 let order = cache.borrow().order_owned(&client_order_id).unwrap();
6902 let pending_update = OrderPendingUpdateSpec::builder()
6903 .trader_id(order.trader_id())
6904 .strategy_id(order.strategy_id())
6905 .instrument_id(order.instrument_id())
6906 .client_order_id(client_order_id)
6907 .account_id(account_id)
6908 .venue_order_id(old_venue_order_id)
6909 .build();
6910 cache
6911 .borrow_mut()
6912 .update_order(&OrderEventAny::PendingUpdate(pending_update))
6913 .unwrap();
6914 let order = cache.borrow().order_owned(&client_order_id).unwrap();
6915 let updated = OrderUpdatedSpec::builder()
6916 .trader_id(order.trader_id())
6917 .strategy_id(order.strategy_id())
6918 .instrument_id(order.instrument_id())
6919 .client_order_id(client_order_id)
6920 .quantity(order.quantity())
6921 .venue_order_id(new_venue_order_id)
6922 .account_id(account_id)
6923 .build();
6924 cache
6925 .borrow_mut()
6926 .update_order(&OrderEventAny::Updated(updated))
6927 .unwrap();
6928
6929 let mut manager = ExecutionManager::new(
6930 clock,
6931 cache.clone(),
6932 ExecutionManagerConfig {
6933 open_check_missing_retries: 1,
6934 ..Default::default()
6935 },
6936 )
6937 .expect("valid config");
6938
6939 manager.record_local_activity(client_order_id);
6940 assert!(
6941 manager
6942 .prepare_missing_order_query(client_order_id)
6943 .is_none()
6944 );
6945
6946 let report = OrderStatusReport::new(
6947 account_id,
6948 instrument_id,
6949 Some(client_order_id),
6950 old_venue_order_id,
6951 OrderSide::Buy.into(),
6952 OrderType::Limit,
6953 TimeInForce::Gtc,
6954 OrderStatus::Canceled,
6955 Quantity::from("10.0"),
6956 Quantity::from("0.0"),
6957 UnixNanos::from(1_000),
6958 UnixNanos::from(2_000),
6959 UnixNanos::from(3_000),
6960 None,
6961 );
6962
6963 manager.observe_execution_report(&ExecutionReport::Order(Box::new(report.clone())));
6964 let order = cache.borrow().order_owned(&client_order_id).unwrap();
6965 let events =
6966 generate_reconciliation_order_events(&order, &report, None, UnixNanos::from(1_000));
6967
6968 assert!(events.is_empty());
6969 assert_eq!(order.status(), OrderStatus::Accepted);
6970 assert_eq!(order.venue_order_id(), Some(new_venue_order_id));
6971 assert!(manager.order_activity.contains_key(&client_order_id));
6972 assert!(
6973 manager
6974 .prepare_missing_order_query(client_order_id)
6975 .is_none()
6976 );
6977 assert_eq!(manager.recon_check_retry_count(&client_order_id), 0);
6978 }
6979
6980 #[rstest]
6981 #[cfg_attr(
6982 not(all(feature = "simulation", madsim)),
6983 tokio::test(start_paused = true)
6984 )]
6985 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
6986 async fn test_prune_order_local_activity_uses_open_check_threshold() {
6987 let old_id = ClientOrderId::from("O-ACTIVITY-OLD");
6988 let fresh_id = ClientOrderId::from("O-ACTIVITY-FRESH");
6989 let clock = Rc::new(RefCell::new(VirtualClock::new()));
6990 let cache = Rc::new(RefCell::new(Cache::default()));
6991
6992 let mut manager = ExecutionManager::new(
6993 clock,
6994 cache,
6995 ExecutionManagerConfig {
6996 open_check_threshold_ns: DurationNanos::from_millis(100),
6997 ..Default::default()
6998 },
6999 )
7000 .expect("valid config");
7001
7002 manager.record_local_activity(old_id);
7003 dst::time::sleep(Duration::from_millis(101)).await;
7004 manager.record_local_activity(fresh_id);
7005
7006 manager.prune_order_local_activity();
7007
7008 assert!(!manager.order_activity.contains_key(&old_id));
7009 assert!(manager.order_activity.contains_key(&fresh_id));
7010 }
7011
7012 #[rstest]
7013 #[cfg_attr(
7014 not(all(feature = "simulation", madsim)),
7015 tokio::test(start_paused = true)
7016 )]
7017 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
7018 async fn test_unapplied_fill_report_expires_after_position_check_threshold() {
7019 let clock = Rc::new(RefCell::new(VirtualClock::new()));
7020 let cache = Rc::new(RefCell::new(Cache::default()));
7021
7022 let mut manager = ExecutionManager::new(
7023 clock,
7024 cache,
7025 ExecutionManagerConfig {
7026 position_check_threshold_ns: DurationNanos::from_millis(100),
7027 ..Default::default()
7028 },
7029 )
7030 .expect("valid config");
7031
7032 let report = unapplied_fill_report("T-UNAPPLIED");
7033 let mut expired = vec![manager.is_unapplied_fill_report_expired(&report)];
7034
7035 manager.record_unapplied_fill_report(&report);
7036 dst::time::sleep(Duration::from_millis(60)).await;
7037 manager.record_unapplied_fill_report(&report);
7038 expired.push(manager.is_unapplied_fill_report_expired(&report));
7039 dst::time::sleep(Duration::from_millis(40)).await;
7040 expired.push(manager.is_unapplied_fill_report_expired(&report));
7041
7042 assert_eq!(expired, vec![false, false, true]);
7043 }
7044
7045 #[rstest]
7046 #[cfg_attr(
7047 not(all(feature = "simulation", madsim)),
7048 tokio::test(start_paused = true)
7049 )]
7050 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
7051 async fn test_record_unapplied_fill_report_prunes_outside_position_check_lookback() {
7052 let clock = Rc::new(RefCell::new(VirtualClock::new()));
7053 let cache = Rc::new(RefCell::new(Cache::default()));
7054
7055 let mut manager = ExecutionManager::new(
7056 clock,
7057 cache,
7058 ExecutionManagerConfig {
7059 position_check_lookback_mins: 1,
7060 position_check_threshold_ns: DurationNanos::from_secs(5),
7061 ..Default::default()
7062 },
7063 )
7064 .expect("valid config");
7065
7066 let old_report = unapplied_fill_report("T-UNAPPLIED-OLD");
7067 let recent_report = unapplied_fill_report("T-UNAPPLIED-RECENT");
7068 let fresh_report = unapplied_fill_report("T-UNAPPLIED-FRESH");
7069
7070 manager.record_unapplied_fill_report(&old_report);
7071 dst::time::sleep(Duration::from_secs(30)).await;
7072 manager.record_unapplied_fill_report(&recent_report);
7073 dst::time::sleep(Duration::from_secs(31)).await;
7074 manager.record_unapplied_fill_report(&fresh_report);
7075
7076 assert!(!manager.fills_unapplied.contains_key(&(
7077 old_report.account_id,
7078 old_report.instrument_id,
7079 old_report.trade_id,
7080 )));
7081 assert!(manager.fills_unapplied.contains_key(&(
7082 recent_report.account_id,
7083 recent_report.instrument_id,
7084 recent_report.trade_id,
7085 )));
7086 assert!(manager.fills_unapplied.contains_key(&(
7087 fresh_report.account_id,
7088 fresh_report.instrument_id,
7089 fresh_report.trade_id,
7090 )));
7091 }
7092
7093 fn unapplied_fill_report(trade_id: &str) -> FillReport {
7094 FillReport::new(
7095 AccountId::from("SIM-001"),
7096 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
7097 VenueOrderId::from("V-UNAPPLIED"),
7098 TradeId::from(trade_id),
7099 OrderSide::Buy,
7100 Quantity::from("1.0"),
7101 Price::from("100.0"),
7102 Money::from("0.10 USDT"),
7103 LiquiditySide::Taker,
7104 None,
7105 None,
7106 UnixNanos::from(1),
7107 UnixNanos::from(1),
7108 None,
7109 )
7110 }
7111
7112 #[rstest]
7113 fn test_prepare_open_order_report_check_builds_bulk_command_with_config() {
7114 let lookback_mins = 5_u64;
7115 let lookback = DurationNanos::from_mins(lookback_mins);
7116 let clock = Rc::new(RefCell::new(VirtualClock::new()));
7117 let cache = Rc::new(RefCell::new(Cache::default()));
7118
7119 let mut manager = ExecutionManager::new(
7120 clock.clone(),
7121 cache.clone(),
7122 ExecutionManagerConfig {
7123 open_check_lookback_mins: Some(lookback_mins),
7124 open_check_open_only: false,
7125 reconciliation_instrument_ids: IndexSet::from([crypto_perpetual_ethusdt().id()]),
7126 ..Default::default()
7127 },
7128 )
7129 .expect("valid config");
7130
7131 let included_id = ClientOrderId::from("O-REPORT-001");
7132 let excluded_id = ClientOrderId::from("O-REPORT-002");
7133 let included_instrument_id = crypto_perpetual_ethusdt().id();
7134 let excluded_instrument_id = btcusd_bybit().id();
7135
7136 cache
7137 .borrow_mut()
7138 .add_instrument(InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt()))
7139 .unwrap();
7140 cache
7141 .borrow_mut()
7142 .add_instrument(InstrumentAny::CryptoPerpetual(btcusd_bybit()))
7143 .unwrap();
7144 insert_accepted_limit_order(
7145 &cache,
7146 included_id,
7147 VenueOrderId::from("V-REPORT-001"),
7148 included_instrument_id,
7149 ClientId::from("BINANCE"),
7150 );
7151 insert_accepted_limit_order(
7152 &cache,
7153 excluded_id,
7154 VenueOrderId::from("V-REPORT-002"),
7155 excluded_instrument_id,
7156 ClientId::from("BYBIT"),
7157 );
7158 clock
7159 .borrow_mut()
7160 .advance_time(UnixNanos::default().saturating_add(lookback * 2), true);
7161
7162 let ts_now = clock.borrow().timestamp_ns();
7163 let command_id = UUID4::new();
7164 let check = manager.prepare_open_order_report_check(command_id, &[]);
7165
7166 assert_eq!(check.command.command_id, command_id);
7167 assert_eq!(check.command.ts_init, ts_now);
7168 assert!(!check.command.open_only);
7169 assert_eq!(check.command.instrument_id, None);
7170 assert_eq!(check.command.start, Some(ts_now.saturating_sub(lookback)));
7171 assert_eq!(check.command.end, None);
7172 assert_eq!(check.command.log_receipt_level, LogLevel::Debug);
7173 assert_eq!(check.filtered_orders.len(), 1);
7174 assert_eq!(check.filtered_orders[0].client_order_id(), included_id);
7175 }
7176
7177 #[rstest]
7178 fn test_prepare_position_report_check_builds_bulk_command_with_coverage() {
7179 let clock = Rc::new(RefCell::new(VirtualClock::new()));
7180 let cache = Rc::new(RefCell::new(Cache::default()));
7181
7182 let manager = ExecutionManager::new(
7183 clock.clone(),
7184 cache.clone(),
7185 ExecutionManagerConfig {
7186 reconciliation_instrument_ids: IndexSet::from([crypto_perpetual_ethusdt().id()]),
7187 ..Default::default()
7188 },
7189 )
7190 .expect("valid config");
7191
7192 let included_instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
7193 let excluded_instrument = InstrumentAny::CryptoPerpetual(btcusd_bybit());
7194
7195 cache
7196 .borrow_mut()
7197 .add_instrument(included_instrument.clone())
7198 .unwrap();
7199 cache
7200 .borrow_mut()
7201 .add_instrument(excluded_instrument.clone())
7202 .unwrap();
7203 let included_position = insert_open_position(
7204 &cache,
7205 &included_instrument,
7206 PositionId::from("P-REPORT-001"),
7207 OrderSide::Buy,
7208 "5.0",
7209 "3000.00",
7210 );
7211 insert_open_position(
7212 &cache,
7213 &excluded_instrument,
7214 PositionId::from("P-REPORT-002"),
7215 OrderSide::Buy,
7216 "2.0",
7217 "40000.00",
7218 );
7219
7220 let ts_now = clock.borrow().timestamp_ns();
7221 let command_id = UUID4::new();
7222 let check = manager.prepare_position_report_check(command_id, &[]);
7223 let key = (
7224 included_position.instrument_id,
7225 included_position.account_id,
7226 );
7227
7228 assert_eq!(check.command.command_id, command_id);
7229 assert_eq!(check.command.ts_init, ts_now);
7230 assert_eq!(check.command.instrument_id, None);
7231 assert_eq!(check.command.start, None);
7232 assert_eq!(check.command.end, None);
7233 assert_eq!(check.command.log_receipt_level, LogLevel::Debug);
7234 assert_eq!(check.client_coverage.len(), 1);
7235 assert_eq!(
7236 check.client_coverage.get(&key),
7237 Some(&ReportClientCoverage::Unresolved)
7238 );
7239 assert_eq!(check.activity_revisions.get(&key), Some(&0));
7240 }
7241
7242 #[rstest]
7243 #[case::unresolved(ReportClientCoverage::Unresolved)]
7244 #[case::empty(ReportClientCoverage::Resolved(IndexSet::new()))]
7245 #[case::unqueried(ReportClientCoverage::Resolved(IndexSet::from([ClientId::from("BYBIT")])))]
7246 fn test_position_reconciliation_preserves_position_without_client_coverage(
7247 #[case] coverage: ReportClientCoverage,
7248 ) {
7249 let clock = Rc::new(RefCell::new(VirtualClock::new()));
7250 let cache = Rc::new(RefCell::new(Cache::default()));
7251
7252 let mut manager = ExecutionManager::new(
7253 clock,
7254 cache.clone(),
7255 ExecutionManagerConfig {
7256 position_check_threshold_ns: DurationNanos::ZERO,
7257 ..Default::default()
7258 },
7259 )
7260 .expect("valid config");
7261
7262 let instrument = test_bybit_spot_instrument();
7263 cache
7264 .borrow_mut()
7265 .add_instrument(instrument.clone())
7266 .unwrap();
7267 let position = insert_open_position(
7268 &cache,
7269 &instrument,
7270 PositionId::from("P-COVERAGE-PRESERVED"),
7271 OrderSide::Buy,
7272 "5.0",
7273 "3000.00",
7274 );
7275 let key = (position.instrument_id, position.account_id);
7276 let mut check = manager.prepare_position_report_check(UUID4::new(), &[]);
7277 check.client_coverage.insert(key, coverage);
7278 manager.set_position_reconciliation_retries(key, PositionReportShape::Unambiguous, 1);
7279
7280 let events = manager.reconcile_position_reports(
7281 &check,
7282 Vec::new(),
7283 &IndexSet::new(),
7284 &IndexSet::new(),
7285 );
7286
7287 assert!(events.is_empty());
7288 assert_eq!(manager.position_recon_retry_count(&key), 1);
7289 }
7290
7291 #[rstest]
7292 fn test_position_reconciliation_preserves_unavailable_spot_coverage() {
7293 let clock = Rc::new(RefCell::new(VirtualClock::new()));
7294 let cache = Rc::new(RefCell::new(Cache::default()));
7295 let mut manager =
7296 ExecutionManager::new(clock, cache.clone(), ExecutionManagerConfig::default())
7297 .expect("valid config");
7298 let derivative = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
7299 let spot = test_bybit_spot_instrument();
7300 cache
7301 .borrow_mut()
7302 .add_instrument(derivative.clone())
7303 .unwrap();
7304 cache.borrow_mut().add_instrument(spot.clone()).unwrap();
7305 let derivative_position = insert_open_position(
7306 &cache,
7307 &derivative,
7308 PositionId::from("P-DERIVATIVE-RECONCILE"),
7309 OrderSide::Buy,
7310 "5.0",
7311 "3000.00",
7312 );
7313 let spot_position = insert_open_position(
7314 &cache,
7315 &spot,
7316 PositionId::from("P-SPOT-PRESERVED"),
7317 OrderSide::Buy,
7318 "2.0",
7319 "2000.00",
7320 );
7321 let client = PositionCoverageStubClient;
7322 let check = manager.prepare_position_report_check(UUID4::new(), &[&client]);
7323 let queried_clients = IndexSet::from([client.client_id()]);
7324
7325 let events = manager.reconcile_position_reports(
7326 &check,
7327 Vec::new(),
7328 &queried_clients,
7329 &IndexSet::new(),
7330 );
7331
7332 assert!(events.iter().any(|event| {
7333 matches!(event, OrderEventAny::Filled(fill) if fill.instrument_id == derivative_position.instrument_id)
7334 }));
7335 assert!(!events.iter().any(|event| {
7336 matches!(event, OrderEventAny::Filled(fill) if fill.instrument_id == spot_position.instrument_id)
7337 }));
7338 }
7339
7340 #[rstest]
7341 fn test_position_reconciliation_preserves_spot_position_when_client_query_fails() {
7342 let clock = Rc::new(RefCell::new(VirtualClock::new()));
7343 let cache = Rc::new(RefCell::new(Cache::default()));
7344 let mut manager =
7345 ExecutionManager::new(clock, cache.clone(), ExecutionManagerConfig::default())
7346 .expect("valid config");
7347 let instrument = test_bybit_spot_instrument();
7348 cache
7349 .borrow_mut()
7350 .add_instrument(instrument.clone())
7351 .unwrap();
7352 let position = insert_open_position(
7353 &cache,
7354 &instrument,
7355 PositionId::from("P-SPOT-QUERY-FAILED"),
7356 OrderSide::Buy,
7357 "5.0",
7358 "3000.00",
7359 );
7360 let key = (position.instrument_id, position.account_id);
7361 let client_id = ClientId::from("BYBIT");
7362 let mut check = manager.prepare_position_report_check(UUID4::new(), &[]);
7363 check.client_coverage.insert(
7364 key,
7365 ReportClientCoverage::Resolved(IndexSet::from([client_id])),
7366 );
7367 let queried_clients = IndexSet::from([client_id]);
7368 let failed_clients = IndexSet::from([client_id]);
7369
7370 let events = manager.reconcile_position_reports(
7371 &check,
7372 Vec::new(),
7373 &queried_clients,
7374 &failed_clients,
7375 );
7376
7377 assert!(
7378 !events
7379 .iter()
7380 .any(|event| matches!(event, OrderEventAny::Filled(_))),
7381 "a failed bulk query must not generate a synthetic closing fill",
7382 );
7383 let cached_position = cache.borrow().position(&position.id).unwrap().clone();
7384 assert!(cached_position.is_open());
7385 assert_eq!(cached_position.quantity, Quantity::from("5.0"));
7386 }
7387
7388 #[rstest]
7389 #[cfg_attr(
7390 not(all(feature = "simulation", madsim)),
7391 tokio::test(start_paused = true)
7392 )]
7393 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
7394 async fn test_position_report_check_defers_activity_recorded_during_delayed_request() {
7395 let clock = Rc::new(RefCell::new(VirtualClock::new()));
7396 let cache = Rc::new(RefCell::new(Cache::default()));
7397
7398 let mut manager = ExecutionManager::new(
7399 clock,
7400 cache.clone(),
7401 ExecutionManagerConfig {
7402 position_check_threshold_ns: DurationNanos::from_secs(5),
7403 ..Default::default()
7404 },
7405 )
7406 .expect("valid config");
7407
7408 let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
7409 let instrument_id = instrument.id();
7410 let position = insert_open_position(
7411 &cache,
7412 &instrument,
7413 PositionId::from("P-ACTIVITY-DURING-REQUEST"),
7414 OrderSide::Buy,
7415 "5.0",
7416 "3000.00",
7417 );
7418 cache
7419 .borrow_mut()
7420 .add_instrument(instrument.clone())
7421 .unwrap();
7422 let account_id = position.account_id;
7423 let check = manager.prepare_position_report_check(UUID4::new(), &[]);
7424
7425 let report = PositionStatusReport::new(
7426 account_id,
7427 instrument_id,
7428 PositionSide::Long,
7429 Quantity::from("5.0"),
7430 UnixNanos::from(1_000_000),
7431 UnixNanos::from(1_000_000),
7432 None,
7433 None,
7434 Some(Decimal::from(3000)),
7435 );
7436
7437 let closed_position = close_long_position(
7438 position,
7439 &instrument,
7440 TradeId::from("T-ACTIVITY-DURING-REQUEST"),
7441 );
7442 cache
7443 .borrow_mut()
7444 .update_position(&closed_position)
7445 .unwrap();
7446 manager.record_position_activity(instrument_id, account_id);
7447
7448 dst::time::sleep(Duration::from_secs(6)).await;
7450
7451 let events = manager.reconcile_position_reports(
7452 &check,
7453 vec![report],
7454 &IndexSet::new(),
7455 &IndexSet::new(),
7456 );
7457
7458 assert!(
7459 !events.iter().any(|event| {
7460 matches!(
7461 event,
7462 OrderEventAny::Filled(fill)
7463 if fill.order_side == OrderSide::Buy
7464 && fill.last_qty == Quantity::from("5.0")
7465 )
7466 }),
7467 "activity recorded after the request started must defer A's stale report",
7468 );
7469 }
7470
7471 #[rstest]
7472 fn test_position_report_check_does_not_defer_activity_recorded_before_request() {
7473 let clock = Rc::new(RefCell::new(VirtualClock::new()));
7474 let cache = Rc::new(RefCell::new(Cache::default()));
7475
7476 let mut manager = ExecutionManager::new(
7477 clock,
7478 cache.clone(),
7479 ExecutionManagerConfig {
7480 position_check_threshold_ns: DurationNanos::ZERO,
7481 ..Default::default()
7482 },
7483 )
7484 .expect("valid config");
7485
7486 let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
7487 let instrument_id = instrument.id();
7488 let position = insert_open_position(
7489 &cache,
7490 &instrument,
7491 PositionId::from("P-ACTIVITY-BEFORE-REQUEST"),
7492 OrderSide::Buy,
7493 "5.0",
7494 "3000.00",
7495 );
7496 cache.borrow_mut().add_instrument(instrument).unwrap();
7497 let account_id = position.account_id;
7498 manager.record_position_activity(instrument_id, account_id);
7499 let check = manager.prepare_position_report_check(UUID4::new(), &[]);
7500
7501 let report = PositionStatusReport::new(
7502 account_id,
7503 instrument_id,
7504 PositionSide::Long,
7505 Quantity::from("10.0"),
7506 UnixNanos::from(1_000_000),
7507 UnixNanos::from(1_000_000),
7508 None,
7509 None,
7510 Some(Decimal::from(3000)),
7511 );
7512
7513 let events = manager.reconcile_position_reports(
7514 &check,
7515 vec![report],
7516 &IndexSet::new(),
7517 &IndexSet::new(),
7518 );
7519
7520 let fills: Vec<_> = events
7521 .iter()
7522 .filter_map(|event| match event {
7523 OrderEventAny::Filled(fill) => Some(fill),
7524 _ => None,
7525 })
7526 .collect();
7527
7528 assert_eq!(fills.len(), 1);
7529 assert_eq!(fills[0].order_side, OrderSide::Buy);
7530 assert_eq!(fills[0].last_qty, Quantity::from("5.0"));
7531 assert_eq!(fills[0].commission, None);
7532 }
7533
7534 #[rstest]
7535 fn test_mass_status_projects_companion_fill_before_void_correction() {
7536 let clock = Rc::new(RefCell::new(VirtualClock::new()));
7537 let cache = Rc::new(RefCell::new(Cache::default()));
7538 let mut manager =
7539 ExecutionManager::new(clock, cache.clone(), ExecutionManagerConfig::default())
7540 .expect("valid config");
7541 let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
7542 let client_order_id = ClientOrderId::from("O-MASS-VOID-001");
7543 let venue_order_id = VenueOrderId::from("V-MASS-VOID-001");
7544 let account_id = AccountId::from("TEST-001");
7545 cache
7546 .borrow_mut()
7547 .add_instrument(instrument.clone())
7548 .unwrap();
7549 insert_accepted_limit_order(
7550 &cache,
7551 client_order_id,
7552 venue_order_id,
7553 instrument.id(),
7554 ClientId::from("BINANCE"),
7555 );
7556 let order = cache.borrow().order_owned(&client_order_id).unwrap();
7557 let initial_fill = TestOrderEventStubs::filled(
7558 &order,
7559 &instrument,
7560 Some(TradeId::from("T-MASS-VOID-INITIAL")),
7561 None,
7562 Some(Price::from("100.0")),
7563 Some(Quantity::from("6.0")),
7564 Some(LiquiditySide::Taker),
7565 None,
7566 None,
7567 Some(account_id),
7568 );
7569 cache.borrow_mut().update_order(&initial_fill).unwrap();
7570 let order = cache.borrow().order_owned(&client_order_id).unwrap();
7571
7572 let report = OrderStatusReport::new(
7573 account_id,
7574 instrument.id(),
7575 Some(client_order_id),
7576 venue_order_id,
7577 OrderSide::Buy.into(),
7578 OrderType::Limit,
7579 TimeInForce::Gtc,
7580 OrderStatus::Canceled,
7581 Quantity::from("10.0"),
7582 Quantity::from("5.0"),
7583 UnixNanos::from(1_000),
7584 UnixNanos::from(1_000),
7585 UnixNanos::from(1_000),
7586 None,
7587 );
7588
7589 let companion_fill = FillReport::new(
7590 account_id,
7591 instrument.id(),
7592 venue_order_id,
7593 TradeId::from("T-MASS-VOID-COMPANION"),
7594 OrderSide::Buy,
7595 Quantity::from("1.0"),
7596 Price::from("100.0"),
7597 Money::zero(instrument.quote_currency()),
7598 LiquiditySide::Taker,
7599 Some(client_order_id),
7600 None,
7601 UnixNanos::from(900),
7602 UnixNanos::from(1_000),
7603 None,
7604 );
7605
7606 let mut fill_queue = ReconciliationFillQueue::default();
7607 let events = manager.reconcile_order_with_fills(
7608 true,
7609 &order,
7610 &report,
7611 &[&companion_fill],
7612 Some(&instrument),
7613 &mut fill_queue,
7614 None,
7615 );
7616 let mut projected = order;
7617
7618 for event in &events {
7619 projected.apply(event.clone()).unwrap();
7620 }
7621
7622 assert!(matches!(events[0], OrderEventAny::Filled(_)));
7623 assert_eq!(
7624 events
7625 .iter()
7626 .filter(|event| matches!(event, OrderEventAny::FillVoided(_)))
7627 .count(),
7628 2
7629 );
7630 assert_eq!(projected.status(), OrderStatus::Canceled);
7631 assert_eq!(projected.filled_qty(), Quantity::from("5.0"));
7632 assert_eq!(projected.voided_qty(), Quantity::from("2.0"));
7633 }
7634
7635 fn insert_accepted_limit_order(
7636 cache: &Rc<RefCell<Cache>>,
7637 client_order_id: ClientOrderId,
7638 venue_order_id: VenueOrderId,
7639 instrument_id: InstrumentId,
7640 client_id: ClientId,
7641 ) {
7642 let account_id = AccountId::from("TEST-001");
7643 let order = OrderTestBuilder::new(OrderType::Limit)
7644 .client_order_id(client_order_id)
7645 .instrument_id(instrument_id)
7646 .quantity(Quantity::from("10.0"))
7647 .price(Price::from("100.0"))
7648 .build();
7649 let submitted = TestOrderEventStubs::submitted(&order, account_id);
7650 cache
7651 .borrow_mut()
7652 .add_order(order, None, Some(client_id), false)
7653 .unwrap();
7654 let order = cache.borrow_mut().update_order(&submitted).unwrap();
7655 let accepted = TestOrderEventStubs::accepted(&order, account_id, venue_order_id);
7656 cache.borrow_mut().update_order(&accepted).unwrap();
7657 }
7658
7659 fn test_bybit_spot_instrument() -> InstrumentAny {
7660 InstrumentAny::CurrencyPair(
7661 CurrencyPair::builder()
7662 .instrument_id(InstrumentId::from("ETHUSDT-SPOT.BYBIT"))
7663 .raw_symbol(Symbol::from("ETHUSDT"))
7664 .base_currency(Currency::from("ETH"))
7665 .quote_currency(Currency::from("USDT"))
7666 .price_precision(2)
7667 .size_precision(5)
7668 .price_increment(Price::from("0.01"))
7669 .size_increment(Quantity::from("0.00001"))
7670 .ts_event(UnixNanos::default())
7671 .ts_init(UnixNanos::default())
7672 .build()
7673 .unwrap(),
7674 )
7675 }
7676
7677 fn insert_open_position(
7678 cache: &Rc<RefCell<Cache>>,
7679 instrument: &InstrumentAny,
7680 position_id: PositionId,
7681 side: OrderSide,
7682 quantity: &str,
7683 price: &str,
7684 ) -> Position {
7685 let order = OrderTestBuilder::new(OrderType::Market)
7686 .instrument_id(instrument.id())
7687 .side(side)
7688 .quantity(Quantity::from(quantity))
7689 .build();
7690 let fill = TestOrderEventStubs::filled(
7691 &order,
7692 instrument,
7693 Some(TradeId::new("T-REPORT-001")),
7694 Some(position_id),
7695 Some(Price::from(price)),
7696 Some(Quantity::from(quantity)),
7697 None,
7698 None,
7699 None,
7700 Some(AccountId::from("TEST-001")),
7701 );
7702 let order_filled: OrderFilled = fill.into();
7703 let position = Position::new(instrument, order_filled);
7704 cache
7705 .borrow_mut()
7706 .add_position(&position, OmsType::Hedging)
7707 .unwrap();
7708 position
7709 }
7710
7711 fn close_long_position(
7712 mut position: Position,
7713 instrument: &InstrumentAny,
7714 trade_id: TradeId,
7715 ) -> Position {
7716 let order = OrderTestBuilder::new(OrderType::Market)
7717 .instrument_id(instrument.id())
7718 .side(OrderSide::Sell)
7719 .quantity(position.quantity)
7720 .build();
7721 let fill = TestOrderEventStubs::filled(
7722 &order,
7723 instrument,
7724 Some(trade_id),
7725 Some(position.id),
7726 Some(Price::from("3000.00")),
7727 Some(position.quantity),
7728 None,
7729 None,
7730 None,
7731 Some(position.account_id),
7732 );
7733 let order_filled: OrderFilled = fill.into();
7734 position.apply(&order_filled);
7735 position
7736 }
7737
7738 #[cfg(feature = "node")]
7739 mod node {
7740 use nautilus_model::events::order::spec::OrderFillVoidedSpec;
7741
7742 use super::*;
7743 use crate::execution::client::LiveExecutionClient;
7744
7745 #[rstest]
7746 fn test_position_fill_reports_contained_prefix_matches_full_report(
7747 #[values(
7748 "account",
7749 "instrument",
7750 "venue_order",
7751 "client_order",
7752 "trade",
7753 "position",
7754 "side",
7755 "price",
7756 "liquidity",
7757 "quantity",
7758 "commission",
7759 "currency"
7760 )]
7761 field: &str,
7762 ) {
7763 let (manager, _, report) = position_history_fixture();
7764 let mut changed = report.clone();
7765 match field {
7766 "account" => changed.account_id = AccountId::from("OTHER-002"),
7767 "instrument" => changed.instrument_id = InstrumentId::from("OTHER.SIM"),
7768 "venue_order" => changed.venue_order_id = VenueOrderId::from("V-OTHER"),
7769 "client_order" => changed.client_order_id = Some(ClientOrderId::from("O-OTHER")),
7770 "trade" => changed.trade_id = TradeId::from("T-OTHER"),
7771 "position" => changed.venue_position_id = Some(PositionId::from("P-OTHER")),
7772 "side" => changed.order_side = OrderSide::Sell,
7773 "price" => changed.last_px = Price::from("3101.00"),
7774 "liquidity" => changed.liquidity_side = LiquiditySide::Maker,
7775 "quantity" => changed.last_qty = Quantity::from("3.000"),
7776 "commission" => changed.commission = Money::from("0.17 USDT"),
7777 "currency" => changed.commission = Money::from("0.13 USD"),
7778 _ => unreachable!(),
7779 }
7780
7781 let reports = [report.clone(), changed, report.clone()];
7782 let scalar: Vec<_> = reports
7783 .iter()
7784 .map(|report| manager.position_contains_fill_report(report))
7785 .collect();
7786 let prefix = manager.position_fill_reports_contained_prefix(&reports);
7787 let repeated =
7788 manager.position_fill_reports_contained_prefix(&[report.clone(), report]);
7789
7790 assert_eq!(scalar, vec![true, false, true]);
7791 assert_eq!(prefix, 1);
7792 assert_eq!(repeated, 2);
7793 assert_eq!(manager.position_fill_reports_contained_prefix(&[]), 0);
7794 }
7795
7796 #[rstest]
7797 fn test_position_fill_reports_contained_prefix_indexes_only_routed_positions(
7798 #[values(false, true)] closed: bool,
7799 #[values(false, true)] missing: bool,
7800 ) {
7801 let (manager, cache, mut report) = position_history_fixture();
7802 let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
7803 let position = insert_open_position(
7804 &cache,
7805 &instrument,
7806 PositionId::from("P-UNRELATED"),
7807 OrderSide::Buy,
7808 "4.000",
7809 "3101.00",
7810 );
7811
7812 if closed {
7813 let position =
7814 close_long_position(position, &instrument, TradeId::from("T-UNRELATED-CLOSE"));
7815 cache.borrow_mut().update_position(&position).unwrap();
7816 }
7817
7818 let trade_id = report.trade_id;
7819 if missing {
7820 report.trade_id = TradeId::from("T-MISSING");
7821 }
7822
7823 let prefix =
7824 manager.position_fill_reports_contained_prefix(&[report.clone(), report.clone()]);
7825 let cache = cache.borrow();
7826 let positions = cache.positions_refs(
7827 None,
7828 Some(&report.instrument_id),
7829 None,
7830 Some(&report.account_id),
7831 None,
7832 );
7833 let mut replay = IndexMap::new();
7834 let contained = position_fill_report_matches(
7835 &cache,
7836 &report,
7837 &positions,
7838 |id| cache.hedging_flip_position_ids(id),
7839 Some(&mut replay),
7840 );
7841
7842 assert_eq!(positions.len(), 2);
7843 assert_eq!(contained, !missing);
7844 assert_eq!(prefix, if missing { 0 } else { 2 });
7845 assert_eq!(
7846 replay,
7847 IndexMap::from([(
7848 report.venue_position_id.unwrap(),
7849 IndexMap::from([(trade_id, vec![0])]),
7850 )]),
7851 );
7852 }
7853
7854 #[rstest]
7855 fn test_position_fill_reports_contained_prefix_resolves_client_order() {
7856 let (manager, cache, mut report) = position_history_fixture();
7857 let position_id = report.venue_position_id.unwrap();
7858 cache
7859 .borrow_mut()
7860 .add_venue_order_id(
7861 &report.client_order_id.unwrap(),
7862 &report.venue_order_id,
7863 false,
7864 )
7865 .unwrap();
7866 report.client_order_id = None;
7867 let before =
7868 manager.position_fill_reports_contained_prefix(&[report.clone(), report.clone()]);
7869 {
7870 let mut cache = cache.borrow_mut();
7871 let mut position = cache.position_mut(&position_id).unwrap();
7872
7873 let PositionReplayEvent::Filled(fill) = &mut position.replay_events[0] else {
7874 unreachable!()
7875 };
7876
7877 fill.client_order_id = ClientOrderId::from("O-OTHER");
7878 }
7879
7880 let after = manager.position_fill_reports_contained_prefix(&[report.clone(), report]);
7881
7882 assert_eq!(before, 2);
7883 assert_eq!(after, 0);
7884 }
7885
7886 #[rstest]
7887 fn test_position_fill_reports_contained_prefix_retains_reordered_fragments(
7888 #[values(false, true)] reverse: bool,
7889 ) {
7890 let (manager, cache, mut report) = position_history_fixture();
7891 let position_id = report.venue_position_id.unwrap();
7892 {
7893 let mut cache = cache.borrow_mut();
7894 let mut position = cache.position_mut(&position_id).unwrap();
7895
7896 let PositionReplayEvent::Filled(mut fragment) = position.replay_events[0].clone()
7897 else {
7898 unreachable!()
7899 };
7900
7901 fragment.last_qty = Quantity::from("3.000");
7902 fragment.commission = Some(Money::from("0.29 USDT"));
7903 let mut unrelated = fragment.clone();
7904 unrelated.trade_id = TradeId::from("T-UNRELATED");
7905 position
7906 .replay_events
7907 .push(PositionReplayEvent::Filled(unrelated));
7908 position
7909 .replay_events
7910 .push(PositionReplayEvent::Filled(fragment));
7911
7912 if reverse {
7913 position.replay_events.reverse();
7914 }
7915 }
7916
7917 let original =
7918 manager.position_fill_reports_contained_prefix(&[report.clone(), report.clone()]);
7919 report.last_qty = Quantity::from("5.000");
7920 report.commission = Money::from("0.42 USDT");
7921 let combined =
7922 manager.position_fill_reports_contained_prefix(&[report.clone(), report.clone()]);
7923 let scalar = manager.position_contains_fill_report(&report);
7924
7925 assert_eq!(original, 0);
7926 assert_eq!(combined, 2);
7927 assert!(scalar);
7928 }
7929
7930 #[rstest]
7931 fn test_position_fill_reports_contained_prefix_rechecks_after_applied_fill() {
7932 let (manager, cache, first) = position_history_fixture();
7933 let position_id = first.venue_position_id.unwrap();
7934 let mut second = first.clone();
7935 second.trade_id = TradeId::from("T-NEXT");
7936 second.last_qty = Quantity::from("1.500");
7937 second.last_px = Price::from("3110.00");
7938 second.commission = Money::from("0.29 USDT");
7939 let reports = [first, second.clone()];
7940 let before = manager.position_fill_reports_contained_prefix(&reports);
7941 {
7942 let mut cache = cache.borrow_mut();
7943 let mut position = cache.position_mut(&position_id).unwrap();
7944
7945 let PositionReplayEvent::Filled(mut fill) = position.replay_events[0].clone()
7946 else {
7947 unreachable!()
7948 };
7949
7950 fill.trade_id = second.trade_id;
7951 fill.last_qty = second.last_qty;
7952 fill.last_px = second.last_px;
7953 fill.commission = Some(second.commission);
7954 fill.event_id = UUID4::new();
7955 position.apply(&fill);
7956 }
7957
7958 let after = manager.position_fill_reports_contained_prefix(&reports);
7959 let cache = cache.borrow();
7960 let position = cache.position_ref(&position_id).unwrap();
7961
7962 assert_eq!(before, 1);
7963 assert_eq!(after, 2);
7964 assert_eq!(position.quantity, Quantity::from("3.500"));
7965 assert_eq!(position.commissions(), vec![Money::from("0.42 USDT")]);
7966 assert_eq!(position.replay_events.len(), 2);
7967 assert_eq!(
7968 manager.position_activity_revision(&(second.instrument_id, second.account_id)),
7969 0
7970 );
7971 }
7972
7973 #[rstest]
7974 fn test_position_fill_reports_contained_prefix_rebuilds_after_history_changes(
7975 #[values("missing_commission", "currency", "payload", "removal")] mutation: &str,
7976 ) {
7977 let (manager, cache, report) = position_history_fixture();
7978 let reports = [report.clone(), report.clone()];
7979 let before = manager.position_fill_reports_contained_prefix(&reports);
7980 let revision =
7981 manager.position_activity_revision(&(report.instrument_id, report.account_id));
7982 {
7983 let mut cache = cache.borrow_mut();
7984
7985 if mutation == "removal" {
7986 let position_id = report.venue_position_id.unwrap();
7987 {
7988 let mut position = cache.position_mut(&position_id).unwrap();
7989
7990 let PositionReplayEvent::Filled(mut closing) =
7991 position.replay_events[0].clone()
7992 else {
7993 unreachable!()
7994 };
7995
7996 closing.trade_id = TradeId::from("T-HISTORY-CLOSE");
7997 closing.order_side = OrderSide::Sell;
7998 closing.event_id = UUID4::new();
7999 position.apply(&closing);
8000 }
8001
8002 cache.purge_position(position_id);
8003 } else {
8004 let mut position = cache
8005 .position_mut(&report.venue_position_id.unwrap())
8006 .unwrap();
8007
8008 let PositionReplayEvent::Filled(fill) = &mut position.replay_events[0] else {
8009 unreachable!()
8010 };
8011
8012 match mutation {
8013 "missing_commission" => fill.commission = None,
8014 "currency" => fill.commission = Some(Money::from("0.13 USD")),
8015 "payload" => fill.last_px = Price::from("3101.00"),
8016 _ => unreachable!(),
8017 }
8018 }
8019 }
8020
8021 let after = manager.position_fill_reports_contained_prefix(&reports);
8022 let revision_after =
8023 manager.position_activity_revision(&(report.instrument_id, report.account_id));
8024
8025 assert_eq!(before, 2);
8026 assert_eq!(after, 0);
8027 assert_eq!(revision, 0);
8028 assert_eq!(revision_after, 0);
8029 }
8030
8031 #[rstest]
8032 fn test_position_fill_reports_contained_prefix_preserves_virtual_flip_proof(
8033 #[values(
8034 "unchanged",
8035 "missing_source",
8036 "conflicting_fragment",
8037 "opening_void",
8038 "closing_void"
8039 )]
8040 change: &str,
8041 ) {
8042 let (manager, cache, first) = position_history_fixture();
8043 let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
8044 let source_id = first.venue_position_id.unwrap();
8045 let destination_id = PositionId::from("P-HISTORY-DESTINATION");
8046 let mut order = OrderTestBuilder::new(OrderType::Market)
8047 .instrument_id(instrument.id())
8048 .client_order_id(ClientOrderId::from("O-HISTORY-FLIP"))
8049 .side(OrderSide::Sell)
8050 .quantity(Quantity::from("5.000"))
8051 .build();
8052 order
8053 .apply(TestOrderEventStubs::submitted(&order, first.account_id))
8054 .unwrap();
8055 order
8056 .apply(TestOrderEventStubs::accepted(
8057 &order,
8058 first.account_id,
8059 VenueOrderId::from("V-HISTORY-FLIP"),
8060 ))
8061 .unwrap();
8062 let event = TestOrderEventStubs::filled(
8063 &order,
8064 &instrument,
8065 Some(TradeId::from("T-HISTORY-FLIP")),
8066 Some(source_id),
8067 Some(Price::from("3100.00")),
8068 Some(Quantity::from("3.000")),
8069 Some(LiquiditySide::Taker),
8070 Some(Money::from("0.75 USDT")),
8071 None,
8072 Some(first.account_id),
8073 );
8074
8075 let OrderEventAny::Filled(fill) = event.clone() else {
8076 unreachable!()
8077 };
8078
8079 order.apply(event).unwrap();
8080 let (closing, opening) = fill
8081 .split_for_position_flip(
8082 Quantity::from("2.000"),
8083 Some(destination_id),
8084 UUID4::new(),
8085 )
8086 .unwrap();
8087 {
8088 let mut cache = cache.borrow_mut();
8089 cache.position_mut(&source_id).unwrap().apply(&closing);
8090 cache
8091 .add_order(order, Some(source_id), None, false)
8092 .unwrap();
8093 cache
8094 .add_position(&Position::new(&instrument, opening), OmsType::Hedging)
8095 .unwrap();
8096 }
8097
8098 let mut report = FillReport::new(
8099 fill.account_id,
8100 fill.instrument_id,
8101 fill.venue_order_id,
8102 fill.trade_id,
8103 fill.order_side,
8104 fill.last_qty,
8105 fill.last_px,
8106 fill.commission.unwrap(),
8107 fill.liquidity_side,
8108 Some(fill.client_order_id),
8109 Some(source_id),
8110 fill.ts_event,
8111 fill.ts_init,
8112 None,
8113 );
8114 let before =
8115 manager.position_fill_reports_contained_prefix(&[report.clone(), report.clone()]);
8116 {
8117 let mut cache = cache.borrow_mut();
8118
8119 match change {
8120 "unchanged" => {}
8121 "missing_source" => cache.purge_position(source_id),
8122 "conflicting_fragment" => {
8123 let mut position = cache.position_mut(&destination_id).unwrap();
8124
8125 let PositionReplayEvent::Filled(fragment) = &mut position.replay_events[0]
8126 else {
8127 unreachable!()
8128 };
8129
8130 fragment.last_px = Price::from("3101.00");
8131 }
8132 "opening_void" | "closing_void" => void_position_history_flip(
8133 &mut cache,
8134 &fill,
8135 source_id,
8136 destination_id,
8137 change == "closing_void",
8138 ),
8139 _ => unreachable!(),
8140 }
8141 }
8142
8143 let original_after =
8144 manager.position_fill_reports_contained_prefix(&[report.clone(), report.clone()]);
8145 if change == "missing_source" {
8146 report.venue_position_id = None;
8147 report.last_qty = Quantity::from("1.000");
8148 report.commission = Money::from("0.25 USDT");
8149 } else if change == "conflicting_fragment" {
8150 report.last_qty = Quantity::from("2.000");
8151 report.commission = Money::from("0.50 USDT");
8152 } else if change == "opening_void" {
8153 report.last_qty = Quantity::from("2.500");
8154 report.commission = Money::from("0.625 USDT");
8155 } else if change == "closing_void" {
8156 report.last_qty = Quantity::from("1.500");
8157 report.commission = Money::from("0.375 USDT");
8158 }
8159
8160 let after =
8161 manager.position_fill_reports_contained_prefix(&[report.clone(), report.clone()]);
8162 let scalar = manager.position_contains_fill_report(&report);
8163 let mixed = manager.position_fill_reports_contained_prefix(&[
8164 first,
8165 report.clone(),
8166 report.clone(),
8167 ]);
8168 let expected = !matches!(change, "missing_source" | "conflicting_fragment");
8169
8170 assert_eq!(before, 2);
8171 assert_eq!(original_after, if change == "unchanged" { 2 } else { 0 });
8172 assert_eq!(after, if expected { 2 } else { 0 });
8173 assert_eq!(scalar, expected);
8174
8175 let expected_mixed = match change {
8176 "missing_source" => 0,
8177 "conflicting_fragment" => 1,
8178 _ => 3,
8179 };
8180
8181 assert_eq!(mixed, expected_mixed);
8182 assert_eq!(
8183 manager.position_activity_revision(&(report.instrument_id, report.account_id)),
8184 0
8185 );
8186
8187 if expected {
8188 let cache = cache.borrow();
8189 let source = cache.position_ref(&source_id).unwrap();
8190 let destination = cache.position_ref(&destination_id).unwrap();
8191 assert_eq!(
8192 source.quantity,
8193 Quantity::from(if change == "closing_void" {
8194 "0.500"
8195 } else {
8196 "0.000"
8197 })
8198 );
8199 assert_eq!(
8200 destination.quantity,
8201 Quantity::from(match change {
8202 "closing_void" => "0.000",
8203 "opening_void" => "0.500",
8204 _ => "1.000",
8205 })
8206 );
8207 assert_eq!(
8208 source.commissions(),
8209 vec![Money::from(if change == "closing_void" {
8210 "0.505 USDT"
8211 } else {
8212 "0.63 USDT"
8213 })]
8214 );
8215
8216 let destination_commissions = if change == "closing_void" {
8217 Vec::new()
8218 } else {
8219 vec![Money::from(if change == "opening_void" {
8220 "0.125 USDT"
8221 } else {
8222 "0.25 USDT"
8223 })]
8224 };
8225
8226 assert_eq!(destination.commissions(), destination_commissions);
8227 }
8228 }
8229
8230 fn void_position_history_flip(
8231 cache: &mut Cache,
8232 fill: &OrderFilled,
8233 source_id: PositionId,
8234 destination_id: PositionId,
8235 closing_void: bool,
8236 ) {
8237 let correction = OrderFillVoidedSpec::builder()
8238 .trader_id(fill.trader_id)
8239 .strategy_id(fill.strategy_id)
8240 .instrument_id(fill.instrument_id)
8241 .client_order_id(fill.client_order_id)
8242 .venue_order_id(fill.venue_order_id)
8243 .account_id(fill.account_id)
8244 .trade_id(fill.trade_id)
8245 .voided_qty(Quantity::from(if closing_void { "1.500" } else { "0.500" }))
8246 .order_side(fill.order_side)
8247 .order_type(fill.order_type)
8248 .last_px(fill.last_px)
8249 .currency(fill.currency)
8250 .liquidity_side(fill.liquidity_side)
8251 .position_id(source_id)
8252 .commission_voided(Money::from(if closing_void {
8253 "0.375 USDT"
8254 } else {
8255 "0.125 USDT"
8256 }))
8257 .is_reopened(true)
8258 .build();
8259
8260 cache
8261 .position_mut(&destination_id)
8262 .unwrap()
8263 .apply_fill_void(
8264 correction.clone(),
8265 Quantity::from(if closing_void { "1.000" } else { "0.500" }),
8266 Some(Money::from(if closing_void {
8267 "0.25 USDT"
8268 } else {
8269 "0.125 USDT"
8270 })),
8271 )
8272 .unwrap();
8273
8274 if closing_void {
8275 cache
8276 .position_mut(&source_id)
8277 .unwrap()
8278 .apply_fill_void(
8279 correction,
8280 Quantity::from("0.500"),
8281 Some(Money::from("0.125 USDT")),
8282 )
8283 .unwrap();
8284 }
8285 }
8286
8287 fn position_history_fixture() -> (ExecutionManager, Rc<RefCell<Cache>>, FillReport) {
8288 let cache = Rc::new(RefCell::new(Cache::default()));
8289 let manager = ExecutionManager::new(
8290 Rc::new(RefCell::new(VirtualClock::new())),
8291 cache.clone(),
8292 ExecutionManagerConfig::default(),
8293 )
8294 .unwrap();
8295 let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
8296 let order = OrderTestBuilder::new(OrderType::Market)
8297 .instrument_id(instrument.id())
8298 .client_order_id(ClientOrderId::from("O-HISTORY"))
8299 .side(OrderSide::Buy)
8300 .quantity(Quantity::from("10.000"))
8301 .build();
8302 let position_id = PositionId::from("P-HISTORY");
8303 let event = TestOrderEventStubs::filled(
8304 &order,
8305 &instrument,
8306 Some(TradeId::from("T-HISTORY")),
8307 Some(position_id),
8308 Some(Price::from("3100.00")),
8309 Some(Quantity::from("2.000")),
8310 Some(LiquiditySide::Taker),
8311 Some(Money::from("0.13 USDT")),
8312 None,
8313 Some(AccountId::from("TEST-001")),
8314 );
8315
8316 let OrderEventAny::Filled(fill) = event else {
8317 unreachable!()
8318 };
8319
8320 let report = FillReport::new(
8321 fill.account_id,
8322 fill.instrument_id,
8323 fill.venue_order_id,
8324 fill.trade_id,
8325 fill.order_side,
8326 fill.last_qty,
8327 fill.last_px,
8328 fill.commission.unwrap(),
8329 fill.liquidity_side,
8330 Some(fill.client_order_id),
8331 Some(position_id),
8332 fill.ts_event,
8333 fill.ts_init,
8334 None,
8335 );
8336 let position = Position::new(&instrument, fill);
8337 cache
8338 .borrow_mut()
8339 .add_position(&position, OmsType::Hedging)
8340 .unwrap();
8341 (manager, cache, report)
8342 }
8343
8344 #[rstest]
8345 fn test_plan_position_fill_reports_uses_configured_lookback() {
8346 let lookback_mins = 7_u64;
8347 let lookback = DurationNanos::from_mins(lookback_mins);
8348 let clock = Rc::new(RefCell::new(VirtualClock::new()));
8349 let cache = Rc::new(RefCell::new(Cache::default()));
8350
8351 let mut manager = ExecutionManager::new(
8352 clock.clone(),
8353 cache.clone(),
8354 ExecutionManagerConfig {
8355 position_check_lookback_mins: lookback_mins,
8356 position_check_threshold_ns: DurationNanos::ZERO,
8357 ..Default::default()
8358 },
8359 )
8360 .expect("valid config");
8361
8362 let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
8363 cache
8364 .borrow_mut()
8365 .add_instrument(instrument.clone())
8366 .unwrap();
8367 let position = insert_open_position(
8368 &cache,
8369 &instrument,
8370 PositionId::from("P-FILL-LOOKBACK"),
8371 OrderSide::Buy,
8372 "1.0",
8373 "3000.00",
8374 );
8375 clock
8376 .borrow_mut()
8377 .advance_time(UnixNanos::default().saturating_add(lookback * 2), true);
8378 let client = PositionCoverageStubClient;
8379 let clients: [&dyn ExecutionClient; 1] = [&client];
8380 let mut check = manager.prepare_position_report_check(UUID4::new(), &clients);
8381 let query_end = clock.borrow().timestamp_ns();
8382
8383 let report = PositionStatusReport::new(
8384 position.account_id,
8385 position.instrument_id,
8386 PositionSide::Long,
8387 Quantity::from("2.0"),
8388 query_end,
8389 query_end,
8390 None,
8391 None,
8392 Some(dec!(3000.00)),
8393 );
8394 let queried_clients = IndexSet::from([client.client_id()]);
8395
8396 let plan = manager.plan_position_fill_reports(
8397 &mut check,
8398 &[report],
8399 &queried_clients,
8400 &IndexSet::new(),
8401 &clients,
8402 );
8403
8404 assert_eq!(
8405 plan.discrepancy_keys,
8406 IndexSet::from([(position.instrument_id, position.account_id)])
8407 );
8408 assert_eq!(plan.queries.len(), 1);
8409 let query = &plan.queries[0];
8410 assert_eq!(
8411 (query.key, query.client_id),
8412 (
8413 (position.instrument_id, position.account_id),
8414 client.client_id()
8415 )
8416 );
8417 assert_eq!(query.command.instrument_id, Some(position.instrument_id));
8418 assert_eq!(query.command.venue_order_id, None);
8419 assert_eq!(
8420 query.command.start,
8421 Some(query_end.saturating_sub(lookback))
8422 );
8423 assert_eq!(query.command.end, Some(query_end));
8424 assert_eq!(query.command.correlation_id, Some(check.command.command_id));
8425 assert_eq!(query.command.log_receipt_level, LogLevel::Debug);
8426 }
8427
8428 #[rstest]
8429 #[case::reported(Some("2.0"), true)]
8430 #[case::unreported(None, false)]
8431 fn test_plan_position_fill_reports_unavailable_coverage_requires_venue_report(
8432 #[case] venue_qty: Option<&str>,
8433 #[case] expect_query: bool,
8434 ) {
8435 let clock = Rc::new(RefCell::new(VirtualClock::new()));
8436 let cache = Rc::new(RefCell::new(Cache::default()));
8437
8438 let mut manager = ExecutionManager::new(
8439 clock.clone(),
8440 cache.clone(),
8441 ExecutionManagerConfig {
8442 position_check_threshold_ns: DurationNanos::ZERO,
8443 ..Default::default()
8444 },
8445 )
8446 .expect("valid config");
8447
8448 let spot = test_bybit_spot_instrument();
8449 cache.borrow_mut().add_instrument(spot.clone()).unwrap();
8450 let position = insert_open_position(
8451 &cache,
8452 &spot,
8453 PositionId::from("P-SPOT-UNAVAILABLE"),
8454 OrderSide::Buy,
8455 "1.0",
8456 "2000.00",
8457 );
8458 let key = (position.instrument_id, position.account_id);
8459 let client = PositionCoverageStubClient;
8460 let clients: [&dyn ExecutionClient; 1] = [&client];
8461 let mut check = manager.prepare_position_report_check(UUID4::new(), &clients);
8462 let ts_now = clock.borrow().timestamp_ns();
8463
8464 let reports: Vec<PositionStatusReport> = venue_qty
8465 .into_iter()
8466 .map(|qty| {
8467 PositionStatusReport::new(
8468 position.account_id,
8469 position.instrument_id,
8470 PositionSide::Long,
8471 Quantity::from(qty),
8472 ts_now,
8473 ts_now,
8474 None,
8475 None,
8476 Some(dec!(2000.00)),
8477 )
8478 })
8479 .collect();
8480
8481 let queried_clients = IndexSet::from([client.client_id()]);
8482
8483 let plan = manager.plan_position_fill_reports(
8484 &mut check,
8485 &reports,
8486 &queried_clients,
8487 &IndexSet::new(),
8488 &clients,
8489 );
8490
8491 let expected_queries = if expect_query {
8492 vec![(key, client.client_id())]
8493 } else {
8494 Vec::new()
8495 };
8496
8497 assert_eq!(
8498 check.client_coverage.get(&key),
8499 Some(&ReportClientCoverage::Unavailable(IndexSet::from([
8500 client.client_id()
8501 ])))
8502 );
8503 assert_eq!(plan.discrepancy_keys, IndexSet::from([key]));
8504 assert_eq!(
8505 plan.queries
8506 .iter()
8507 .map(|query| (query.key, query.client_id))
8508 .collect::<Vec<_>>(),
8509 expected_queries
8510 );
8511 }
8512
8513 #[rstest]
8514 fn test_plan_position_fill_reports_defers_position_opened_during_request() {
8515 let clock = Rc::new(RefCell::new(VirtualClock::new()));
8516 let cache = Rc::new(RefCell::new(Cache::default()));
8517
8518 let mut manager = ExecutionManager::new(
8519 clock,
8520 cache.clone(),
8521 ExecutionManagerConfig {
8522 position_check_threshold_ns: DurationNanos::ZERO,
8523 ..Default::default()
8524 },
8525 )
8526 .expect("valid config");
8527
8528 let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
8529 cache
8530 .borrow_mut()
8531 .add_instrument(instrument.clone())
8532 .unwrap();
8533 let client = PositionCoverageStubClient;
8534 let clients: [&dyn ExecutionClient; 1] = [&client];
8535 let mut check = manager.prepare_position_report_check(UUID4::new(), &clients);
8536 let position = insert_open_position(
8537 &cache,
8538 &instrument,
8539 PositionId::from("P-FILL-DURING-REQUEST"),
8540 OrderSide::Buy,
8541 "1.0",
8542 "3000.00",
8543 );
8544 manager.record_position_activity(position.instrument_id, position.account_id);
8545
8546 let report = PositionStatusReport::new(
8547 position.account_id,
8548 position.instrument_id,
8549 PositionSide::Long,
8550 Quantity::from("2.0"),
8551 UnixNanos::from(1_000_000),
8552 UnixNanos::from(1_000_000),
8553 None,
8554 None,
8555 Some(dec!(3000.00)),
8556 );
8557
8558 let plan = manager.plan_position_fill_reports(
8559 &mut check,
8560 &[report],
8561 &IndexSet::from([client.client_id()]),
8562 &IndexSet::new(),
8563 &clients,
8564 );
8565
8566 assert_eq!(
8567 plan.discrepancy_keys,
8568 IndexSet::from([(position.instrument_id, position.account_id)])
8569 );
8570 assert!(plan.queries.is_empty());
8571 assert_eq!(
8572 check
8573 .activity_revisions
8574 .get(&(position.instrument_id, position.account_id)),
8575 Some(&0)
8576 );
8577 }
8578
8579 #[rstest]
8580 fn test_prepare_position_report_check_uses_live_client_bulk_coverage() {
8581 let clock = Rc::new(RefCell::new(VirtualClock::new()));
8582 let cache = Rc::new(RefCell::new(Cache::default()));
8583 let manager =
8584 ExecutionManager::new(clock, cache.clone(), ExecutionManagerConfig::default())
8585 .expect("valid config");
8586 let derivative = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
8587 let spot = test_bybit_spot_instrument();
8588 cache
8589 .borrow_mut()
8590 .add_instrument(derivative.clone())
8591 .unwrap();
8592 cache.borrow_mut().add_instrument(spot.clone()).unwrap();
8593 let derivative_position = insert_open_position(
8594 &cache,
8595 &derivative,
8596 PositionId::from("P-DERIVATIVE-COVERAGE"),
8597 OrderSide::Buy,
8598 "5.0",
8599 "3000.00",
8600 );
8601 let spot_position = insert_open_position(
8602 &cache,
8603 &spot,
8604 PositionId::from("P-SPOT-COVERAGE"),
8605 OrderSide::Buy,
8606 "2.0",
8607 "2000.00",
8608 );
8609 let client = LiveExecutionClient::new(Box::new(PositionCoverageStubClient));
8610 let client: &dyn ExecutionClient = &client;
8611
8612 let check = manager.prepare_position_report_check(UUID4::new(), &[client]);
8613 let client_id = ClientId::from("BYBIT");
8614
8615 assert_eq!(
8616 check.client_coverage.get(&(
8617 derivative_position.instrument_id,
8618 derivative_position.account_id
8619 )),
8620 Some(&ReportClientCoverage::Resolved(IndexSet::from([client_id])))
8621 );
8622 assert_eq!(
8623 check
8624 .client_coverage
8625 .get(&(spot_position.instrument_id, spot_position.account_id)),
8626 Some(&ReportClientCoverage::Unavailable(IndexSet::from([
8627 client_id
8628 ])))
8629 );
8630 }
8631 }
8632}