Skip to main content

nautilus_live/execution/
manager.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Execution state manager for live trading.
17//!
18//! This module provides the execution manager for reconciling execution state between
19//! the local cache and connected venues, as well as purging old state during live trading.
20
21use 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
72/// Tag for orders originating from venue (external orders).
73static TAG_VENUE: LazyLock<Ustr> = LazyLock::new(|| Ustr::from("VENUE"));
74
75/// Tag for orders generated by reconciliation logic (synthetic orders).
76static TAG_RECONCILIATION: LazyLock<Ustr> = LazyLock::new(|| Ustr::from("RECONCILIATION"));
77
78/// Composite key identifying a position context by instrument and account.
79///
80/// Used to scope per-position reconciliation state (retry counters, activity
81/// throttles, venue report lookups) so that multiple accounts holding the same
82/// instrument do not share the same tracking entry.
83pub type InstrumentAccountKey = (InstrumentId, AccountId);
84
85/// Metadata for an external order that needs to be registered with the execution client.
86#[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/// Result of reconciliation containing events and external order metadata.
96#[derive(Debug, Default)]
97pub struct ReconciliationResult {
98    /// Order events generated during reconciliation.
99    pub events: Vec<OrderEventAny>,
100    /// External orders that need to be registered with execution clients.
101    pub external_orders: Vec<ExternalOrderMetadata>,
102}
103
104/// Result of inflight order checks containing terminal events and intermediate queries.
105#[derive(Debug, Default)]
106pub struct InflightCheckResult {
107    /// Terminal events (rejection/cancellation) for orders that exceeded max retries.
108    pub events: Vec<OrderEventAny>,
109    /// Intermediate venue queries for orders still within retry budget.
110    pub queries: Vec<TradingCommand>,
111}
112
113/// Snapshot and command for one continuous open-order reconciliation check.
114#[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/// Snapshot and command for one continuous position reconciliation check.
122#[derive(Debug, Clone)]
123pub(crate) struct PositionReportCheck {
124    pub command: GeneratePositionStatusReports,
125    pub positions_by_key: IndexMap<InstrumentAccountKey, Vec<Position>>,
126}
127
128/// Configuration for execution manager.
129#[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    /// The trader ID for generated orders.
136    pub trader_id: TraderId,
137    /// If reconciliation is active at start-up.
138    pub reconciliation: bool,
139    /// Number of minutes to look back during reconciliation.
140    pub lookback_mins: Option<u64>,
141    /// Instrument IDs to include during reconciliation (empty => all).
142    pub reconciliation_instrument_ids: IndexSet<InstrumentId>,
143    /// Whether to filter unclaimed external orders.
144    pub filter_unclaimed_external: bool,
145    /// Whether to filter position status reports during reconciliation.
146    pub filter_position_reports: bool,
147    /// Client order IDs excluded from reconciliation.
148    pub filtered_client_order_ids: IndexSet<ClientOrderId>,
149    /// Whether to generate missing orders from reports.
150    pub generate_missing_orders: bool,
151    /// The interval (milliseconds) between checking whether in-flight orders have exceeded their threshold.
152    pub inflight_check_interval_ms: u32,
153    /// Threshold in milliseconds for inflight order checks.
154    pub inflight_threshold_ms: u64,
155    /// Maximum number of retries for inflight checks.
156    pub inflight_max_retries: u32,
157    /// The interval (seconds) between checks for open orders at the venue.
158    pub open_check_interval_secs: Option<f64>,
159    /// The lookback minutes for open order checks.
160    pub open_check_lookback_mins: Option<u64>,
161    /// Threshold in nanoseconds before acting on venue discrepancies for open orders.
162    pub open_check_threshold_ns: u64,
163    /// Maximum retries before resolving an open order missing at the venue.
164    pub open_check_missing_retries: u32,
165    /// Whether open-order polling should only request open orders from the venue.
166    pub open_check_open_only: bool,
167    /// The maximum number of single-order queries per consistency check cycle.
168    pub max_single_order_queries_per_cycle: u32,
169    /// The delay (milliseconds) between consecutive single-order queries.
170    pub single_order_query_delay_ms: u32,
171    /// The interval (seconds) between checks for open positions at the venue.
172    pub position_check_interval_secs: Option<f64>,
173    /// The lookback minutes for position consistency checks.
174    pub position_check_lookback_mins: u64,
175    /// Threshold in nanoseconds before acting on venue discrepancies for positions.
176    pub position_check_threshold_ns: u64,
177    /// Maximum retries before stopping position discrepancy reconciliation.
178    pub position_check_retries: u32,
179    /// The time buffer (minutes) before closed orders can be purged.
180    pub purge_closed_orders_buffer_mins: Option<u32>,
181    /// The time buffer (minutes) before closed positions can be purged.
182    pub purge_closed_positions_buffer_mins: Option<u32>,
183    /// The time buffer (minutes) before account events can be purged.
184    pub purge_account_events_lookback_mins: Option<u32>,
185    /// If purge operations should also delete from the backing database.
186    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    /// Sets the trader ID on the configuration.
224    #[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/// Information about an inflight order check.
232#[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/// Manager for execution state.
242///
243/// The `ExecutionManager` handles:
244/// - Startup reconciliation to align state on system start.
245/// - Continuous reconciliation of inflight orders.
246/// - External order discovery and claiming.
247/// - Fill report processing and validation.
248/// - Purging of old orders, positions, and account events.
249///
250/// # Thread Safety
251///
252/// This struct is **not thread-safe** and is designed for single-threaded use within
253/// an async runtime. Internal state is managed using `IndexMap` without synchronization,
254/// and the `clock` and `cache` use `Rc<RefCell<>>` which provide runtime borrow checking
255/// but no thread-safety guarantees.
256///
257/// If concurrent access is required, this struct must be wrapped in `Arc<Mutex<>>` or
258/// similar synchronization primitives. Alternatively, ensure that all methods are called
259/// from the same thread/task in the async runtime.
260///
261/// **Warning:** Concurrent mutable access to internal `IndexMaps` or concurrent borrows
262/// of `RefCell` contents will cause runtime panics.
263#[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    /// Creates a new [`ExecutionManager`] instance.
293    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    /// Returns the current clock timestamp in nanoseconds.
315    #[must_use]
316    pub fn generate_timestamp_ns(&self) -> UnixNanos {
317        self.clock.borrow().timestamp_ns()
318    }
319
320    /// Reconciles orders and fills from a mass status report.
321    ///
322    /// Order events are collected, sorted globally by `ts_event`, then processed through
323    /// the execution engine to ensure chronological ordering across all orders.
324    /// Position events are processed after all order events to ensure fills are applied first.
325    #[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        // Publish raw reports before any state mutation (including fill adjustment
335        // below, which can synthesise replacement order/fill reports). The
336        // execution engine's per-report `reconcile_*` entry points are bypassed by
337        // this path, so the capture seam lives here.
338        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        // Deduplicate reports by venue_order_id, keeping the most advanced state
400        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                    // Still ensure venue_order_id is indexed even when skipping
417                    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                // Skip closed reconciliation orders to prevent duplicate inferred fills on restart
429                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                    // Always ensure venue_order_id is indexed after reconciliation
476                    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                    // Fallback: match by venue_order_id
486                    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, // Not synthetic (venue order)
537                        );
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                // Fallback: match by venue_order_id
562                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                // Synthetic orders (S- prefix) are generated by reconciliation logic
602                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        // Process orphan fills (fills without matching order reports)
639        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            // Skip if fill's client_order_id is in filtered list
660            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            // Skip if resolved order's client_order_id is filtered (venue_order_id lookup path)
679            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            // Collect instruments with fills that lack venue_position_id (can't attribute to
718            // specific hedge position, so must skip all hedge reports for that instrument)
719            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    /// Checks inflight orders and returns terminal events and intermediate venue queries.
797    ///
798    /// For retries below `inflight_max_retries`, generates `QueryOrder` commands to poll
799    /// the venue for the order's current status. At max retries, generates terminal events
800    /// (rejection or cancellation) based on the order's status.
801    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                                // Generate rejection for submitted orders that never got accepted
843                                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                                // Generate cancellation for orders stuck in pending modify/cancel
853                                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, // reconciliation
862                                    order.venue_order_id(),
863                                    order.account_id(),
864                                ));
865                                result.events.push(event);
866                            }
867                            _ => {
868                                // Order already resolved, just clear tracking
869                            }
870                        }
871                    }
872                    // Remove from inflight checks regardless of whether order exists
873                    self.clear_recon_tracking(&client_order_id, true);
874                } else if let Some(order) = self.get_order(client_order_id) {
875                    // Intermediate retry: query the venue for current order status
876                    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, // correlation_id
888                    ));
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    /// Prepares a bulk open-order report request and snapshots cached open orders.
943    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    /// Builds per-order venue queries for fallback open-order reconciliation.
981    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    /// Checks open orders consistency between cache and venue.
1074    ///
1075    /// This method validates that open orders in the cache match the venue's state,
1076    /// comparing order status and filled quantities, and generating reconciliation
1077    /// events for any discrepancies detected.
1078    ///
1079    /// # Returns
1080    ///
1081    /// A vector of order events generated to reconcile discrepancies.
1082    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    /// Reconciles bulk open-order report responses against a cached order snapshot.
1109    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                // Check for recent local activity to avoid race conditions with in-flight fills
1130                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        // Handle orders missing at venue (skip in open_only mode where the
1154        // venue response may omit recently closed orders). When a lookback
1155        // window is set, only consider orders within that window so older
1156        // GTC orders outside the query range are not falsely marked missing.
1157        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    /// Prepares a bulk position report request and snapshots cached positions.
1209    #[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, // instrument_id - query all
1223            None, // start
1224            None, // end
1225            None, // params
1226            None, // correlation_id
1227        );
1228        command.log_receipt_level = LogLevel::Debug;
1229
1230        PositionReportCheck {
1231            command,
1232            positions_by_key,
1233        }
1234    }
1235
1236    /// Checks position consistency between cache and venue.
1237    ///
1238    /// This method validates that positions in the cache match the venue's state,
1239    /// detecting position drift and querying for missing fills when discrepancies
1240    /// are found.
1241    ///
1242    /// # Returns
1243    ///
1244    /// A vector of fill events generated to reconcile position discrepancies.
1245    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    /// Reconciles cached positions against venue position reports.
1275    #[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        // Prune retry counters for (instrument, account) pairs no longer actively
1331        // tracked, excluding flat venue reports which shouldn't protect stale counters
1332        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    /// Registers an order as inflight for tracking.
1379    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    /// Records local activity for the specified order.
1396    ///
1397    /// Uses the current clock time (receipt time) instead of venue time to accurately
1398    /// track when we last processed activity for this order. This avoids race conditions
1399    /// where network/queue latency makes events appear "old" even though they just arrived.
1400    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    /// Clears reconciliation tracking state for an order.
1406    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    /// Claims external orders for a specific strategy and instrument.
1417    ///
1418    /// # Errors
1419    ///
1420    /// Returns an error if the instrument already has a registered claim.
1421    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    /// Records position activity for reconciliation tracking, scoped per (instrument, account).
1436    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    /// Returns the current position-reconciliation retry count for the given
1447    /// `(instrument, account)` key, or zero if no entry exists.
1448    #[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    /// Observes an incoming execution report and updates tracking state.
1454    ///
1455    /// This should be called **before** the report is dispatched to the execution
1456    /// engine, so that the manager's state is current when periodic checks run.
1457    ///
1458    /// Updates performed per report variant:
1459    /// - `Order`: clears inflight tracking and records local activity
1460    /// - `Fill`: marks fill as processed, records order and position activity
1461    /// - `OrderWithFills`: clears inflight tracking, records local activity, records position activity per fill
1462    /// - `Position`: records position activity
1463    /// - `MassStatus`: no-op (handled separately via startup reconciliation)
1464    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                    // Only clear inflight tracking for non-pending states.
1469                    // Pending reports (PendingUpdate, PendingCancel) are interim
1470                    // acknowledgements; the order is still inflight until the
1471                    // venue confirms the final state.
1472                    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                // Handled separately via reconcile_execution_mass_status
1532            }
1533        }
1534    }
1535
1536    /// Checks if a fill has been recently processed (for deduplication).
1537    #[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    /// Marks a fill as recently processed with current timestamp.
1543    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    /// Prunes expired fills from the recent fills cache.
1549    ///
1550    /// Default TTL is 60 seconds.
1551    #[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    /// Purges closed orders from the cache that are older than the configured buffer.
1566    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    /// Purges closed positions from the cache that are older than the configured buffer.
1580    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    /// Purges old account events from the cache based on the configured lookback.
1594    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    // Private helper methods
1608
1609    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        // Check if order is too recent
1670        if (ts_now - ts_last) < self.config.open_check_threshold_ns {
1671            return events;
1672        }
1673
1674        // Check local activity threshold
1675        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        // Increment retry counter
1682        let retries = self.recon_check_retries.entry(client_order_id).or_insert(0);
1683        *retries += 1;
1684
1685        // If max retries exceeded, generate rejection event
1686        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        // Track retries when reconciliation didn't produce events
1865        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    /// Handles position reconciliation when position flips sign, splitting into two
1887    /// fills: close existing position then open new position in opposite direction.
1888    #[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        // Close existing position first
1909        let close_qty = cached_signed_qty.abs();
1910        let close_side = if cached_signed_qty < Decimal::ZERO {
1911            OrderSide::Buy // Close short by buying
1912        } else {
1913            OrderSide::Sell // Close long by selling
1914        };
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        // Then open new position in opposite direction
1966        let open_qty = venue_signed_qty.abs();
1967        let open_side = if venue_signed_qty > Decimal::ZERO {
1968            OrderSide::Buy // Open long
1969        } else {
1970            OrderSide::Sell // Open short
1971        };
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    /// Creates a position from a venue position report when no orders/fills exist.
2026    ///
2027    /// This handles the case where the venue reports an open position but there are
2028    /// no order or fill reports to create it from (e.g., orders are already closed).
2029    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        // Preserve venue_position_id for hedging mode
2086        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        // Skip if batch already has fills for this position (will be created from fills)
2129        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        // Skip if fills exist for this instrument but lack venue_position_id
2137        // (can't determine which hedge position they belong to)
2138        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    /// Reconciles an order with its associated fills atomically.
2480    ///
2481    /// For terminal statuses (Canceled), fills are applied BEFORE the terminal event
2482    /// to ensure correct state transitions (matching Python behavior).
2483    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                // Apply fills before Canceled event regardless of current state,
2506                // as the order may have partial fills we haven't seen yet
2507                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                // Apply fills before Expired event (same as Canceled)
2528                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                // Unclaimed orders use EXTERNAL strategy ID with tag distinguishing source
2581                let tag = if is_synthetic {
2582                    *TAG_RECONCILIATION
2583                } else {
2584                    *TAG_VENUE
2585                };
2586                (StrategyId::from("EXTERNAL"), Some(vec![tag]))
2587            };
2588
2589        // Filter unclaimed venue orders (but not synthetic reconciliation orders)
2590        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, // quote_quantity
2623            true,  // reconciliation
2624            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, // emulation_trigger
2636            None, // trigger_instrument_id
2637            Some(report.contingency_type),
2638            report.order_list_id,
2639            report.linked_order_ids.clone(),
2640            report.parent_order_id,
2641            None, // exec_algorithm_id
2642            None, // exec_algorithm_params
2643            None, // exec_spawn_id
2644            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                // Deterministic synthetic reconciliation IDs hash the same logical event
2660                // to the same client_order_id, so a restart replay can legitimately collide
2661                // with a cached order. Differentiate expected dedup from stuck state.
2662                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        // Generate events for external order: Accepted first, then fills (for terminal statuses),
2707        // then terminal status. This matches Python's behavior.
2708        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                    // Only pop if the last event is a Filled event (the inferred fill)
2734                    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    /// Adjusts fills for instruments with incomplete first lifecycle (partial window).
2793    ///
2794    /// When historical fills don't fully explain the current position (e.g., lookback window
2795    /// started mid-position), this creates synthetic fills to align with the venue position.
2796    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            // Skip hedge mode instruments (have venue_position_id) as partial-window
2818            // adjustment assumes a single net position per instrument
2819            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    /// Deduplicates order reports, keeping the most advanced state per `venue_order_id`.
2886    ///
2887    /// When a batch contains multiple reports for the same order, we keep the one with
2888    /// the highest `filled_qty` (most progress), or if equal, the most terminal status.
2889    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        // Equal filled_qty - compare status (terminal states are more advanced)
2917        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}