1use std::{cell::RefCell, fmt::Debug, rc::Rc, str::FromStr, sync::LazyLock};
22
23use indexmap::{IndexMap, IndexSet};
24use nautilus_common::{
25 cache::Cache,
26 clients::ExecutionClient,
27 clock::Clock,
28 enums::{LogColor, LogLevel},
29 log_info,
30 messages::{
31 ExecutionReport,
32 execution::{
33 QueryOrder, TradingCommand,
34 report::{GenerateOrderStatusReports, GeneratePositionStatusReports},
35 },
36 },
37 msgbus::{self, MessagingSwitchboard, switchboard},
38};
39use nautilus_core::{
40 UUID4, UnixNanos,
41 datetime::{
42 NANOSECONDS_IN_MILLISECOND, NANOSECONDS_IN_SECOND, mins_to_nanos, mins_to_secs,
43 nanos_to_millis,
44 },
45};
46use nautilus_execution::{
47 engine::ExecutionEngine,
48 reconciliation::{
49 calculate_reconciliation_price, create_inferred_fill_for_qty,
50 create_position_reconciliation_venue_order_id, create_reconciliation_rejected,
51 create_reconciliation_triggered, generate_external_order_status_events,
52 process_mass_status_for_reconciliation, reconcile_order_report,
53 should_reconciliation_update,
54 },
55};
56use nautilus_model::{
57 enums::{OrderSide, OrderStatus, OrderType, TimeInForce},
58 events::{OrderCanceled, OrderEventAny, OrderFilled, OrderInitialized},
59 identifiers::{
60 AccountId, ClientId, ClientOrderId, InstrumentId, PositionId, StrategyId, TradeId,
61 TraderId, Venue, VenueOrderId,
62 },
63 instruments::{Instrument, InstrumentAny},
64 orders::{Order, OrderAny, TRIGGERABLE_ORDER_TYPES},
65 position::Position,
66 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
67 types::{Price, Quantity},
68};
69use rust_decimal::{Decimal, prelude::ToPrimitive};
70use ustr::Ustr;
71
72static TAG_VENUE: LazyLock<Ustr> = LazyLock::new(|| Ustr::from("VENUE"));
74
75static TAG_RECONCILIATION: LazyLock<Ustr> = LazyLock::new(|| Ustr::from("RECONCILIATION"));
77
78pub type InstrumentAccountKey = (InstrumentId, AccountId);
84
85#[derive(Debug, Clone)]
87pub struct ExternalOrderMetadata {
88 pub client_order_id: ClientOrderId,
89 pub venue_order_id: VenueOrderId,
90 pub instrument_id: InstrumentId,
91 pub strategy_id: StrategyId,
92 pub ts_init: UnixNanos,
93}
94
95#[derive(Debug, Default)]
97pub struct ReconciliationResult {
98 pub events: Vec<OrderEventAny>,
100 pub external_orders: Vec<ExternalOrderMetadata>,
102}
103
104#[derive(Debug, Default)]
106pub struct InflightCheckResult {
107 pub events: Vec<OrderEventAny>,
109 pub queries: Vec<TradingCommand>,
111}
112
113#[derive(Debug, Clone)]
115pub(crate) struct OpenOrderReportCheck {
116 pub command: GenerateOrderStatusReports,
117 pub filtered_orders: Vec<OrderAny>,
118 pub start: Option<UnixNanos>,
119}
120
121#[derive(Debug, Clone)]
123pub(crate) struct PositionReportCheck {
124 pub command: GeneratePositionStatusReports,
125 pub positions_by_key: IndexMap<InstrumentAccountKey, Vec<Position>>,
126}
127
128#[expect(
130 clippy::struct_excessive_bools,
131 reason = "config flags mirror the live execution engine configuration surface"
132)]
133#[derive(Debug, Clone)]
134pub struct ExecutionManagerConfig {
135 pub trader_id: TraderId,
137 pub reconciliation: bool,
139 pub lookback_mins: Option<u64>,
141 pub reconciliation_instrument_ids: IndexSet<InstrumentId>,
143 pub filter_unclaimed_external: bool,
145 pub filter_position_reports: bool,
147 pub filtered_client_order_ids: IndexSet<ClientOrderId>,
149 pub generate_missing_orders: bool,
151 pub inflight_check_interval_ms: u32,
153 pub inflight_threshold_ms: u64,
155 pub inflight_max_retries: u32,
157 pub open_check_interval_secs: Option<f64>,
159 pub open_check_lookback_mins: Option<u64>,
161 pub open_check_threshold_ns: u64,
163 pub open_check_missing_retries: u32,
165 pub open_check_open_only: bool,
167 pub max_single_order_queries_per_cycle: u32,
169 pub single_order_query_delay_ms: u32,
171 pub position_check_interval_secs: Option<f64>,
173 pub position_check_lookback_mins: u64,
175 pub position_check_threshold_ns: u64,
177 pub position_check_retries: u32,
179 pub purge_closed_orders_buffer_mins: Option<u32>,
181 pub purge_closed_positions_buffer_mins: Option<u32>,
183 pub purge_account_events_lookback_mins: Option<u32>,
185 pub purge_from_database: bool,
187}
188
189impl Default for ExecutionManagerConfig {
190 fn default() -> Self {
191 Self {
192 trader_id: TraderId::default(),
193 reconciliation: true,
194 lookback_mins: Some(60),
195 reconciliation_instrument_ids: IndexSet::new(),
196 filter_unclaimed_external: false,
197 filter_position_reports: false,
198 filtered_client_order_ids: IndexSet::new(),
199 generate_missing_orders: true,
200 inflight_check_interval_ms: 2_000,
201 inflight_threshold_ms: 5_000,
202 inflight_max_retries: 5,
203 open_check_interval_secs: None,
204 open_check_lookback_mins: Some(60),
205 open_check_threshold_ns: 5_000_000_000,
206 open_check_missing_retries: 5,
207 open_check_open_only: true,
208 max_single_order_queries_per_cycle: 5,
209 single_order_query_delay_ms: 100,
210 position_check_interval_secs: None,
211 position_check_lookback_mins: 60,
212 position_check_threshold_ns: 60_000_000_000,
213 position_check_retries: 3,
214 purge_closed_orders_buffer_mins: None,
215 purge_closed_positions_buffer_mins: None,
216 purge_account_events_lookback_mins: None,
217 purge_from_database: false,
218 }
219 }
220}
221
222impl ExecutionManagerConfig {
223 #[must_use]
225 pub fn with_trader_id(mut self, trader_id: TraderId) -> Self {
226 self.trader_id = trader_id;
227 self
228 }
229}
230
231#[derive(Debug, Clone)]
233struct InflightCheck {
234 #[allow(dead_code)]
235 pub client_order_id: ClientOrderId,
236 pub ts_submitted: UnixNanos,
237 pub retry_count: u32,
238 pub last_query_ts: Option<UnixNanos>,
239}
240
241#[derive(Clone)]
264pub struct ExecutionManager {
265 clock: Rc<RefCell<dyn Clock>>,
266 cache: Rc<RefCell<Cache>>,
267 config: ExecutionManagerConfig,
268 inflight_checks: IndexMap<ClientOrderId, InflightCheck>,
269 external_order_claims: IndexMap<InstrumentId, StrategyId>,
270 processed_fills: IndexMap<TradeId, ClientOrderId>,
271 recon_check_retries: IndexMap<ClientOrderId, u32>,
272 ts_last_query: IndexMap<ClientOrderId, UnixNanos>,
273 order_local_activity_ns: IndexMap<ClientOrderId, UnixNanos>,
274 position_local_activity_ns: IndexMap<InstrumentAccountKey, UnixNanos>,
275 position_recon_retries: IndexMap<InstrumentAccountKey, u32>,
276 recent_fills_cache: IndexMap<TradeId, UnixNanos>,
277}
278
279impl Debug for ExecutionManager {
280 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281 f.debug_struct(stringify!(ExecutionManager))
282 .field("config", &self.config)
283 .field("inflight_checks", &self.inflight_checks)
284 .field("external_order_claims", &self.external_order_claims)
285 .field("processed_fills", &self.processed_fills)
286 .field("recon_check_retries", &self.recon_check_retries)
287 .finish_non_exhaustive()
288 }
289}
290
291impl ExecutionManager {
292 pub fn new(
294 clock: Rc<RefCell<dyn Clock>>,
295 cache: Rc<RefCell<Cache>>,
296 config: ExecutionManagerConfig,
297 ) -> Self {
298 Self {
299 clock,
300 cache,
301 config,
302 inflight_checks: IndexMap::new(),
303 external_order_claims: IndexMap::new(),
304 processed_fills: IndexMap::new(),
305 recon_check_retries: IndexMap::new(),
306 ts_last_query: IndexMap::new(),
307 order_local_activity_ns: IndexMap::new(),
308 position_local_activity_ns: IndexMap::new(),
309 position_recon_retries: IndexMap::new(),
310 recent_fills_cache: IndexMap::new(),
311 }
312 }
313
314 #[must_use]
316 pub fn generate_timestamp_ns(&self) -> UnixNanos {
317 self.clock.borrow().timestamp_ns()
318 }
319
320 #[expect(
326 clippy::unused_async,
327 reason = "public reconciliation API stays async; live node and test callers await it"
328 )]
329 pub async fn reconcile_execution_mass_status(
330 &mut self,
331 mass_status: ExecutionMassStatus,
332 exec_engine: Rc<RefCell<ExecutionEngine>>,
333 ) -> ReconciliationResult {
334 let raw_order_status_topic =
339 MessagingSwitchboard::reconciliation_raw_order_status_report_topic();
340
341 for report in mass_status.order_reports().values() {
342 msgbus::publish_any(raw_order_status_topic, report);
343 }
344
345 let raw_fill_topic = MessagingSwitchboard::reconciliation_raw_fill_report_topic();
346
347 for fills in mass_status.fill_reports().values() {
348 for fill in fills {
349 msgbus::publish_any(raw_fill_topic, fill);
350 }
351 }
352
353 let raw_position_topic =
354 MessagingSwitchboard::reconciliation_raw_position_status_report_topic();
355
356 for reports in mass_status.position_reports().values() {
357 for report in reports {
358 msgbus::publish_any(raw_position_topic, report);
359 }
360 }
361
362 let venue = mass_status.venue;
363 let order_count = mass_status.order_reports().len();
364 let fill_count: usize = mass_status.fill_reports().values().map(|v| v.len()).sum();
365 let position_count = mass_status.position_reports().len();
366
367 log_info!(
368 "Reconciling ExecutionMassStatus for {venue}",
369 color = LogColor::Blue
370 );
371 log_info!(
372 "Received {order_count} order(s), {fill_count} fill(s), {position_count} position(s)",
373 color = LogColor::Blue
374 );
375
376 let (adjusted_order_reports, adjusted_fill_reports) =
377 self.adjust_mass_status_fills(&mass_status);
378
379 let mut events = Vec::new();
380 let mut external_orders = Vec::new();
381 let mut orders_reconciled = 0usize;
382 let mut external_orders_created = 0usize;
383 let mut open_orders_initialized = 0usize;
384 let mut orders_skipped_no_instrument = 0usize;
385 let mut orders_skipped_duplicate = 0usize;
386 let mut fills_applied = 0usize;
387
388 let fill_reports = &adjusted_fill_reports;
389 let mut seen_trade_ids: IndexSet<TradeId> = IndexSet::new();
390
391 for fills in fill_reports.values() {
392 for fill in fills {
393 if !seen_trade_ids.insert(fill.trade_id) {
394 log::warn!("Duplicate trade_id {} in mass status", fill.trade_id);
395 }
396 }
397 }
398
399 let order_reports = Self::deduplicate_order_reports(adjusted_order_reports.values());
401 let mut orders_skipped_filtered = 0usize;
402
403 for report in order_reports.values() {
404 if self.should_skip_order_report(report) {
405 orders_skipped_filtered += 1;
406 continue;
407 }
408
409 if let Some(client_order_id) = &report.client_order_id {
410 if let Some(cached_order) = self.get_order(*client_order_id)
411 && Self::is_exact_order_match(&cached_order, report)
412 {
413 log::debug!("Skipping order {client_order_id}: already in sync with venue");
414 orders_skipped_duplicate += 1;
415
416 if let Err(e) = self.cache.borrow_mut().add_venue_order_id(
418 client_order_id,
419 &report.venue_order_id,
420 false,
421 ) {
422 log::warn!("Failed to add venue order ID index: {e}");
423 }
424
425 continue;
426 }
427
428 if let Some(cached_order) = self.get_order(*client_order_id)
430 && cached_order.is_closed()
431 && cached_order
432 .tags()
433 .is_some_and(|tags| tags.contains(&*TAG_RECONCILIATION))
434 {
435 log::debug!(
436 "Skipping closed reconciliation order {client_order_id}: \
437 synthetic position adjustment from previous session",
438 );
439 orders_skipped_duplicate += 1;
440 continue;
441 }
442
443 if let Some(order) = self.get_order(*client_order_id) {
444 let instrument = self.get_instrument(&report.instrument_id);
445 log::info!(
446 color = LogColor::Blue as u8;
447 "Reconciling {} {} {} [{}] -> [{}]",
448 client_order_id,
449 report.venue_order_id,
450 report.instrument_id,
451 order.status(),
452 report.order_status,
453 );
454
455 let order_fills: Vec<&FillReport> = fill_reports
456 .get(&report.venue_order_id)
457 .map(|f| f.iter().collect())
458 .unwrap_or_default();
459 let order_events = self.reconcile_order_with_fills(
460 &order,
461 report,
462 &order_fills,
463 instrument.as_ref(),
464 );
465
466 if !order_events.is_empty() {
467 orders_reconciled += 1;
468 fills_applied += order_events
469 .iter()
470 .filter(|e| matches!(e, OrderEventAny::Filled(_)))
471 .count();
472 events.extend(order_events);
473 }
474
475 if let Err(e) = self.cache.borrow_mut().add_venue_order_id(
477 client_order_id,
478 &report.venue_order_id,
479 false,
480 ) {
481 log::warn!("Failed to add venue order ID index: {e}");
482 }
483 } else if let Some(order) = self.get_order_by_venue_order_id(report.venue_order_id)
484 {
485 let instrument = self.get_instrument(&report.instrument_id);
487
488 log::info!(
489 color = LogColor::Blue as u8;
490 "Reconciling {} (matched by venue_order_id {}) {} [{}] -> [{}]",
491 order.client_order_id(),
492 report.venue_order_id,
493 report.instrument_id,
494 order.status(),
495 report.order_status,
496 );
497
498 let order_fills: Vec<&FillReport> = fill_reports
499 .get(&report.venue_order_id)
500 .map(|f| f.iter().collect())
501 .unwrap_or_default();
502 let order_events = self.reconcile_order_with_fills(
503 &order,
504 report,
505 &order_fills,
506 instrument.as_ref(),
507 );
508
509 if !order_events.is_empty() {
510 orders_reconciled += 1;
511 fills_applied += order_events
512 .iter()
513 .filter(|e| matches!(e, OrderEventAny::Filled(_)))
514 .count();
515 events.extend(order_events);
516 }
517
518 if let Err(e) = self.cache.borrow_mut().add_venue_order_id(
519 &order.client_order_id(),
520 &report.venue_order_id,
521 false,
522 ) {
523 log::warn!("Failed to add venue order ID index: {e}");
524 }
525 } else if !self.config.filter_unclaimed_external {
526 if let Some(instrument) = self.get_instrument(&report.instrument_id) {
527 let order_fills: Vec<&FillReport> = fill_reports
528 .get(&report.venue_order_id)
529 .map(|f| f.iter().collect())
530 .unwrap_or_default();
531 let (external_events, metadata) = self.handle_external_order(
532 report,
533 mass_status.account_id,
534 &instrument,
535 &order_fills,
536 false, );
538
539 if !external_events.is_empty() {
540 external_orders_created += 1;
541 fills_applied += external_events
542 .iter()
543 .filter(|e| matches!(e, OrderEventAny::Filled(_)))
544 .count();
545
546 if report.order_status.is_open() {
547 open_orders_initialized += 1;
548 }
549
550 events.extend(external_events);
551
552 if let Some(m) = metadata {
553 external_orders.push(m);
554 }
555 }
556 } else {
557 orders_skipped_no_instrument += 1;
558 }
559 }
560 } else if let Some(order) = self.get_order_by_venue_order_id(report.venue_order_id) {
561 let instrument = self.get_instrument(&report.instrument_id);
563 log::info!(
564 color = LogColor::Blue as u8;
565 "Reconciling {} (matched by venue_order_id {}) {} [{}] -> [{}]",
566 order.client_order_id(),
567 report.venue_order_id,
568 report.instrument_id,
569 order.status(),
570 report.order_status,
571 );
572
573 let order_fills: Vec<&FillReport> = fill_reports
574 .get(&report.venue_order_id)
575 .map(|f| f.iter().collect())
576 .unwrap_or_default();
577 let order_events = self.reconcile_order_with_fills(
578 &order,
579 report,
580 &order_fills,
581 instrument.as_ref(),
582 );
583
584 if !order_events.is_empty() {
585 orders_reconciled += 1;
586 fills_applied += order_events
587 .iter()
588 .filter(|e| matches!(e, OrderEventAny::Filled(_)))
589 .count();
590 events.extend(order_events);
591 }
592
593 if let Err(e) = self.cache.borrow_mut().add_venue_order_id(
594 &order.client_order_id(),
595 &report.venue_order_id,
596 false,
597 ) {
598 log::warn!("Failed to add venue order ID index: {e}");
599 }
600 } else if let Some(instrument) = self.get_instrument(&report.instrument_id) {
601 let is_synthetic = report.venue_order_id.as_str().starts_with("S-");
603
604 let order_fills: Vec<&FillReport> = fill_reports
605 .get(&report.venue_order_id)
606 .map(|f| f.iter().collect())
607 .unwrap_or_default();
608 let (external_events, metadata) = self.handle_external_order(
609 report,
610 mass_status.account_id,
611 &instrument,
612 &order_fills,
613 is_synthetic,
614 );
615
616 if !external_events.is_empty() {
617 external_orders_created += 1;
618 fills_applied += external_events
619 .iter()
620 .filter(|e| matches!(e, OrderEventAny::Filled(_)))
621 .count();
622
623 if report.order_status.is_open() {
624 open_orders_initialized += 1;
625 }
626
627 events.extend(external_events);
628
629 if let Some(m) = metadata {
630 external_orders.push(m);
631 }
632 }
633 } else {
634 orders_skipped_no_instrument += 1;
635 }
636 }
637
638 let processed_venue_order_ids: IndexSet<VenueOrderId> =
640 order_reports.keys().copied().collect();
641
642 for (venue_order_id, fills) in fill_reports {
643 if processed_venue_order_ids.contains(venue_order_id) {
644 continue;
645 }
646
647 let Some(first_fill) = fills.first() else {
648 continue;
649 };
650
651 if !self.should_reconcile_instrument(&first_fill.instrument_id) {
652 log::debug!(
653 "Skipping orphan fills for {}: not in reconciliation_instrument_ids",
654 first_fill.instrument_id
655 );
656 continue;
657 }
658
659 if let Some(client_order_id) = &first_fill.client_order_id
661 && self
662 .config
663 .filtered_client_order_ids
664 .contains(client_order_id)
665 {
666 log::debug!(
667 "Skipping orphan fills for {client_order_id}: in filtered_client_order_ids"
668 );
669 continue;
670 }
671
672 let order = first_fill
673 .client_order_id
674 .as_ref()
675 .and_then(|id| self.get_order(*id))
676 .or_else(|| self.get_order_by_venue_order_id(*venue_order_id));
677
678 if let Some(ref order) = order
680 && self
681 .config
682 .filtered_client_order_ids
683 .contains(&order.client_order_id())
684 {
685 log::debug!(
686 "Skipping orphan fills for {}: in filtered_client_order_ids",
687 order.client_order_id()
688 );
689 continue;
690 }
691
692 if let Some(order) = order {
693 let instrument_id = order.instrument_id();
694 if let Some(instrument) = self.get_instrument(&instrument_id) {
695 let mut sorted_fills: Vec<&FillReport> = fills.iter().collect();
696 sorted_fills.sort_by_key(|f| f.ts_event);
697
698 for fill in sorted_fills {
699 if let Some(event) = self.create_order_fill(&order, fill, &instrument) {
700 fills_applied += 1;
701 events.push(event);
702 }
703 }
704 }
705 }
706 }
707
708 events.sort_by_key(|e| e.ts_event());
709
710 for event in &events {
711 exec_engine.borrow_mut().process(event);
712 }
713
714 let mut positions_created = 0usize;
715
716 if !self.config.filter_position_reports {
717 let instruments_with_unattributed_fills: IndexSet<InstrumentId> = mass_status
720 .fill_reports()
721 .values()
722 .flatten()
723 .filter(|f| f.venue_position_id.is_none())
724 .map(|f| f.instrument_id)
725 .chain(
726 mass_status
727 .order_reports()
728 .values()
729 .filter(|r| !r.filled_qty.is_zero() && r.venue_position_id.is_none())
730 .map(|r| r.instrument_id),
731 )
732 .collect();
733
734 let positions_with_fills: IndexSet<PositionId> = mass_status
735 .fill_reports()
736 .values()
737 .flatten()
738 .filter_map(|f| f.venue_position_id)
739 .chain(
740 mass_status
741 .order_reports()
742 .values()
743 .filter(|r| !r.filled_qty.is_zero())
744 .filter_map(|r| r.venue_position_id),
745 )
746 .collect();
747
748 for (instrument_id, reports) in mass_status.position_reports() {
749 if !self.should_reconcile_instrument(&instrument_id) {
750 log::debug!(
751 "Skipping position reports for {instrument_id}: not in reconciliation_instrument_ids"
752 );
753 continue;
754 }
755
756 for report in reports {
757 if let Some(position_events) = self.reconcile_position_report(
758 &report,
759 mass_status.account_id,
760 &instruments_with_unattributed_fills,
761 &positions_with_fills,
762 ) {
763 for event in position_events {
764 exec_engine.borrow_mut().process(&event);
765 events.push(event);
766 }
767 positions_created += 1;
768 }
769 }
770 }
771 }
772
773 if orders_skipped_no_instrument > 0 {
774 log::warn!("{orders_skipped_no_instrument} orders skipped (instrument not in cache)");
775 }
776
777 if orders_skipped_duplicate > 0 {
778 log::debug!("{orders_skipped_duplicate} orders skipped (already in sync)");
779 }
780
781 if orders_skipped_filtered > 0 {
782 log::debug!("{orders_skipped_filtered} orders skipped (filtered by config)");
783 }
784
785 log::info!(
786 color = LogColor::Blue as u8;
787 "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}",
788 );
789
790 ReconciliationResult {
791 events,
792 external_orders,
793 }
794 }
795
796 pub fn check_inflight_orders(&mut self) -> InflightCheckResult {
802 let mut result = InflightCheckResult::default();
803 let current_time = self.clock.borrow().timestamp_ns();
804 let threshold_ns = self.config.inflight_threshold_ms * NANOSECONDS_IN_MILLISECOND;
805
806 let mut to_check = Vec::new();
807
808 for (client_order_id, check) in &self.inflight_checks {
809 if current_time - check.ts_submitted > threshold_ns {
810 to_check.push(*client_order_id);
811 }
812 }
813
814 for client_order_id in to_check {
815 if self
816 .config
817 .filtered_client_order_ids
818 .contains(&client_order_id)
819 {
820 continue;
821 }
822
823 if let Some(check) = self.inflight_checks.get_mut(&client_order_id) {
824 if let Some(last_query_ts) = check.last_query_ts
825 && current_time - last_query_ts < threshold_ns
826 {
827 continue;
828 }
829
830 check.retry_count += 1;
831 check.last_query_ts = Some(current_time);
832 self.ts_last_query.insert(client_order_id, current_time);
833 self.recon_check_retries
834 .insert(client_order_id, check.retry_count);
835
836 if check.retry_count >= self.config.inflight_max_retries {
837 let ts_now = self.clock.borrow().timestamp_ns();
838
839 if let Some(order) = self.get_order(client_order_id) {
840 match order.status() {
841 OrderStatus::Submitted => {
842 if let Some(event) = create_reconciliation_rejected(
844 &order,
845 Some("INFLIGHT_TIMEOUT"),
846 ts_now,
847 ) {
848 result.events.push(event);
849 }
850 }
851 OrderStatus::PendingUpdate | OrderStatus::PendingCancel => {
852 let event = OrderEventAny::Canceled(OrderCanceled::new(
854 order.trader_id(),
855 order.strategy_id(),
856 order.instrument_id(),
857 order.client_order_id(),
858 UUID4::new(),
859 ts_now,
860 ts_now,
861 true, order.venue_order_id(),
863 order.account_id(),
864 ));
865 result.events.push(event);
866 }
867 _ => {
868 }
870 }
871 }
872 self.clear_recon_tracking(&client_order_id, true);
874 } else if let Some(order) = self.get_order(client_order_id) {
875 let client_id = self.cache.borrow().client_id(&client_order_id).copied();
877 let query = TradingCommand::QueryOrder(QueryOrder::new(
878 order.trader_id(),
879 client_id,
880 order.strategy_id(),
881 order.instrument_id(),
882 order.client_order_id(),
883 order.venue_order_id(),
884 UUID4::new(),
885 current_time,
886 None,
887 None, ));
889 result.queries.push(query);
890 }
891 }
892 }
893
894 result
895 }
896
897 fn filtered_open_orders_for_reconciliation(&self) -> Vec<OrderAny> {
898 {
899 let cache = self.cache.borrow();
900 let mut orders = cache.orders_open(None, None, None, None, None);
901 orders.extend(cache.orders_inflight(None, None, None, None, None));
902 let mut seen_client_order_ids = IndexSet::new();
903 orders.retain(|order| seen_client_order_ids.insert(order.client_order_id()));
904
905 if self.config.reconciliation_instrument_ids.is_empty() {
906 orders.iter().map(|o| (*o).clone()).collect()
907 } else {
908 orders
909 .iter()
910 .filter(|o| {
911 self.config
912 .reconciliation_instrument_ids
913 .contains(&o.instrument_id())
914 })
915 .map(|o| (*o).clone())
916 .collect()
917 }
918 }
919 }
920
921 fn open_positions_by_key_for_reconciliation(
922 &self,
923 ) -> IndexMap<InstrumentAccountKey, Vec<Position>> {
924 let cache = self.cache.borrow();
925 let positions = cache.positions_open(None, None, None, None, None);
926 let mut positions_by_key: IndexMap<InstrumentAccountKey, Vec<Position>> = IndexMap::new();
927
928 for position in positions {
929 if !self.should_reconcile_instrument(&position.instrument_id) {
930 continue;
931 }
932
933 positions_by_key
934 .entry((position.instrument_id, position.account_id))
935 .or_default()
936 .push(position.clone());
937 }
938
939 positions_by_key
940 }
941
942 pub(crate) fn prepare_open_order_report_check(
944 &self,
945 command_id: UUID4,
946 ) -> OpenOrderReportCheck {
947 let filtered_orders = self.filtered_open_orders_for_reconciliation();
948
949 log::debug!(
950 "Found {} order{} open in cache",
951 filtered_orders.len(),
952 if filtered_orders.len() == 1 { "" } else { "s" }
953 );
954
955 let ts_now = self.clock.borrow().timestamp_ns();
956 let start = self.config.open_check_lookback_mins.map(|mins| {
957 let lookback_ns = mins_to_nanos(mins);
958 ts_now.saturating_sub_ns(lookback_ns)
959 });
960
961 let mut command = GenerateOrderStatusReports::new(
962 command_id,
963 ts_now,
964 self.config.open_check_open_only,
965 None,
966 start,
967 None,
968 None,
969 None,
970 );
971 command.log_receipt_level = LogLevel::Debug;
972
973 OpenOrderReportCheck {
974 command,
975 filtered_orders,
976 start,
977 }
978 }
979
980 pub fn check_open_order_queries(&mut self) -> Vec<TradingCommand> {
982 self.check_open_order_queries_for_clients(None)
983 }
984
985 pub(crate) fn check_open_order_queries_for_clients(
986 &mut self,
987 client_ids: Option<&IndexSet<ClientId>>,
988 ) -> Vec<TradingCommand> {
989 let current_time = self.clock.borrow().timestamp_ns();
990 let query_delay_ns =
991 u64::from(self.config.single_order_query_delay_ms) * NANOSECONDS_IN_MILLISECOND;
992 let query_limit = self.config.max_single_order_queries_per_cycle as usize;
993
994 if query_limit == 0 {
995 return Vec::new();
996 }
997
998 let mut filtered_orders = self.filtered_open_orders_for_reconciliation();
999 filtered_orders.sort_by_key(|order| {
1000 let client_order_id = order.client_order_id();
1001 (
1002 self.ts_last_query.get(&client_order_id).copied(),
1003 client_order_id,
1004 )
1005 });
1006
1007 let mut queries = Vec::new();
1008
1009 for order in filtered_orders {
1010 if queries.len() >= query_limit {
1011 break;
1012 }
1013
1014 let client_order_id = order.client_order_id();
1015 let client_id = self.cache.borrow().client_id(&client_order_id).copied();
1016
1017 if let Some(client_ids) = client_ids
1018 && !client_id.is_some_and(|client_id| client_ids.contains(&client_id))
1019 {
1020 continue;
1021 }
1022
1023 if self
1024 .config
1025 .filtered_client_order_ids
1026 .contains(&client_order_id)
1027 {
1028 continue;
1029 }
1030
1031 if let Some(&last_activity) = self.order_local_activity_ns.get(&client_order_id) {
1032 let elapsed_ns = current_time.duration_since(&last_activity).unwrap_or(0);
1033
1034 if elapsed_ns < self.config.open_check_threshold_ns {
1035 let elapsed_ms = nanos_to_millis(elapsed_ns);
1036 let threshold_ms = nanos_to_millis(self.config.open_check_threshold_ns);
1037 log::debug!(
1038 "Deferring open order query for {client_order_id}: recent local activity \
1039 ({elapsed_ms}ms < threshold={threshold_ms}ms)",
1040 );
1041 continue;
1042 }
1043 }
1044
1045 if let Some(last_query_ts) = self.ts_last_query.get(&client_order_id)
1046 && current_time
1047 .duration_since(last_query_ts)
1048 .is_none_or(|elapsed_ns| elapsed_ns < query_delay_ns)
1049 {
1050 continue;
1051 }
1052
1053 self.ts_last_query.insert(client_order_id, current_time);
1054
1055 let cmd = TradingCommand::QueryOrder(QueryOrder::new(
1056 order.trader_id(),
1057 client_id,
1058 order.strategy_id(),
1059 order.instrument_id(),
1060 client_order_id,
1061 order.venue_order_id(),
1062 UUID4::new(),
1063 current_time,
1064 None,
1065 None,
1066 ));
1067 queries.push(cmd);
1068 }
1069
1070 queries
1071 }
1072
1073 pub async fn check_open_orders(
1083 &mut self,
1084 clients: &[&dyn ExecutionClient],
1085 ) -> Vec<OrderEventAny> {
1086 log::debug!("Checking order consistency between cached-state and venues");
1087
1088 let check = self.prepare_open_order_report_check(UUID4::new());
1089 let mut all_reports = Vec::new();
1090
1091 for client in clients {
1092 match client.generate_order_status_reports(&check.command).await {
1093 Ok(reports) => {
1094 all_reports.extend(reports);
1095 }
1096 Err(e) => {
1097 log::warn!(
1098 "Failed to query order reports from {}: {e}",
1099 client.client_id()
1100 );
1101 }
1102 }
1103 }
1104
1105 self.reconcile_open_order_reports(&check, all_reports)
1106 }
1107
1108 pub(crate) fn reconcile_open_order_reports(
1110 &mut self,
1111 check: &OpenOrderReportCheck,
1112 all_reports: Vec<OrderStatusReport>,
1113 ) -> Vec<OrderEventAny> {
1114 let mut venue_reported_ids = IndexSet::new();
1115
1116 for report in &all_reports {
1117 if let Some(client_order_id) = &report.client_order_id {
1118 venue_reported_ids.insert(*client_order_id);
1119 }
1120 }
1121
1122 let ts_now = self.clock.borrow().timestamp_ns();
1123 let mut events = Vec::new();
1124
1125 for report in all_reports {
1126 if let Some(client_order_id) = &report.client_order_id
1127 && let Some(order) = self.get_order(*client_order_id)
1128 {
1129 if let Some(&last_activity) = self.order_local_activity_ns.get(client_order_id) {
1131 let elapsed_ns = ts_now.duration_since(&last_activity).unwrap_or(0);
1132
1133 if elapsed_ns < self.config.open_check_threshold_ns {
1134 let elapsed_ms = nanos_to_millis(elapsed_ns);
1135 let threshold_ms = nanos_to_millis(self.config.open_check_threshold_ns);
1136 log::debug!(
1137 "Deferring reconciliation for {client_order_id}: recent local activity ({elapsed_ms}ms < threshold={threshold_ms}ms)",
1138 );
1139 continue;
1140 }
1141 }
1142
1143 let instrument = self.get_instrument(&report.instrument_id);
1144
1145 if let Some(event) =
1146 self.reconcile_order_report(&order, &report, instrument.as_ref())
1147 {
1148 events.push(event);
1149 }
1150 }
1151 }
1152
1153 if self.config.open_check_open_only {
1158 let cached_ids: IndexSet<ClientOrderId> = check
1159 .filtered_orders
1160 .iter()
1161 .map(|o| o.client_order_id())
1162 .collect();
1163 let missing_at_venue: IndexSet<ClientOrderId> = cached_ids
1164 .difference(&venue_reported_ids)
1165 .copied()
1166 .collect();
1167
1168 if !missing_at_venue.is_empty() {
1169 log::debug!(
1170 "{} cached open order{} not present in venue current response",
1171 missing_at_venue.len(),
1172 if missing_at_venue.len() == 1 {
1173 " is"
1174 } else {
1175 "s are"
1176 },
1177 );
1178
1179 for client_order_id in missing_at_venue {
1180 log::debug!("Cached open order missing from venue response: {client_order_id}");
1181 }
1182 }
1183 } else {
1184 let candidates: Vec<&OrderAny> = if let Some(cutoff) = check.start {
1185 check
1186 .filtered_orders
1187 .iter()
1188 .filter(|o| o.ts_last() >= cutoff)
1189 .collect()
1190 } else {
1191 check.filtered_orders.iter().collect()
1192 };
1193 let cached_ids: IndexSet<ClientOrderId> =
1194 candidates.iter().map(|o| o.client_order_id()).collect();
1195 let missing_at_venue: IndexSet<ClientOrderId> = cached_ids
1196 .difference(&venue_reported_ids)
1197 .copied()
1198 .collect();
1199
1200 for client_order_id in missing_at_venue {
1201 events.extend(self.handle_missing_order(client_order_id));
1202 }
1203 }
1204
1205 events
1206 }
1207
1208 #[must_use]
1210 pub(crate) fn prepare_position_report_check(&self, command_id: UUID4) -> PositionReportCheck {
1211 let positions_by_key = self.open_positions_by_key_for_reconciliation();
1212
1213 log::debug!(
1214 "Found {} unique instrument/account combination{} with open positions",
1215 positions_by_key.len(),
1216 if positions_by_key.len() == 1 { "" } else { "s" }
1217 );
1218
1219 let mut command = GeneratePositionStatusReports::new(
1220 command_id,
1221 self.clock.borrow().timestamp_ns(),
1222 None, None, None, None, None, );
1228 command.log_receipt_level = LogLevel::Debug;
1229
1230 PositionReportCheck {
1231 command,
1232 positions_by_key,
1233 }
1234 }
1235
1236 pub async fn check_positions_consistency(
1246 &mut self,
1247 clients: &[&dyn ExecutionClient],
1248 ) -> Vec<OrderEventAny> {
1249 let check = self.prepare_position_report_check(UUID4::new());
1250 let mut reports = Vec::new();
1251 let mut failed_venues = IndexSet::new();
1252
1253 for client in clients {
1254 match client
1255 .generate_position_status_reports(&check.command)
1256 .await
1257 {
1258 Ok(client_reports) => {
1259 reports.extend(client_reports);
1260 }
1261 Err(e) => {
1262 failed_venues.insert(client.venue());
1263 log::warn!(
1264 "Failed to query position reports from {}: {e}",
1265 client.client_id()
1266 );
1267 }
1268 }
1269 }
1270
1271 self.reconcile_position_reports(&check, reports, &failed_venues)
1272 }
1273
1274 #[must_use]
1276 pub(crate) fn reconcile_position_reports(
1277 &mut self,
1278 check: &PositionReportCheck,
1279 reports: Vec<PositionStatusReport>,
1280 failed_venues: &IndexSet<Venue>,
1281 ) -> Vec<OrderEventAny> {
1282 log::debug!("Checking position consistency between cached-state and venues");
1283
1284 let mut venue_positions = IndexMap::new();
1285
1286 for report in reports {
1287 if !self.should_reconcile_instrument(&report.instrument_id) {
1288 continue;
1289 }
1290
1291 venue_positions.insert((report.instrument_id, report.account_id), report);
1292 }
1293
1294 let mut events = Vec::new();
1295
1296 for (key, cached_positions) in &check.positions_by_key {
1297 let venue_report = venue_positions.get(key);
1298
1299 if venue_report.is_none()
1300 && Self::position_query_failed_for_instrument(failed_venues, &key.0)
1301 {
1302 log::warn!(
1303 "Skipping position reconciliation for {}: failed to query venue position status",
1304 key.0
1305 );
1306 continue;
1307 }
1308
1309 if let Some(discrepancy_events) =
1310 self.check_position_discrepancy(*key, cached_positions, venue_report)
1311 {
1312 events.extend(discrepancy_events);
1313 }
1314 }
1315
1316 for (key, venue_report) in &venue_positions {
1317 if check.positions_by_key.contains_key(key)
1318 || venue_report.signed_decimal_qty == Decimal::ZERO
1319 {
1320 continue;
1321 }
1322
1323 if let Some(discrepancy_events) =
1324 self.check_position_discrepancy(*key, &[], Some(venue_report))
1325 {
1326 events.extend(discrepancy_events);
1327 }
1328 }
1329
1330 let active_keys: IndexSet<InstrumentAccountKey> = check
1333 .positions_by_key
1334 .keys()
1335 .copied()
1336 .chain(
1337 venue_positions
1338 .iter()
1339 .filter(|(_, r)| r.signed_decimal_qty != Decimal::ZERO)
1340 .map(|(k, _)| *k),
1341 )
1342 .collect();
1343 self.position_recon_retries
1344 .retain(|k, _| active_keys.contains(k));
1345
1346 events
1347 }
1348
1349 fn position_query_failed_for_instrument(
1350 failed_venues: &IndexSet<Venue>,
1351 instrument_id: &InstrumentId,
1352 ) -> bool {
1353 failed_venues.contains(&instrument_id.venue)
1354 }
1355
1356 fn position_snapshot_avg_px(cached_positions: &[Position]) -> Option<Decimal> {
1357 let mut total_value = Decimal::ZERO;
1358 let mut total_qty = Decimal::ZERO;
1359
1360 for position in cached_positions {
1361 let qty = position.signed_decimal_qty().abs();
1362 if position.avg_px_open > 0.0
1363 && qty > Decimal::ZERO
1364 && let Ok(avg_px) = Decimal::from_str(&position.avg_px_open.to_string())
1365 {
1366 total_value += avg_px * qty;
1367 total_qty += qty;
1368 }
1369 }
1370
1371 if total_qty > Decimal::ZERO {
1372 Some(total_value / total_qty)
1373 } else {
1374 None
1375 }
1376 }
1377
1378 pub fn register_inflight(&mut self, client_order_id: ClientOrderId) {
1380 let ts_submitted = self.clock.borrow().timestamp_ns();
1381 self.inflight_checks.insert(
1382 client_order_id,
1383 InflightCheck {
1384 client_order_id,
1385 ts_submitted,
1386 retry_count: 0,
1387 last_query_ts: None,
1388 },
1389 );
1390 self.recon_check_retries.insert(client_order_id, 0);
1391 self.ts_last_query.shift_remove(&client_order_id);
1392 self.order_local_activity_ns.shift_remove(&client_order_id);
1393 }
1394
1395 pub fn record_local_activity(&mut self, client_order_id: ClientOrderId) {
1401 let ts_now = self.clock.borrow().timestamp_ns();
1402 self.order_local_activity_ns.insert(client_order_id, ts_now);
1403 }
1404
1405 pub fn clear_recon_tracking(&mut self, client_order_id: &ClientOrderId, drop_last_query: bool) {
1407 self.inflight_checks.shift_remove(client_order_id);
1408 self.recon_check_retries.shift_remove(client_order_id);
1409
1410 if drop_last_query {
1411 self.ts_last_query.shift_remove(client_order_id);
1412 }
1413 self.order_local_activity_ns.shift_remove(client_order_id);
1414 }
1415
1416 pub fn claim_external_orders(
1422 &mut self,
1423 instrument_id: InstrumentId,
1424 strategy_id: StrategyId,
1425 ) -> anyhow::Result<()> {
1426 if let Some(existing) = self.external_order_claims.get(&instrument_id) {
1427 anyhow::bail!("External order claim for {instrument_id} already exists for {existing}");
1428 }
1429
1430 self.external_order_claims
1431 .insert(instrument_id, strategy_id);
1432 Ok(())
1433 }
1434
1435 pub fn record_position_activity(
1437 &mut self,
1438 instrument_id: InstrumentId,
1439 account_id: AccountId,
1440 ts_event: UnixNanos,
1441 ) {
1442 self.position_local_activity_ns
1443 .insert((instrument_id, account_id), ts_event);
1444 }
1445
1446 #[must_use]
1449 pub fn position_recon_retry_count(&self, key: &InstrumentAccountKey) -> u32 {
1450 self.position_recon_retries.get(key).copied().unwrap_or(0)
1451 }
1452
1453 pub fn observe_execution_report(&mut self, report: &ExecutionReport) {
1465 match report {
1466 ExecutionReport::Order(order_report) => {
1467 if let Some(client_order_id) = &order_report.client_order_id {
1468 if !matches!(
1473 order_report.order_status,
1474 OrderStatus::PendingUpdate | OrderStatus::PendingCancel
1475 ) {
1476 self.clear_recon_tracking(
1477 client_order_id,
1478 order_report.order_status.is_closed(),
1479 );
1480 }
1481 self.record_local_activity(*client_order_id);
1482 }
1483 }
1484 ExecutionReport::Fill(fill_report) => {
1485 let client_order_id = fill_report.client_order_id.or_else(|| {
1486 self.cache
1487 .borrow()
1488 .client_order_id(&fill_report.venue_order_id)
1489 .copied()
1490 });
1491
1492 if let Some(coid) = client_order_id {
1493 self.record_local_activity(coid);
1494 }
1495 self.record_position_activity(
1496 fill_report.instrument_id,
1497 fill_report.account_id,
1498 fill_report.ts_event,
1499 );
1500 }
1501 ExecutionReport::OrderWithFills(order_report, fills) => {
1502 if let Some(client_order_id) = &order_report.client_order_id
1503 && !matches!(
1504 order_report.order_status,
1505 OrderStatus::PendingUpdate | OrderStatus::PendingCancel
1506 )
1507 {
1508 self.clear_recon_tracking(
1509 client_order_id,
1510 order_report.order_status.is_closed(),
1511 );
1512 self.record_local_activity(*client_order_id);
1513 }
1514
1515 for fill_report in fills {
1516 self.record_position_activity(
1517 fill_report.instrument_id,
1518 fill_report.account_id,
1519 fill_report.ts_event,
1520 );
1521 }
1522 }
1523 ExecutionReport::Position(position_report) => {
1524 self.record_position_activity(
1525 position_report.instrument_id,
1526 position_report.account_id,
1527 position_report.ts_last,
1528 );
1529 }
1530 ExecutionReport::MassStatus(_) => {
1531 }
1533 }
1534 }
1535
1536 #[must_use]
1538 pub fn is_fill_recently_processed(&self, trade_id: &TradeId) -> bool {
1539 self.recent_fills_cache.contains_key(trade_id)
1540 }
1541
1542 pub fn mark_fill_processed(&mut self, trade_id: TradeId) {
1544 let ts_now = self.clock.borrow().timestamp_ns();
1545 self.recent_fills_cache.insert(trade_id, ts_now);
1546 }
1547
1548 #[expect(
1552 clippy::cast_precision_loss,
1553 clippy::cast_possible_truncation,
1554 clippy::cast_sign_loss,
1555 reason = "TTL is a small positive seconds value; the nanosecond product fits u64"
1556 )]
1557 pub fn prune_recent_fills_cache(&mut self, ttl_secs: f64) {
1558 let ts_now = self.clock.borrow().timestamp_ns();
1559 let ttl_ns = (ttl_secs * NANOSECONDS_IN_SECOND as f64) as u64;
1560
1561 self.recent_fills_cache
1562 .retain(|_, &mut ts_cached| ts_now - ts_cached <= ttl_ns);
1563 }
1564
1565 pub fn purge_closed_orders(&mut self) {
1567 let Some(buffer_mins) = self.config.purge_closed_orders_buffer_mins else {
1568 return;
1569 };
1570
1571 let ts_now = self.clock.borrow().timestamp_ns();
1572 let buffer_secs = mins_to_secs(u64::from(buffer_mins));
1573
1574 self.cache
1575 .borrow_mut()
1576 .purge_closed_orders(ts_now, buffer_secs);
1577 }
1578
1579 pub fn purge_closed_positions(&mut self) {
1581 let Some(buffer_mins) = self.config.purge_closed_positions_buffer_mins else {
1582 return;
1583 };
1584
1585 let ts_now = self.clock.borrow().timestamp_ns();
1586 let buffer_secs = mins_to_secs(u64::from(buffer_mins));
1587
1588 self.cache
1589 .borrow_mut()
1590 .purge_closed_positions(ts_now, buffer_secs);
1591 }
1592
1593 pub fn purge_account_events(&mut self) {
1595 let Some(lookback_mins) = self.config.purge_account_events_lookback_mins else {
1596 return;
1597 };
1598
1599 let ts_now = self.clock.borrow().timestamp_ns();
1600 let lookback_secs = mins_to_secs(u64::from(lookback_mins));
1601
1602 self.cache
1603 .borrow_mut()
1604 .purge_account_events(ts_now, lookback_secs);
1605 }
1606
1607 fn get_order(&self, client_order_id: ClientOrderId) -> Option<OrderAny> {
1610 self.cache
1611 .borrow()
1612 .order(&client_order_id)
1613 .map(|o| o.clone())
1614 }
1615
1616 fn get_order_by_venue_order_id(&self, venue_order_id: VenueOrderId) -> Option<OrderAny> {
1617 let cache = self.cache.borrow();
1618 cache
1619 .client_order_id(&venue_order_id)
1620 .and_then(|client_order_id| cache.order(client_order_id).map(|o| o.clone()))
1621 }
1622
1623 fn get_instrument(&self, instrument_id: &InstrumentId) -> Option<InstrumentAny> {
1624 self.cache.borrow().instrument(instrument_id).cloned()
1625 }
1626
1627 fn should_skip_order_report(&self, report: &OrderStatusReport) -> bool {
1628 if let Some(client_order_id) = &report.client_order_id
1629 && self
1630 .config
1631 .filtered_client_order_ids
1632 .contains(client_order_id)
1633 {
1634 log::debug!(
1635 "Skipping order report {client_order_id}: in filtered_client_order_ids list"
1636 );
1637 return true;
1638 }
1639
1640 if !self.should_reconcile_instrument(&report.instrument_id) {
1641 log::debug!(
1642 "Skipping order report for {}: not in reconciliation_instrument_ids",
1643 report.instrument_id
1644 );
1645 return true;
1646 }
1647
1648 false
1649 }
1650
1651 fn should_reconcile_instrument(&self, instrument_id: &InstrumentId) -> bool {
1652 self.config.reconciliation_instrument_ids.is_empty()
1653 || self
1654 .config
1655 .reconciliation_instrument_ids
1656 .contains(instrument_id)
1657 }
1658
1659 fn handle_missing_order(&mut self, client_order_id: ClientOrderId) -> Vec<OrderEventAny> {
1660 let mut events = Vec::new();
1661
1662 let Some(order) = self.get_order(client_order_id) else {
1663 return events;
1664 };
1665
1666 let ts_now = self.clock.borrow().timestamp_ns();
1667 let ts_last = order.ts_last();
1668
1669 if (ts_now - ts_last) < self.config.open_check_threshold_ns {
1671 return events;
1672 }
1673
1674 if let Some(&last_activity) = self.order_local_activity_ns.get(&client_order_id)
1676 && (ts_now - last_activity) < self.config.open_check_threshold_ns
1677 {
1678 return events;
1679 }
1680
1681 let retries = self.recon_check_retries.entry(client_order_id).or_insert(0);
1683 *retries += 1;
1684
1685 if *retries >= self.config.open_check_missing_retries {
1687 log::warn!(
1688 "Order {client_order_id} not found at venue after {retries} retries, marking as REJECTED"
1689 );
1690
1691 let ts_now = self.clock.borrow().timestamp_ns();
1692
1693 if let Some(rejected) =
1694 create_reconciliation_rejected(&order, Some("NOT_FOUND_AT_VENUE"), ts_now)
1695 {
1696 events.push(rejected);
1697 }
1698
1699 self.clear_recon_tracking(&client_order_id, true);
1700 } else {
1701 log::debug!(
1702 "Order {} not found at venue, retry {}/{}",
1703 client_order_id,
1704 retries,
1705 self.config.open_check_missing_retries
1706 );
1707 }
1708
1709 events
1710 }
1711
1712 fn check_position_discrepancy(
1713 &mut self,
1714 key: InstrumentAccountKey,
1715 cached_positions: &[Position],
1716 venue_report: Option<&PositionStatusReport>,
1717 ) -> Option<Vec<OrderEventAny>> {
1718 let (instrument_id, account_id) = key;
1719
1720 let mut cached_signed_qty = Decimal::ZERO;
1721 for position in cached_positions {
1722 cached_signed_qty += position.signed_decimal_qty();
1723 }
1724 let venue_signed_qty = venue_report.map_or(Decimal::ZERO, |r| r.signed_decimal_qty);
1725
1726 let tolerance = Decimal::from_str("0.00000001").unwrap();
1727 if (cached_signed_qty - venue_signed_qty).abs() <= tolerance {
1728 self.position_recon_retries.shift_remove(&key);
1729 return None;
1730 }
1731
1732 let ts_now = self.clock.borrow().timestamp_ns();
1733
1734 if let Some(&last_activity) = self.position_local_activity_ns.get(&key)
1735 && (ts_now - last_activity) < self.config.position_check_threshold_ns
1736 {
1737 log::debug!(
1738 "Skipping position reconciliation for {instrument_id}: recent activity within threshold"
1739 );
1740 return None;
1741 }
1742
1743 let retries = *self.position_recon_retries.get(&key).unwrap_or(&0);
1744
1745 if retries >= self.config.position_check_retries {
1746 return None;
1747 }
1748
1749 log::warn!(
1750 "Position discrepancy detected for {instrument_id}: cached_signed_qty={cached_signed_qty}, venue_signed_qty={venue_signed_qty}"
1751 );
1752
1753 let Some(instrument) = self.cache.borrow().instrument(&instrument_id).cloned() else {
1754 log::debug!("Cannot reconcile position for {instrument_id}: instrument not in cache");
1755 let new_retries = retries + 1;
1756 self.position_recon_retries.insert(key, new_retries);
1757 if new_retries >= self.config.position_check_retries {
1758 log::error!(
1759 "Position discrepancy for {instrument_id} unresolved after {} attempts \
1760 (cached_qty={cached_signed_qty}, venue_qty={venue_signed_qty}); \
1761 no further reconciliation attempts will be made",
1762 self.config.position_check_retries,
1763 );
1764 }
1765 return None;
1766 };
1767
1768 let cached_avg_px = Self::position_snapshot_avg_px(cached_positions);
1769 let venue_avg_px = venue_report.and_then(|r| r.avg_px_open);
1770
1771 let crosses_zero = (cached_signed_qty > Decimal::ZERO && venue_signed_qty < Decimal::ZERO)
1772 || (cached_signed_qty < Decimal::ZERO && venue_signed_qty > Decimal::ZERO);
1773
1774 let result = if crosses_zero {
1775 let venue_ts_last = venue_report.map_or(ts_now, |r| r.ts_last);
1776 self.reconcile_cross_zero_position(
1777 &instrument,
1778 account_id,
1779 instrument_id,
1780 cached_signed_qty,
1781 cached_avg_px,
1782 venue_signed_qty,
1783 venue_avg_px,
1784 ts_now,
1785 venue_ts_last,
1786 )
1787 } else {
1788 let qty_diff = venue_signed_qty - cached_signed_qty;
1789 let order_side = if qty_diff > Decimal::ZERO {
1790 OrderSide::Buy
1791 } else {
1792 OrderSide::Sell
1793 };
1794
1795 let reconciliation_px = calculate_reconciliation_price(
1796 cached_signed_qty,
1797 cached_avg_px,
1798 venue_signed_qty,
1799 venue_avg_px,
1800 );
1801
1802 match reconciliation_px.or(venue_avg_px).or(cached_avg_px) {
1803 Some(fill_px) => {
1804 let fill_qty = qty_diff.abs();
1805 let venue_position_id = venue_report.and_then(|r| r.venue_position_id);
1806 let venue_ts_last = venue_report.map_or(ts_now, |r| r.ts_last);
1807
1808 Quantity::from_decimal_dp(fill_qty, instrument.size_precision())
1809 .ok()
1810 .and_then(|order_qty| {
1811 let fill_price =
1812 Price::from_decimal_dp(fill_px, instrument.price_precision()).ok();
1813 let venue_order_id = create_position_reconciliation_venue_order_id(
1814 account_id,
1815 instrument_id,
1816 order_side,
1817 OrderType::Market,
1818 order_qty,
1819 fill_price,
1820 venue_position_id,
1821 None,
1822 venue_ts_last,
1823 );
1824
1825 OrderStatusReport::new(
1826 account_id,
1827 instrument_id,
1828 None,
1829 venue_order_id,
1830 order_side,
1831 OrderType::Market,
1832 TimeInForce::Gtc,
1833 OrderStatus::Filled,
1834 order_qty,
1835 order_qty,
1836 ts_now,
1837 ts_now,
1838 ts_now,
1839 None,
1840 )
1841 .with_avg_px(fill_px.to_f64().unwrap_or(0.0))
1842 .ok()
1843 })
1844 .map(|order_report| {
1845 log::info!(
1846 color = LogColor::Blue as u8;
1847 "Generating synthetic fill for position reconciliation {instrument_id}: side={order_side:?}, qty={}, px={fill_px}", qty_diff.abs(),
1848 );
1849
1850 let (events, _) = self.handle_external_order(
1851 &order_report,
1852 account_id,
1853 &instrument,
1854 &[],
1855 true,
1856 );
1857 events
1858 })
1859 }
1860 None => None,
1861 }
1862 };
1863
1864 if result.is_none() || result.as_ref().is_some_and(|e| e.is_empty()) {
1866 let new_retries = retries + 1;
1867 self.position_recon_retries.insert(key, new_retries);
1868 if new_retries >= self.config.position_check_retries {
1869 log::error!(
1870 "Position discrepancy for {} unresolved after {} attempts \
1871 (cached_qty={}, venue_qty={}); \
1872 no further reconciliation attempts will be made",
1873 instrument_id,
1874 self.config.position_check_retries,
1875 cached_signed_qty,
1876 venue_signed_qty,
1877 );
1878 }
1879 } else {
1880 self.position_recon_retries.shift_remove(&key);
1881 }
1882
1883 result
1884 }
1885
1886 #[expect(clippy::too_many_arguments)]
1889 fn reconcile_cross_zero_position(
1890 &mut self,
1891 instrument: &InstrumentAny,
1892 account_id: AccountId,
1893 instrument_id: InstrumentId,
1894 cached_signed_qty: Decimal,
1895 cached_avg_px: Option<Decimal>,
1896 venue_signed_qty: Decimal,
1897 venue_avg_px: Option<Decimal>,
1898 ts_now: UnixNanos,
1899 venue_ts_last: UnixNanos,
1900 ) -> Option<Vec<OrderEventAny>> {
1901 log::info!(
1902 color = LogColor::Blue as u8;
1903 "Position crosses zero for {instrument_id}: cached={cached_signed_qty}, venue={venue_signed_qty}. Splitting into two fills",
1904 );
1905
1906 let mut all_events = Vec::new();
1907
1908 let close_qty = cached_signed_qty.abs();
1910 let close_side = if cached_signed_qty < Decimal::ZERO {
1911 OrderSide::Buy } else {
1913 OrderSide::Sell };
1915
1916 if let Some(close_px) = cached_avg_px {
1917 let close_order_qty =
1918 Quantity::from_decimal_dp(close_qty, instrument.size_precision()).ok()?;
1919 let close_fill_price =
1920 Price::from_decimal_dp(close_px, instrument.price_precision()).ok();
1921 let close_venue_order_id = create_position_reconciliation_venue_order_id(
1922 account_id,
1923 instrument_id,
1924 close_side,
1925 OrderType::Market,
1926 close_order_qty,
1927 close_fill_price,
1928 None,
1929 Some("CLOSE"),
1930 venue_ts_last,
1931 );
1932
1933 let close_report = OrderStatusReport::new(
1934 account_id,
1935 instrument_id,
1936 None,
1937 close_venue_order_id,
1938 close_side,
1939 OrderType::Market,
1940 TimeInForce::Gtc,
1941 OrderStatus::Filled,
1942 close_order_qty,
1943 close_order_qty,
1944 ts_now,
1945 ts_now,
1946 ts_now,
1947 None,
1948 )
1949 .with_avg_px(close_px.to_f64().unwrap_or(0.0))
1950 .ok()?;
1951
1952 log::info!(
1953 color = LogColor::Blue as u8;
1954 "Generating close fill for cross-zero {instrument_id}: side={close_side:?}, qty={close_qty}, px={close_px}",
1955 );
1956
1957 let (close_events, _) =
1958 self.handle_external_order(&close_report, account_id, instrument, &[], true);
1959 all_events.extend(close_events);
1960 } else {
1961 log::warn!("Cannot close position for {instrument_id}: no cached average price");
1962 return None;
1963 }
1964
1965 let open_qty = venue_signed_qty.abs();
1967 let open_side = if venue_signed_qty > Decimal::ZERO {
1968 OrderSide::Buy } else {
1970 OrderSide::Sell };
1972
1973 if let Some(open_px) = venue_avg_px {
1974 let open_order_qty =
1975 Quantity::from_decimal_dp(open_qty, instrument.size_precision()).ok()?;
1976 let open_fill_price =
1977 Price::from_decimal_dp(open_px, instrument.price_precision()).ok();
1978 let open_venue_order_id = create_position_reconciliation_venue_order_id(
1979 account_id,
1980 instrument_id,
1981 open_side,
1982 OrderType::Market,
1983 open_order_qty,
1984 open_fill_price,
1985 None,
1986 Some("OPEN"),
1987 venue_ts_last,
1988 );
1989
1990 let open_report = OrderStatusReport::new(
1991 account_id,
1992 instrument_id,
1993 None,
1994 open_venue_order_id,
1995 open_side,
1996 OrderType::Market,
1997 TimeInForce::Gtc,
1998 OrderStatus::Filled,
1999 open_order_qty,
2000 open_order_qty,
2001 ts_now,
2002 ts_now,
2003 ts_now,
2004 None,
2005 )
2006 .with_avg_px(open_px.to_f64().unwrap_or(0.0))
2007 .ok()?;
2008
2009 log::info!(
2010 color = LogColor::Blue as u8;
2011 "Generating open fill for cross-zero {instrument_id}: side={open_side:?}, qty={open_qty}, px={open_px}",
2012 );
2013
2014 let (open_events, _) =
2015 self.handle_external_order(&open_report, account_id, instrument, &[], true);
2016 all_events.extend(open_events);
2017 } else {
2018 log::warn!("Cannot open new position for {instrument_id}: no venue average price");
2019 return Some(all_events);
2020 }
2021
2022 Some(all_events)
2023 }
2024
2025 fn create_position_from_report(
2030 &mut self,
2031 report: &PositionStatusReport,
2032 account_id: AccountId,
2033 instrument: &InstrumentAny,
2034 ) -> Option<Vec<OrderEventAny>> {
2035 let instrument_id = report.instrument_id;
2036 let venue_signed_qty = report.signed_decimal_qty;
2037
2038 if venue_signed_qty == Decimal::ZERO {
2039 return None;
2040 }
2041
2042 let order_side = if venue_signed_qty > Decimal::ZERO {
2043 OrderSide::Buy
2044 } else {
2045 OrderSide::Sell
2046 };
2047
2048 let qty_abs = venue_signed_qty.abs();
2049 let venue_avg_px = report.avg_px_open?;
2050
2051 let ts_now = self.clock.borrow().timestamp_ns();
2052 let order_qty = Quantity::from_decimal_dp(qty_abs, instrument.size_precision()).ok()?;
2053 let fill_price = Price::from_decimal_dp(venue_avg_px, instrument.price_precision()).ok();
2054 let venue_order_id = create_position_reconciliation_venue_order_id(
2055 account_id,
2056 instrument_id,
2057 order_side,
2058 OrderType::Market,
2059 order_qty,
2060 fill_price,
2061 report.venue_position_id,
2062 None,
2063 report.ts_last,
2064 );
2065
2066 let mut order_report = OrderStatusReport::new(
2067 account_id,
2068 instrument_id,
2069 None,
2070 venue_order_id,
2071 order_side,
2072 OrderType::Market,
2073 TimeInForce::Gtc,
2074 OrderStatus::Filled,
2075 order_qty,
2076 order_qty,
2077 ts_now,
2078 ts_now,
2079 ts_now,
2080 None,
2081 )
2082 .with_avg_px(venue_avg_px.to_f64().unwrap_or(0.0))
2083 .ok()?;
2084
2085 if let Some(venue_position_id) = report.venue_position_id {
2087 order_report = order_report.with_venue_position_id(venue_position_id);
2088 }
2089
2090 log::info!(
2091 color = LogColor::Blue as u8;
2092 "Creating position from venue report for {instrument_id}: side={order_side:?}, qty={qty_abs}, avg_px={venue_avg_px}",
2093 );
2094
2095 let (events, _) =
2096 self.handle_external_order(&order_report, account_id, instrument, &[], true);
2097 Some(events)
2098 }
2099
2100 fn reconcile_position_report(
2101 &mut self,
2102 report: &PositionStatusReport,
2103 account_id: AccountId,
2104 instruments_with_unattributed_fills: &IndexSet<InstrumentId>,
2105 positions_with_fills: &IndexSet<PositionId>,
2106 ) -> Option<Vec<OrderEventAny>> {
2107 if report.venue_position_id.is_some() {
2108 self.reconcile_position_report_hedging(
2109 report,
2110 account_id,
2111 instruments_with_unattributed_fills,
2112 positions_with_fills,
2113 )
2114 } else {
2115 self.reconcile_position_report_netting(report, account_id)
2116 }
2117 }
2118
2119 fn reconcile_position_report_hedging(
2120 &mut self,
2121 report: &PositionStatusReport,
2122 account_id: AccountId,
2123 instruments_with_unattributed_fills: &IndexSet<InstrumentId>,
2124 positions_with_fills: &IndexSet<PositionId>,
2125 ) -> Option<Vec<OrderEventAny>> {
2126 let venue_position_id = report.venue_position_id?;
2127
2128 if positions_with_fills.contains(&venue_position_id) {
2130 log::debug!(
2131 "Skipping hedge position {venue_position_id} reconciliation: fills already in batch"
2132 );
2133 return None;
2134 }
2135
2136 if instruments_with_unattributed_fills.contains(&report.instrument_id) {
2139 log::debug!(
2140 "Skipping hedge position {venue_position_id} reconciliation: unattributed fills in batch"
2141 );
2142 return None;
2143 }
2144
2145 log::debug!(
2146 "Reconciling HEDGE position for {}, venue_position_id={}",
2147 report.instrument_id,
2148 venue_position_id
2149 );
2150
2151 let position = {
2152 let cache = self.cache.borrow();
2153 cache.position_owned(&venue_position_id)
2154 };
2155
2156 match position {
2157 Some(position) => {
2158 let cached_signed_qty = position.signed_decimal_qty();
2159 let venue_signed_qty = report.signed_decimal_qty;
2160
2161 if cached_signed_qty == venue_signed_qty {
2162 log::debug!(
2163 "Hedge position {venue_position_id} matches venue: qty={cached_signed_qty}"
2164 );
2165 return None;
2166 }
2167
2168 if venue_signed_qty == Decimal::ZERO && cached_signed_qty == Decimal::ZERO {
2169 return None;
2170 }
2171
2172 if !self.config.generate_missing_orders {
2173 log::error!(
2174 "Cannot reconcile {} {}: position net qty {} != reported net qty {} \
2175 and `generate_missing_orders` is disabled",
2176 report.instrument_id,
2177 venue_position_id,
2178 cached_signed_qty,
2179 venue_signed_qty
2180 );
2181 return None;
2182 }
2183
2184 self.reconcile_hedge_position_discrepancy(
2185 report,
2186 account_id,
2187 &position,
2188 cached_signed_qty,
2189 )
2190 }
2191 None => {
2192 if report.signed_decimal_qty == Decimal::ZERO {
2193 return None;
2194 }
2195
2196 if !self.config.generate_missing_orders {
2197 log::error!(
2198 "Cannot reconcile position: {venue_position_id} not found and `generate_missing_orders` is disabled"
2199 );
2200 return None;
2201 }
2202
2203 self.reconcile_missing_hedge_position(report, account_id)
2204 }
2205 }
2206 }
2207
2208 fn reconcile_hedge_position_discrepancy(
2209 &mut self,
2210 report: &PositionStatusReport,
2211 account_id: AccountId,
2212 position: &Position,
2213 cached_signed_qty: Decimal,
2214 ) -> Option<Vec<OrderEventAny>> {
2215 let instrument = self.get_instrument(&report.instrument_id)?;
2216 let venue_signed_qty = report.signed_decimal_qty;
2217
2218 let diff = (cached_signed_qty - venue_signed_qty).abs();
2219 let diff_qty = Quantity::from_decimal_dp(diff, instrument.size_precision()).ok()?;
2220
2221 if diff_qty.is_zero() {
2222 log::debug!(
2223 "Difference quantity rounds to zero for {}, skipping",
2224 instrument.id()
2225 );
2226 return None;
2227 }
2228
2229 let venue_position_id = report.venue_position_id?;
2230 log::warn!(
2231 "Hedge position discrepancy for {} {}: cached={}, venue={}, generating reconciliation order",
2232 report.instrument_id,
2233 venue_position_id,
2234 cached_signed_qty,
2235 venue_signed_qty
2236 );
2237
2238 let current_avg_px = if position.avg_px_open > 0.0 {
2239 Decimal::from_str(&position.avg_px_open.to_string()).ok()
2240 } else {
2241 None
2242 };
2243
2244 self.create_position_reconciliation_order(
2245 report,
2246 account_id,
2247 &instrument,
2248 cached_signed_qty,
2249 diff_qty,
2250 current_avg_px,
2251 )
2252 }
2253
2254 fn reconcile_missing_hedge_position(
2255 &mut self,
2256 report: &PositionStatusReport,
2257 account_id: AccountId,
2258 ) -> Option<Vec<OrderEventAny>> {
2259 let instrument = self.get_instrument(&report.instrument_id)?;
2260 let venue_signed_qty = report.signed_decimal_qty;
2261
2262 let qty = venue_signed_qty.abs();
2263 let diff_qty = Quantity::from_decimal_dp(qty, instrument.size_precision()).ok()?;
2264
2265 if diff_qty.is_zero() {
2266 return None;
2267 }
2268
2269 let venue_position_id = report.venue_position_id?;
2270 log::warn!(
2271 "Missing hedge position for {} {}: venue reports {}, generating reconciliation order",
2272 report.instrument_id,
2273 venue_position_id,
2274 venue_signed_qty
2275 );
2276
2277 self.create_position_reconciliation_order(
2278 report,
2279 account_id,
2280 &instrument,
2281 Decimal::ZERO,
2282 diff_qty,
2283 None,
2284 )
2285 }
2286
2287 fn reconcile_position_report_netting(
2288 &mut self,
2289 report: &PositionStatusReport,
2290 account_id: AccountId,
2291 ) -> Option<Vec<OrderEventAny>> {
2292 let instrument_id = report.instrument_id;
2293
2294 log::debug!("Reconciling NET position for {instrument_id}");
2295
2296 let instrument = self.get_instrument(&instrument_id)?;
2297
2298 let (cached_signed_qty, cached_avg_px) = {
2299 let cache = self.cache.borrow();
2300 let positions =
2301 cache.positions_open(None, Some(&instrument_id), None, Some(&account_id), None);
2302
2303 if positions.is_empty() {
2304 (Decimal::ZERO, None)
2305 } else {
2306 let mut total_signed_qty = Decimal::ZERO;
2307 let mut total_value = Decimal::ZERO;
2308 let mut total_qty = Decimal::ZERO;
2309
2310 for pos in positions {
2311 total_signed_qty += pos.signed_decimal_qty();
2312 let qty = pos.signed_decimal_qty().abs();
2313 if pos.avg_px_open > 0.0
2314 && qty > Decimal::ZERO
2315 && let Ok(avg_px) = Decimal::from_str(&pos.avg_px_open.to_string())
2316 {
2317 total_value += avg_px * qty;
2318 total_qty += qty;
2319 }
2320 }
2321
2322 let avg_px = if total_qty > Decimal::ZERO {
2323 Some(total_value / total_qty)
2324 } else {
2325 None
2326 };
2327
2328 (total_signed_qty, avg_px)
2329 }
2330 };
2331
2332 let venue_signed_qty = report.signed_decimal_qty;
2333
2334 log::debug!("venue_signed_qty={venue_signed_qty}, cached_signed_qty={cached_signed_qty}");
2335
2336 let tolerance = Decimal::from_str("0.00000001").unwrap_or(Decimal::ZERO);
2337 if (cached_signed_qty - venue_signed_qty).abs() <= tolerance {
2338 log::debug!("Position quantities match for {instrument_id}, no reconciliation needed");
2339 return None;
2340 }
2341
2342 if !self.config.generate_missing_orders {
2343 log::debug!(
2344 "Discrepancy for {instrument_id} position when `generate_missing_orders` disabled, skipping"
2345 );
2346 return None;
2347 }
2348
2349 let diff = (cached_signed_qty - venue_signed_qty).abs();
2350 let diff_qty = Quantity::from_decimal_dp(diff, instrument.size_precision()).ok()?;
2351
2352 if diff_qty.is_zero() {
2353 log::debug!(
2354 "Difference quantity rounds to zero for {instrument_id}, skipping order generation"
2355 );
2356 return None;
2357 }
2358
2359 let crosses_zero = cached_signed_qty != Decimal::ZERO
2360 && venue_signed_qty != Decimal::ZERO
2361 && ((cached_signed_qty > Decimal::ZERO && venue_signed_qty < Decimal::ZERO)
2362 || (cached_signed_qty < Decimal::ZERO && venue_signed_qty > Decimal::ZERO));
2363
2364 if crosses_zero {
2365 let ts_now = self.clock.borrow().timestamp_ns();
2366 return self.reconcile_cross_zero_position(
2367 &instrument,
2368 account_id,
2369 instrument_id,
2370 cached_signed_qty,
2371 cached_avg_px,
2372 venue_signed_qty,
2373 report.avg_px_open,
2374 ts_now,
2375 report.ts_last,
2376 );
2377 }
2378
2379 if cached_signed_qty == Decimal::ZERO {
2380 return self.create_position_from_report(report, account_id, &instrument);
2381 }
2382
2383 self.create_position_reconciliation_order(
2384 report,
2385 account_id,
2386 &instrument,
2387 cached_signed_qty,
2388 diff_qty,
2389 cached_avg_px,
2390 )
2391 }
2392
2393 fn create_position_reconciliation_order(
2394 &mut self,
2395 report: &PositionStatusReport,
2396 account_id: AccountId,
2397 instrument: &InstrumentAny,
2398 cached_signed_qty: Decimal,
2399 diff_qty: Quantity,
2400 current_avg_px: Option<Decimal>,
2401 ) -> Option<Vec<OrderEventAny>> {
2402 let venue_signed_qty = report.signed_decimal_qty;
2403 let instrument_id = report.instrument_id;
2404
2405 let order_side = if venue_signed_qty > cached_signed_qty {
2406 OrderSide::Buy
2407 } else {
2408 OrderSide::Sell
2409 };
2410
2411 let reconciliation_px = calculate_reconciliation_price(
2412 cached_signed_qty,
2413 current_avg_px,
2414 venue_signed_qty,
2415 report.avg_px_open,
2416 );
2417
2418 let fill_px = reconciliation_px
2419 .or(report.avg_px_open)
2420 .or(current_avg_px)?;
2421
2422 let ts_now = self.clock.borrow().timestamp_ns();
2423 let fill_price = Price::from_decimal_dp(fill_px, instrument.price_precision()).ok();
2424 let venue_order_id = create_position_reconciliation_venue_order_id(
2425 account_id,
2426 instrument_id,
2427 order_side,
2428 OrderType::Market,
2429 diff_qty,
2430 fill_price,
2431 report.venue_position_id,
2432 None,
2433 report.ts_last,
2434 );
2435
2436 let mut order_report = OrderStatusReport::new(
2437 account_id,
2438 instrument_id,
2439 None,
2440 venue_order_id,
2441 order_side,
2442 OrderType::Market,
2443 TimeInForce::Gtc,
2444 OrderStatus::Filled,
2445 diff_qty,
2446 diff_qty,
2447 ts_now,
2448 ts_now,
2449 ts_now,
2450 None,
2451 )
2452 .with_avg_px(fill_px.to_f64().unwrap_or(0.0))
2453 .ok()?;
2454
2455 if let Some(venue_position_id) = report.venue_position_id {
2456 order_report = order_report.with_venue_position_id(venue_position_id);
2457 }
2458
2459 log::info!(
2460 color = LogColor::Blue as u8;
2461 "Generating reconciliation order for {instrument_id}: side={order_side:?}, qty={diff_qty}, px={fill_px}",
2462 );
2463
2464 let (events, _) =
2465 self.handle_external_order(&order_report, account_id, instrument, &[], true);
2466 Some(events)
2467 }
2468
2469 fn reconcile_order_report(
2470 &self,
2471 order: &OrderAny,
2472 report: &OrderStatusReport,
2473 instrument: Option<&InstrumentAny>,
2474 ) -> Option<OrderEventAny> {
2475 let ts_now = self.clock.borrow().timestamp_ns();
2476 reconcile_order_report(order, report, instrument, ts_now)
2477 }
2478
2479 fn reconcile_order_with_fills(
2484 &mut self,
2485 order: &OrderAny,
2486 report: &OrderStatusReport,
2487 fills: &[&FillReport],
2488 instrument: Option<&InstrumentAny>,
2489 ) -> Vec<OrderEventAny> {
2490 let mut events = Vec::new();
2491 let mut sorted_fills: Vec<&FillReport> = fills.to_vec();
2492 sorted_fills.sort_by_key(|f| f.ts_event);
2493
2494 let ts_now = self.clock.borrow().timestamp_ns();
2495
2496 match report.order_status {
2497 OrderStatus::Canceled => {
2498 if report.ts_triggered.is_some()
2499 && order.status() != OrderStatus::Triggered
2500 && TRIGGERABLE_ORDER_TYPES.contains(&order.order_type())
2501 {
2502 events.push(create_reconciliation_triggered(order, report, ts_now));
2503 }
2504
2505 if let Some(inst) = instrument {
2508 for fill in &sorted_fills {
2509 if let Some(event) = self.create_order_fill(order, fill, inst) {
2510 events.push(event);
2511 }
2512 }
2513 }
2514
2515 if let Some(event) = self.reconcile_order_report(order, report, instrument) {
2516 events.push(event);
2517 }
2518 }
2519 OrderStatus::Expired => {
2520 if report.ts_triggered.is_some()
2521 && order.status() != OrderStatus::Triggered
2522 && TRIGGERABLE_ORDER_TYPES.contains(&order.order_type())
2523 {
2524 events.push(create_reconciliation_triggered(order, report, ts_now));
2525 }
2526
2527 if let Some(inst) = instrument {
2529 for fill in &sorted_fills {
2530 if let Some(event) = self.create_order_fill(order, fill, inst) {
2531 events.push(event);
2532 }
2533 }
2534 }
2535
2536 if let Some(event) = self.reconcile_order_report(order, report, instrument) {
2537 events.push(event);
2538 }
2539 }
2540 _ => {
2541 if let Some(event) = self.reconcile_order_report(order, report, instrument) {
2542 events.push(event);
2543 }
2544
2545 if let Some(inst) = instrument {
2546 for fill in &sorted_fills {
2547 if let Some(event) = self.create_order_fill(order, fill, inst) {
2548 events.push(event);
2549 }
2550 }
2551 }
2552 }
2553 }
2554
2555 events
2556 }
2557
2558 fn handle_external_order(
2559 &mut self,
2560 report: &OrderStatusReport,
2561 account_id: AccountId,
2562 instrument: &InstrumentAny,
2563 fills: &[&FillReport],
2564 is_synthetic: bool,
2565 ) -> (Vec<OrderEventAny>, Option<ExternalOrderMetadata>) {
2566 let (strategy_id, tags) =
2567 if let Some(claimed_strategy) = self.external_order_claims.get(&report.instrument_id) {
2568 let order_id = report
2569 .client_order_id
2570 .map_or_else(|| report.venue_order_id.to_string(), |id| id.to_string());
2571 log::info!(
2572 color = LogColor::Blue as u8;
2573 "External order {} for {} claimed by strategy {}",
2574 order_id,
2575 report.instrument_id,
2576 claimed_strategy,
2577 );
2578 (*claimed_strategy, None)
2579 } else {
2580 let tag = if is_synthetic {
2582 *TAG_RECONCILIATION
2583 } else {
2584 *TAG_VENUE
2585 };
2586 (StrategyId::from("EXTERNAL"), Some(vec![tag]))
2587 };
2588
2589 if self.config.filter_unclaimed_external && !is_synthetic {
2591 return (Vec::new(), None);
2592 }
2593
2594 let client_order_id = report
2595 .client_order_id
2596 .unwrap_or_else(|| ClientOrderId::from(report.venue_order_id.as_str()));
2597
2598 if !report.quantity.is_positive() {
2599 log::error!(
2600 "Skipping external order {} ({}) for {}: non-positive quantity in report {:?}",
2601 client_order_id,
2602 report.venue_order_id,
2603 report.instrument_id,
2604 report,
2605 );
2606 return (Vec::new(), None);
2607 }
2608
2609 let ts_now = self.clock.borrow().timestamp_ns();
2610
2611 let initialized = OrderInitialized::new(
2612 self.config.trader_id,
2613 strategy_id,
2614 report.instrument_id,
2615 client_order_id,
2616 report.order_side,
2617 report.order_type,
2618 report.quantity,
2619 report.time_in_force,
2620 report.post_only,
2621 report.reduce_only,
2622 false, true, UUID4::new(),
2625 ts_now,
2626 ts_now,
2627 report.price,
2628 report.trigger_price,
2629 report.trigger_type,
2630 report.limit_offset,
2631 report.trailing_offset,
2632 Some(report.trailing_offset_type),
2633 report.expire_time,
2634 report.display_qty,
2635 None, None, Some(report.contingency_type),
2638 report.order_list_id,
2639 report.linked_order_ids.clone(),
2640 report.parent_order_id,
2641 None, None, None, tags,
2645 );
2646
2647 let initialized = OrderEventAny::Initialized(initialized);
2648 let order = match OrderAny::from_events(vec![initialized.clone()]) {
2649 Ok(order) => order,
2650 Err(e) => {
2651 log::error!("Failed to create order from report: {e}");
2652 return (Vec::new(), None);
2653 }
2654 };
2655
2656 {
2657 let mut cache = self.cache.borrow_mut();
2658 if let Err(e) = cache.add_order(order.clone(), None, None, false) {
2659 match cache.order(&client_order_id) {
2663 Some(existing) if is_synthetic && existing.is_closed() => {
2664 log::debug!(
2665 "Skipping synthetic reconciliation order {client_order_id} for {}: \
2666 replay deduped (cached status={:?})",
2667 report.instrument_id,
2668 existing.status(),
2669 );
2670 }
2671 Some(existing) if is_synthetic => {
2672 log::warn!(
2673 "Synthetic reconciliation order {client_order_id} for {} exists in \
2674 cache in non-terminal state {:?}; fill not regenerated",
2675 report.instrument_id,
2676 existing.status(),
2677 );
2678 }
2679 _ => {
2680 log::error!("Failed to add external order to cache: {e}");
2681 }
2682 }
2683 return (Vec::new(), None);
2684 }
2685
2686 if let Err(e) =
2687 cache.add_venue_order_id(&client_order_id, &report.venue_order_id, false)
2688 {
2689 log::warn!("Failed to add venue order ID index: {e}");
2690 }
2691 }
2692
2693 Self::publish_order_event(&initialized);
2694
2695 log::info!(
2696 color = LogColor::Blue as u8;
2697 "Created external order {} ({}) for {} [{}]",
2698 client_order_id,
2699 report.venue_order_id,
2700 report.instrument_id,
2701 report.order_status,
2702 );
2703
2704 let ts_now = self.clock.borrow().timestamp_ns();
2705
2706 let mut order_events =
2709 generate_external_order_status_events(&order, report, &account_id, instrument, ts_now);
2710
2711 if !fills.is_empty() {
2712 let cached_order = self.get_order(client_order_id).unwrap();
2713 let mut sorted_fills: Vec<&FillReport> = fills.to_vec();
2714 sorted_fills.sort_by_key(|f| f.ts_event);
2715
2716 match report.order_status {
2717 OrderStatus::Canceled | OrderStatus::Expired => {
2718 let terminal_event = order_events.pop();
2719
2720 for fill in sorted_fills {
2721 if let Some(fill_event) =
2722 self.create_order_fill(&cached_order, fill, instrument)
2723 {
2724 order_events.push(fill_event);
2725 }
2726 }
2727
2728 if let Some(event) = terminal_event {
2729 order_events.push(event);
2730 }
2731 }
2732 OrderStatus::Filled | OrderStatus::PartiallyFilled => {
2733 if order_events
2735 .last()
2736 .is_some_and(|e| matches!(e, OrderEventAny::Filled(_)))
2737 {
2738 order_events.pop();
2739 }
2740
2741 let mut real_fill_total = Decimal::ZERO;
2742
2743 for fill in &sorted_fills {
2744 if let Some(fill_event) =
2745 self.create_order_fill(&cached_order, fill, instrument)
2746 {
2747 real_fill_total += fill.last_qty.as_decimal();
2748 order_events.push(fill_event);
2749 }
2750 }
2751
2752 let report_filled = report.filled_qty.as_decimal();
2753 if real_fill_total < report_filled {
2754 let diff_decimal = report_filled - real_fill_total;
2755
2756 if let Ok(diff) =
2757 Quantity::from_decimal_dp(diff_decimal, instrument.size_precision())
2758 && let Some(inferred_fill) = create_inferred_fill_for_qty(
2759 &cached_order,
2760 report,
2761 &account_id,
2762 instrument,
2763 diff,
2764 ts_now,
2765 None,
2766 )
2767 {
2768 order_events.push(inferred_fill);
2769 }
2770 }
2771 }
2772 _ => {}
2773 }
2774 }
2775
2776 let metadata = ExternalOrderMetadata {
2777 client_order_id,
2778 venue_order_id: report.venue_order_id,
2779 instrument_id: report.instrument_id,
2780 strategy_id,
2781 ts_init: ts_now,
2782 };
2783
2784 (order_events, Some(metadata))
2785 }
2786
2787 fn publish_order_event(event: &OrderEventAny) {
2788 let topic = switchboard::get_event_order_topic(event.strategy_id());
2789 msgbus::publish_order_event(topic, event);
2790 }
2791
2792 fn adjust_mass_status_fills(
2797 &self,
2798 mass_status: &ExecutionMassStatus,
2799 ) -> (
2800 IndexMap<VenueOrderId, OrderStatusReport>,
2801 IndexMap<VenueOrderId, Vec<FillReport>>,
2802 ) {
2803 let mut final_orders: IndexMap<VenueOrderId, OrderStatusReport> =
2804 mass_status.order_reports();
2805 let mut final_fills: IndexMap<VenueOrderId, Vec<FillReport>> = mass_status.fill_reports();
2806
2807 let mut instruments_to_adjust = Vec::new();
2808
2809 for (instrument_id, position_reports) in mass_status.position_reports() {
2810 if !self.should_reconcile_instrument(&instrument_id) {
2811 log::debug!(
2812 "Skipping fill adjustment for {instrument_id}: not in reconciliation_instrument_ids"
2813 );
2814 continue;
2815 }
2816
2817 let is_hedge_mode = position_reports
2820 .iter()
2821 .any(|r| r.venue_position_id.is_some());
2822
2823 if is_hedge_mode {
2824 log::debug!(
2825 "Skipping fill adjustment for {instrument_id}: hedge mode (has venue_position_id)"
2826 );
2827 continue;
2828 }
2829
2830 if let Some(instrument) = self.get_instrument(&instrument_id) {
2831 instruments_to_adjust.push(instrument);
2832 } else {
2833 log::debug!(
2834 "Skipping fill adjustment for {instrument_id}: instrument not found in cache"
2835 );
2836 }
2837 }
2838
2839 if instruments_to_adjust.is_empty() {
2840 return (final_orders, final_fills);
2841 }
2842
2843 log_info!(
2844 "Adjusting fills for {} instrument(s) with position reports",
2845 instruments_to_adjust.len(),
2846 color = LogColor::Blue
2847 );
2848
2849 for instrument in &instruments_to_adjust {
2850 let instrument_id = instrument.id();
2851
2852 match process_mass_status_for_reconciliation(mass_status, instrument, None) {
2853 Ok(result) => {
2854 final_orders.retain(|_, order| order.instrument_id != instrument_id);
2855 final_fills.retain(|_, fills| {
2856 fills
2857 .first()
2858 .is_none_or(|f| f.instrument_id != instrument_id)
2859 });
2860
2861 for (venue_order_id, order) in result.orders {
2862 final_orders.insert(venue_order_id, order);
2863 }
2864
2865 for (venue_order_id, fills) in result.fills {
2866 final_fills.insert(venue_order_id, fills);
2867 }
2868 }
2869 Err(e) => {
2870 log::warn!("Failed to adjust fills for {instrument_id}: {e}");
2871 }
2872 }
2873 }
2874
2875 log_info!(
2876 "After adjustment: {} order(s), {} fill group(s)",
2877 final_orders.len(),
2878 final_fills.len(),
2879 color = LogColor::Blue
2880 );
2881
2882 (final_orders, final_fills)
2883 }
2884
2885 fn deduplicate_order_reports<'a>(
2890 reports: impl Iterator<Item = &'a OrderStatusReport>,
2891 ) -> IndexMap<VenueOrderId, &'a OrderStatusReport> {
2892 let mut best_reports: IndexMap<VenueOrderId, &'a OrderStatusReport> = IndexMap::new();
2893
2894 for report in reports {
2895 let dominated = best_reports
2896 .get(&report.venue_order_id)
2897 .is_some_and(|existing| Self::is_more_advanced(existing, report));
2898
2899 if !dominated {
2900 best_reports.insert(report.venue_order_id, report);
2901 }
2902 }
2903
2904 best_reports
2905 }
2906
2907 fn is_more_advanced(a: &OrderStatusReport, b: &OrderStatusReport) -> bool {
2908 if a.filled_qty > b.filled_qty {
2909 return true;
2910 }
2911
2912 if a.filled_qty < b.filled_qty {
2913 return false;
2914 }
2915
2916 Self::status_priority(a.order_status) > Self::status_priority(b.order_status)
2918 }
2919
2920 const fn status_priority(status: OrderStatus) -> u8 {
2921 match status {
2922 OrderStatus::Initialized | OrderStatus::Submitted | OrderStatus::Emulated => 0,
2923 OrderStatus::Released | OrderStatus::Denied => 1,
2924 OrderStatus::Accepted | OrderStatus::PendingUpdate | OrderStatus::PendingCancel => 2,
2925 OrderStatus::Triggered => 3,
2926 OrderStatus::PartiallyFilled => 4,
2927 OrderStatus::Canceled | OrderStatus::Expired | OrderStatus::Rejected => 5,
2928 OrderStatus::Filled => 6,
2929 }
2930 }
2931
2932 fn is_exact_order_match(order: &OrderAny, report: &OrderStatusReport) -> bool {
2933 order.status() == report.order_status
2934 && order.filled_qty() == report.filled_qty
2935 && !should_reconciliation_update(order, report)
2936 }
2937
2938 fn create_order_fill(
2939 &mut self,
2940 order: &OrderAny,
2941 fill: &FillReport,
2942 instrument: &InstrumentAny,
2943 ) -> Option<OrderEventAny> {
2944 if self.processed_fills.contains_key(&fill.trade_id) {
2945 return None;
2946 }
2947
2948 self.processed_fills
2949 .insert(fill.trade_id, order.client_order_id());
2950
2951 Some(OrderEventAny::Filled(OrderFilled::new(
2952 order.trader_id(),
2953 order.strategy_id(),
2954 order.instrument_id(),
2955 order.client_order_id(),
2956 fill.venue_order_id,
2957 fill.account_id,
2958 fill.trade_id,
2959 fill.order_side,
2960 order.order_type(),
2961 fill.last_qty,
2962 fill.last_px,
2963 instrument.quote_currency(),
2964 fill.liquidity_side,
2965 fill.report_id,
2966 fill.ts_event,
2967 self.clock.borrow().timestamp_ns(),
2968 false,
2969 fill.venue_position_id,
2970 Some(fill.commission),
2971 )))
2972 }
2973}
2974
2975#[cfg(test)]
2976mod tests {
2977 use nautilus_common::clock::TestClock;
2978 use nautilus_model::{
2979 enums::OmsType,
2980 instruments::{
2981 Instrument,
2982 stubs::{crypto_perpetual_ethusdt, xbtusd_bitmex},
2983 },
2984 orders::{OrderTestBuilder, stubs::TestOrderEventStubs},
2985 };
2986 use rstest::rstest;
2987
2988 use super::*;
2989
2990 #[rstest]
2991 fn test_prepare_open_order_report_check_builds_bulk_command_with_config() {
2992 let lookback_mins = 5_u64;
2993 let lookback_ns = lookback_mins * 60 * NANOSECONDS_IN_SECOND;
2994 let clock = Rc::new(RefCell::new(TestClock::new()));
2995 let cache = Rc::new(RefCell::new(Cache::default()));
2996 let manager = ExecutionManager::new(
2997 clock.clone(),
2998 cache.clone(),
2999 ExecutionManagerConfig {
3000 open_check_lookback_mins: Some(lookback_mins),
3001 open_check_open_only: false,
3002 reconciliation_instrument_ids: IndexSet::from([crypto_perpetual_ethusdt().id()]),
3003 ..Default::default()
3004 },
3005 );
3006 let included_id = ClientOrderId::from("O-REPORT-001");
3007 let excluded_id = ClientOrderId::from("O-REPORT-002");
3008 let included_instrument_id = crypto_perpetual_ethusdt().id();
3009 let excluded_instrument_id = xbtusd_bitmex().id();
3010
3011 cache
3012 .borrow_mut()
3013 .add_instrument(InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt()))
3014 .unwrap();
3015 cache
3016 .borrow_mut()
3017 .add_instrument(InstrumentAny::CryptoPerpetual(xbtusd_bitmex()))
3018 .unwrap();
3019 insert_accepted_limit_order(
3020 &cache,
3021 included_id,
3022 VenueOrderId::from("V-REPORT-001"),
3023 included_instrument_id,
3024 ClientId::from("BINANCE"),
3025 );
3026 insert_accepted_limit_order(
3027 &cache,
3028 excluded_id,
3029 VenueOrderId::from("V-REPORT-002"),
3030 excluded_instrument_id,
3031 ClientId::from("BITMEX"),
3032 );
3033 clock
3034 .borrow_mut()
3035 .advance_time(UnixNanos::from(lookback_ns * 2), true);
3036
3037 let ts_now = clock.borrow().timestamp_ns();
3038 let command_id = UUID4::new();
3039 let check = manager.prepare_open_order_report_check(command_id);
3040
3041 assert_eq!(check.command.command_id, command_id);
3042 assert_eq!(check.command.ts_init, ts_now);
3043 assert!(!check.command.open_only);
3044 assert_eq!(check.command.instrument_id, None);
3045 assert_eq!(
3046 check.command.start,
3047 Some(ts_now.saturating_sub_ns(lookback_ns))
3048 );
3049 assert_eq!(check.command.end, None);
3050 assert_eq!(check.command.log_receipt_level, LogLevel::Debug);
3051 assert_eq!(check.start, check.command.start);
3052 assert_eq!(check.filtered_orders.len(), 1);
3053 assert_eq!(check.filtered_orders[0].client_order_id(), included_id);
3054 }
3055
3056 #[rstest]
3057 fn test_prepare_position_report_check_builds_bulk_command_with_snapshot() {
3058 let clock = Rc::new(RefCell::new(TestClock::new()));
3059 let cache = Rc::new(RefCell::new(Cache::default()));
3060 let manager = ExecutionManager::new(
3061 clock.clone(),
3062 cache.clone(),
3063 ExecutionManagerConfig {
3064 reconciliation_instrument_ids: IndexSet::from([crypto_perpetual_ethusdt().id()]),
3065 ..Default::default()
3066 },
3067 );
3068 let included_instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
3069 let excluded_instrument = InstrumentAny::CryptoPerpetual(xbtusd_bitmex());
3070
3071 cache
3072 .borrow_mut()
3073 .add_instrument(included_instrument.clone())
3074 .unwrap();
3075 cache
3076 .borrow_mut()
3077 .add_instrument(excluded_instrument.clone())
3078 .unwrap();
3079 let included_position = insert_open_position(
3080 &cache,
3081 &included_instrument,
3082 PositionId::from("P-REPORT-001"),
3083 OrderSide::Buy,
3084 "5.0",
3085 "3000.00",
3086 );
3087 insert_open_position(
3088 &cache,
3089 &excluded_instrument,
3090 PositionId::from("P-REPORT-002"),
3091 OrderSide::Buy,
3092 "2.0",
3093 "40000.00",
3094 );
3095
3096 let ts_now = clock.borrow().timestamp_ns();
3097 let command_id = UUID4::new();
3098 let check = manager.prepare_position_report_check(command_id);
3099 let key = (
3100 included_position.instrument_id,
3101 included_position.account_id,
3102 );
3103 let positions = check.positions_by_key.get(&key).unwrap();
3104
3105 assert_eq!(check.command.command_id, command_id);
3106 assert_eq!(check.command.ts_init, ts_now);
3107 assert_eq!(check.command.instrument_id, None);
3108 assert_eq!(check.command.start, None);
3109 assert_eq!(check.command.end, None);
3110 assert_eq!(check.command.log_receipt_level, LogLevel::Debug);
3111 assert_eq!(check.positions_by_key.len(), 1);
3112 assert_eq!(positions.len(), 1);
3113 assert_eq!(positions[0].id, included_position.id);
3114 }
3115
3116 fn insert_accepted_limit_order(
3117 cache: &Rc<RefCell<Cache>>,
3118 client_order_id: ClientOrderId,
3119 venue_order_id: VenueOrderId,
3120 instrument_id: InstrumentId,
3121 client_id: ClientId,
3122 ) {
3123 let account_id = AccountId::from("TEST-001");
3124 let order = OrderTestBuilder::new(OrderType::Limit)
3125 .client_order_id(client_order_id)
3126 .instrument_id(instrument_id)
3127 .quantity(Quantity::from("10.0"))
3128 .price(Price::from("100.0"))
3129 .build();
3130 let submitted = TestOrderEventStubs::submitted(&order, account_id);
3131 cache
3132 .borrow_mut()
3133 .add_order(order, None, Some(client_id), false)
3134 .unwrap();
3135 let order = cache.borrow_mut().update_order(&submitted).unwrap();
3136 let accepted = TestOrderEventStubs::accepted(&order, account_id, venue_order_id);
3137 cache.borrow_mut().update_order(&accepted).unwrap();
3138 }
3139
3140 fn insert_open_position(
3141 cache: &Rc<RefCell<Cache>>,
3142 instrument: &InstrumentAny,
3143 position_id: PositionId,
3144 side: OrderSide,
3145 quantity: &str,
3146 price: &str,
3147 ) -> Position {
3148 let order = OrderTestBuilder::new(OrderType::Market)
3149 .instrument_id(instrument.id())
3150 .side(side)
3151 .quantity(Quantity::from(quantity))
3152 .build();
3153 let fill = TestOrderEventStubs::filled(
3154 &order,
3155 instrument,
3156 Some(TradeId::new("T-REPORT-001")),
3157 Some(position_id),
3158 Some(Price::from(price)),
3159 Some(Quantity::from(quantity)),
3160 None,
3161 None,
3162 None,
3163 Some(AccountId::from("TEST-001")),
3164 );
3165 let order_filled: OrderFilled = fill.into();
3166 let position = Position::new(instrument, order_filled);
3167 cache
3168 .borrow_mut()
3169 .add_position(&position, OmsType::Hedging)
3170 .unwrap();
3171 position
3172 }
3173}