Skip to main content

nautilus_execution/reconciliation/
orders.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//! Order and fill reconciliation.
17//!
18//! Event constructors, order state reconciliation, and fill reconciliation. Every
19//! helper turns a venue-sourced report into zero or more `OrderEventAny`s that are
20//! safe to apply to the local order model.
21
22use std::str::FromStr;
23
24use nautilus_common::enums::LogColor;
25use nautilus_core::{UUID4, UnixNanos};
26use nautilus_model::{
27    enums::{LiquiditySide, OrderStatus, OrderType},
28    events::{
29        OrderAccepted, OrderCanceled, OrderEventAny, OrderExpired, OrderFilled, OrderRejected,
30        OrderTriggered, OrderUpdated,
31    },
32    identifiers::{AccountId, PositionId},
33    instruments::{Instrument, InstrumentAny},
34    orders::{Order, OrderAny, TRIGGERABLE_ORDER_TYPES},
35    reports::{FillReport, OrderStatusReport},
36    types::{Money, Price, Quantity},
37};
38use rust_decimal::Decimal;
39use ustr::Ustr;
40
41use super::{
42    ids::create_inferred_reconciliation_trade_id,
43    positions::{cap_price_at_instrument_max, is_within_single_unit_tolerance},
44};
45
46/// Generates reconciliation events for a live order status report.
47///
48/// Events are produced in venue-temporal order: any missing `Accepted` event
49/// first, then an `OrderUpdated` if the venue snapshot shows a confirmed
50/// quantity/price amendment, then any status/fill events from
51/// [`reconcile_order_report`]. Emitting the amendment before the fill matters
52/// when the venue increased the total quantity and reported a fill that would
53/// otherwise close the order under the stale local quantity. The `Updated`
54/// step is suppressed for pending venue states so a still-unconfirmed amend
55/// cannot mutate the local projection ahead of venue confirmation.
56#[must_use]
57pub fn generate_reconciliation_order_events(
58    order: &OrderAny,
59    report: &OrderStatusReport,
60    instrument: Option<&InstrumentAny>,
61    ts_now: UnixNanos,
62) -> Vec<OrderEventAny> {
63    let mut working = order.clone();
64    let mut events: Vec<OrderEventAny> = Vec::new();
65
66    if should_accept_before_reconciliation(&working, report) {
67        let Some(accepted) = create_reconciliation_accepted(&working, report, ts_now) else {
68            log::warn!(
69                "Cannot create reconciliation acceptance for {}: missing account_id",
70                order.client_order_id(),
71            );
72            return reconcile_order_report(order, report, instrument, ts_now)
73                .into_iter()
74                .collect();
75        };
76
77        if let Err(e) = working.apply(accepted.clone()) {
78            log::warn!(
79                "Failed to pre-apply reconciliation acceptance for {}: {e}",
80                order.client_order_id(),
81            );
82            return reconcile_order_report(order, report, instrument, ts_now)
83                .into_iter()
84                .collect();
85        }
86        events.push(accepted);
87    }
88
89    if report_is_confirmed_state(report)
90        && local_accepts_amendment(&working)
91        && should_reconciliation_update(&working, report)
92    {
93        let updated = create_reconciliation_updated(&working, report, ts_now);
94        if let Err(e) = working.apply(updated.clone()) {
95            log::warn!(
96                "Failed to pre-apply reconciliation update for {}: {e}",
97                order.client_order_id(),
98            );
99        } else {
100            events.push(updated);
101        }
102    }
103
104    if let Some(event) = reconcile_order_report(&working, report, instrument, ts_now) {
105        events.push(event);
106    }
107
108    events
109}
110
111/// Reconciles an order with a venue status report, generating appropriate events.
112///
113/// This is the core reconciliation logic that handles all order status transitions.
114/// For the higher-level wrapper that emits a venue-temporal sequence of events,
115/// use [`generate_reconciliation_order_events`].
116///
117/// Returns `None` for pending venue states (`PendingUpdate`, `PendingCancel`)
118/// regardless of local state, since an unconfirmed amend or cancel must not
119/// drive any local mutation until the venue surfaces a confirmed status.
120///
121/// Returns `None` for a `Canceled` report that references a previously-promoted
122/// `venue_order_id` whose successor is still live in the cache (the cancel-half
123/// of a cancel-replace modify).
124#[must_use]
125pub fn reconcile_order_report(
126    order: &OrderAny,
127    report: &OrderStatusReport,
128    instrument: Option<&InstrumentAny>,
129    ts_now: UnixNanos,
130) -> Option<OrderEventAny> {
131    if matches!(
132        report.order_status,
133        OrderStatus::PendingUpdate | OrderStatus::PendingCancel
134    ) {
135        log::debug!(
136            "Order {} venue report in pending state: {:?}",
137            order.client_order_id(),
138            report.order_status,
139        );
140        return None;
141    }
142
143    if order.status() == report.order_status && order.filled_qty() == report.filled_qty {
144        if should_reconciliation_update(order, report) {
145            log::info!(
146                "Order {} has been updated at venue: qty={}->{}, price={:?}->{:?}",
147                order.client_order_id(),
148                order.quantity(),
149                report.quantity,
150                order.price(),
151                report.price
152            );
153            return Some(create_reconciliation_updated(order, report, ts_now));
154        }
155        return None; // Already in sync
156    }
157
158    match report.order_status {
159        OrderStatus::Accepted => {
160            if order.status() == OrderStatus::Accepted
161                && should_reconciliation_update(order, report)
162            {
163                return Some(create_reconciliation_updated(order, report, ts_now));
164            }
165            create_reconciliation_accepted(order, report, ts_now)
166        }
167        OrderStatus::Rejected => {
168            create_reconciliation_rejected(order, report.cancel_reason.as_deref(), ts_now)
169        }
170        OrderStatus::Triggered => {
171            if TRIGGERABLE_ORDER_TYPES.contains(&order.order_type()) {
172                Some(create_reconciliation_triggered(order, report, ts_now))
173            } else {
174                log::debug!(
175                    "Skipping OrderTriggered for {} order {}: market-style stops have no TRIGGERED state",
176                    order.order_type(),
177                    order.client_order_id(),
178                );
179                None
180            }
181        }
182        OrderStatus::Canceled => {
183            // TODO: Venue cancel-replace handling that belongs in the adapters, not generic
184            // reconciliation. Remove once each cancel-replace adapter suppresses its stale leg
185            // on the query/reconcile path; until then this is the engine's only cover for a
186            // superseded-leg Canceled arriving via inflight query or reconnect snapshot.
187            let report_venue_order_id = report.venue_order_id;
188            if let Some(cached_venue_order_id) = order.venue_order_id()
189                && cached_venue_order_id != report_venue_order_id
190                && order
191                    .venue_order_ids()
192                    .iter()
193                    .any(|v| **v == report_venue_order_id)
194            {
195                log::info!(
196                    "Suppressing Canceled for {} on previously-promoted venue_order_id {}: \
197                     current venue_order_id is {}",
198                    order.client_order_id(),
199                    report_venue_order_id,
200                    cached_venue_order_id,
201                );
202                return None;
203            }
204
205            Some(create_reconciliation_canceled(order, report, ts_now))
206        }
207        OrderStatus::Expired => Some(create_reconciliation_expired(order, report, ts_now)),
208
209        OrderStatus::PartiallyFilled | OrderStatus::Filled => {
210            reconcile_fill_quantity_mismatch(order, report, instrument, ts_now)
211        }
212
213        OrderStatus::PendingUpdate | OrderStatus::PendingCancel => None,
214
215        // Internal states - should not appear in venue reports
216        OrderStatus::Initialized
217        | OrderStatus::Submitted
218        | OrderStatus::Denied
219        | OrderStatus::Emulated
220        | OrderStatus::Released => {
221            log::warn!(
222                "Unexpected order status in venue report for {}: {:?}",
223                order.client_order_id(),
224                report.order_status
225            );
226            None
227        }
228    }
229}
230
231/// Generates the appropriate order events for an external order and order status report.
232///
233/// After creating an external order, we need to transition it to its actual state
234/// based on the order status report from the venue. For terminal states like
235/// Canceled/Expired/Filled, we return multiple events to properly transition
236/// the order through Accepted before reaching the terminal state.
237pub fn generate_external_order_status_events(
238    order: &OrderAny,
239    report: &OrderStatusReport,
240    account_id: &AccountId,
241    instrument: &InstrumentAny,
242    ts_now: UnixNanos,
243) -> Vec<OrderEventAny> {
244    let accepted = OrderEventAny::Accepted(OrderAccepted::new(
245        order.trader_id(),
246        order.strategy_id(),
247        order.instrument_id(),
248        order.client_order_id(),
249        report.venue_order_id,
250        *account_id,
251        UUID4::new(),
252        report.ts_accepted,
253        ts_now,
254        true, // reconciliation
255    ));
256
257    match report.order_status {
258        OrderStatus::Accepted | OrderStatus::Triggered => vec![accepted],
259        OrderStatus::PartiallyFilled | OrderStatus::Filled => {
260            let mut events = vec![accepted];
261
262            if !report.filled_qty.is_zero()
263                && let Some(filled) =
264                    create_inferred_fill(order, report, *account_id, instrument, ts_now, None)
265            {
266                events.push(filled);
267            }
268
269            events
270        }
271        OrderStatus::Canceled => {
272            let canceled = OrderEventAny::Canceled(OrderCanceled::new(
273                order.trader_id(),
274                order.strategy_id(),
275                order.instrument_id(),
276                order.client_order_id(),
277                UUID4::new(),
278                report.ts_last,
279                ts_now,
280                true, // reconciliation
281                Some(report.venue_order_id),
282                Some(*account_id),
283            ));
284            vec![accepted, canceled]
285        }
286        OrderStatus::Expired => {
287            let expired = OrderEventAny::Expired(OrderExpired::new(
288                order.trader_id(),
289                order.strategy_id(),
290                order.instrument_id(),
291                order.client_order_id(),
292                UUID4::new(),
293                report.ts_last,
294                ts_now,
295                true, // reconciliation
296                Some(report.venue_order_id),
297                Some(*account_id),
298            ));
299            vec![accepted, expired]
300        }
301        OrderStatus::Rejected => {
302            // Rejected goes directly to terminal state without acceptance
303            let reason = report.cancel_reason.as_deref().unwrap_or("UNKNOWN");
304            vec![OrderEventAny::Rejected(OrderRejected::new(
305                order.trader_id(),
306                order.strategy_id(),
307                order.instrument_id(),
308                order.client_order_id(),
309                *account_id,
310                Ustr::from(reason),
311                UUID4::new(),
312                report.ts_last,
313                ts_now,
314                true, // reconciliation
315                reason_indicates_post_only_rejection(reason),
316            ))]
317        }
318        _ => {
319            log::warn!(
320                "Unhandled order status {} for external order {}",
321                report.order_status,
322                order.client_order_id()
323            );
324            Vec::new()
325        }
326    }
327}
328
329/// Creates an `OrderFilled` event from a `FillReport`.
330///
331/// This is used during reconciliation when a fill report is received from the venue.
332/// Returns `None` if the fill is a duplicate or would cause an overfill.
333pub fn reconcile_fill_report(
334    order: &OrderAny,
335    report: &FillReport,
336    instrument: &InstrumentAny,
337    ts_now: UnixNanos,
338    allow_overfills: bool,
339) -> Option<OrderEventAny> {
340    debug_assert!(
341        !report.last_qty.is_zero(),
342        "fill report last_qty must be non-zero for {}",
343        order.client_order_id(),
344    );
345
346    if order.trade_ids().iter().any(|id| **id == report.trade_id) {
347        log::debug!(
348            "Duplicate fill detected: trade_id {} already exists for order {}",
349            report.trade_id,
350            order.client_order_id()
351        );
352        return None;
353    }
354
355    let potential_filled_qty = order.filled_qty() + report.last_qty;
356    if potential_filled_qty > order.quantity() {
357        if !allow_overfills {
358            log::warn!(
359                "Rejecting fill that would cause overfill for {}: order.quantity={}, order.filled_qty={}, fill.last_qty={}, would result in filled_qty={}",
360                order.client_order_id(),
361                order.quantity(),
362                order.filled_qty(),
363                report.last_qty,
364                potential_filled_qty
365            );
366            return None;
367        }
368        log::warn!(
369            "Allowing overfill during reconciliation for {}: order.quantity={}, order.filled_qty={}, fill.last_qty={}, will result in filled_qty={}",
370            order.client_order_id(),
371            order.quantity(),
372            order.filled_qty(),
373            report.last_qty,
374            potential_filled_qty
375        );
376    }
377
378    let account_id = report.account_id;
379    let venue_order_id = order.venue_order_id().unwrap_or(report.venue_order_id);
380
381    log::info!(
382        color = LogColor::Blue as u8;
383        "Reconciling fill for {}: qty={}, px={}, trade_id={}",
384        order.client_order_id(),
385        report.last_qty,
386        report.last_px,
387        report.trade_id,
388    );
389
390    Some(OrderEventAny::Filled(OrderFilled::new(
391        order.trader_id(),
392        order.strategy_id(),
393        order.instrument_id(),
394        order.client_order_id(),
395        venue_order_id,
396        account_id,
397        report.trade_id,
398        order.order_side(),
399        order.order_type(),
400        report.last_qty,
401        report.last_px,
402        instrument.quote_currency(),
403        report.liquidity_side,
404        UUID4::new(),
405        report.ts_event,
406        ts_now,
407        true, // reconciliation
408        report.venue_position_id,
409        Some(report.commission),
410    )))
411}
412
413/// Checks if the order should be updated based on quantity, price, or trigger price
414/// differences from the venue report.
415///
416/// A `None` value in `report.price` or `report.trigger_price` is treated as
417/// "venue did not include this field" rather than as drift, so a partial
418/// snapshot (for example, a Filled report that omits price but supplies
419/// `avg_px`) does not trigger a spurious `OrderUpdated`.
420pub fn should_reconciliation_update(order: &OrderAny, report: &OrderStatusReport) -> bool {
421    if report.quantity != order.quantity() && report.quantity >= order.filled_qty() {
422        return true;
423    }
424
425    let price_drift = report.price.is_some() && report.price != order.price();
426    let trigger_drift =
427        report.trigger_price.is_some() && report.trigger_price != order.trigger_price();
428
429    match order.order_type() {
430        OrderType::Limit => price_drift,
431        OrderType::StopMarket | OrderType::TrailingStopMarket | OrderType::MarketIfTouched => {
432            trigger_drift
433        }
434        OrderType::StopLimit | OrderType::TrailingStopLimit | OrderType::LimitIfTouched => {
435            trigger_drift || price_drift
436        }
437        _ => false,
438    }
439}
440
441/// Creates an `OrderAccepted` event for reconciliation.
442#[must_use]
443pub(super) fn create_reconciliation_accepted(
444    order: &OrderAny,
445    report: &OrderStatusReport,
446    ts_now: UnixNanos,
447) -> Option<OrderEventAny> {
448    let account_id = order.account_id()?;
449
450    Some(OrderEventAny::Accepted(OrderAccepted::new(
451        order.trader_id(),
452        order.strategy_id(),
453        order.instrument_id(),
454        order.client_order_id(),
455        order.venue_order_id().unwrap_or(report.venue_order_id),
456        account_id,
457        UUID4::new(),
458        report.ts_accepted,
459        ts_now,
460        true, // reconciliation
461    )))
462}
463
464/// Creates an `OrderRejected` event for reconciliation.
465#[must_use]
466pub fn create_reconciliation_rejected(
467    order: &OrderAny,
468    reason: Option<&str>,
469    ts_now: UnixNanos,
470) -> Option<OrderEventAny> {
471    let account_id = order.account_id()?;
472    let reason = reason.unwrap_or("UNKNOWN");
473
474    Some(OrderEventAny::Rejected(OrderRejected::new(
475        order.trader_id(),
476        order.strategy_id(),
477        order.instrument_id(),
478        order.client_order_id(),
479        account_id,
480        Ustr::from(reason),
481        UUID4::new(),
482        ts_now,
483        ts_now,
484        true, // reconciliation
485        reason_indicates_post_only_rejection(reason),
486    )))
487}
488
489fn reason_indicates_post_only_rejection(reason: &str) -> bool {
490    let normalized: String = reason
491        .chars()
492        .filter_map(|ch| {
493            if ch == '-' || ch == '_' || ch.is_whitespace() {
494                None
495            } else {
496                Some(ch.to_ascii_lowercase())
497            }
498        })
499        .collect();
500
501    normalized.contains("postonly") || normalized.contains("postwouldexecute")
502}
503
504/// Creates an `OrderTriggered` event for reconciliation.
505#[must_use]
506pub fn create_reconciliation_triggered(
507    order: &OrderAny,
508    report: &OrderStatusReport,
509    ts_now: UnixNanos,
510) -> OrderEventAny {
511    OrderEventAny::Triggered(OrderTriggered::new(
512        order.trader_id(),
513        order.strategy_id(),
514        order.instrument_id(),
515        order.client_order_id(),
516        UUID4::new(),
517        report.ts_triggered.unwrap_or(ts_now),
518        ts_now,
519        true, // reconciliation
520        order.venue_order_id(),
521        order.account_id(),
522    ))
523}
524
525/// Creates an `OrderCanceled` event for reconciliation.
526#[must_use]
527pub(super) fn create_reconciliation_canceled(
528    order: &OrderAny,
529    report: &OrderStatusReport,
530    ts_now: UnixNanos,
531) -> OrderEventAny {
532    OrderEventAny::Canceled(OrderCanceled::new(
533        order.trader_id(),
534        order.strategy_id(),
535        order.instrument_id(),
536        order.client_order_id(),
537        UUID4::new(),
538        report.ts_last,
539        ts_now,
540        true, // reconciliation
541        order.venue_order_id(),
542        order.account_id(),
543    ))
544}
545
546/// Creates an `OrderExpired` event for reconciliation.
547#[must_use]
548pub(super) fn create_reconciliation_expired(
549    order: &OrderAny,
550    report: &OrderStatusReport,
551    ts_now: UnixNanos,
552) -> OrderEventAny {
553    OrderEventAny::Expired(OrderExpired::new(
554        order.trader_id(),
555        order.strategy_id(),
556        order.instrument_id(),
557        order.client_order_id(),
558        UUID4::new(),
559        report.ts_last,
560        ts_now,
561        true, // reconciliation
562        order.venue_order_id(),
563        order.account_id(),
564    ))
565}
566
567/// Creates an `OrderUpdated` event for reconciliation.
568#[must_use]
569pub(super) fn create_reconciliation_updated(
570    order: &OrderAny,
571    report: &OrderStatusReport,
572    ts_now: UnixNanos,
573) -> OrderEventAny {
574    // Only pass trigger_price for order types that support it.
575    // Limit, Market, and MarketToLimit orders assert trigger_price.is_none()
576    // in their update() methods — passing a spurious trigger_price from the
577    // venue report (e.g. Bybit sends "0.00" for non-conditional orders)
578    // causes a panic. Positive list ensures new order types without
579    // trigger_price support won't accidentally receive one.
580    let trigger_price = match order.order_type() {
581        OrderType::StopMarket
582        | OrderType::StopLimit
583        | OrderType::MarketIfTouched
584        | OrderType::LimitIfTouched
585        | OrderType::TrailingStopMarket
586        | OrderType::TrailingStopLimit => report.trigger_price,
587        _ => None,
588    };
589
590    OrderEventAny::Updated(OrderUpdated::new(
591        order.trader_id(),
592        order.strategy_id(),
593        order.instrument_id(),
594        order.client_order_id(),
595        report.quantity,
596        UUID4::new(),
597        report.ts_last,
598        ts_now,
599        true, // reconciliation
600        order.venue_order_id(),
601        order.account_id(),
602        report.price,
603        trigger_price,
604        None, // protection_price
605        order.is_quote_quantity(),
606    ))
607}
608
609/// Creates an inferred fill event for reconciliation when fill reports are missing.
610pub(super) fn create_inferred_fill(
611    order: &OrderAny,
612    report: &OrderStatusReport,
613    account_id: AccountId,
614    instrument: &InstrumentAny,
615    ts_now: UnixNanos,
616    commission: Option<Money>,
617) -> Option<OrderEventAny> {
618    let liquidity_side = match order.order_type() {
619        OrderType::Market | OrderType::StopMarket | OrderType::TrailingStopMarket => {
620            LiquiditySide::Taker
621        }
622        _ if report.post_only => LiquiditySide::Maker,
623        _ => LiquiditySide::NoLiquiditySide,
624    };
625
626    let last_px = if let Some(avg_px) = report.avg_px {
627        match Price::from_decimal_dp(avg_px, instrument.price_precision()) {
628            Ok(px) => px,
629            Err(e) => {
630                log::warn!("Failed to create price from avg_px for inferred fill: {e}");
631                return None;
632            }
633        }
634    } else if let Some(price) = report.price {
635        price
636    } else {
637        log::warn!(
638            "Cannot create inferred fill for {}: no avg_px or price available",
639            order.client_order_id()
640        );
641        return None;
642    };
643    let last_px = clamp_inferred_fill_price(last_px, instrument);
644
645    let position_id = reconciliation_position_id(report, instrument);
646    let trade_id = create_inferred_reconciliation_trade_id(
647        account_id,
648        order.instrument_id(),
649        order.client_order_id(),
650        Some(report.venue_order_id),
651        report.order_side,
652        order.order_type(),
653        report.filled_qty,
654        report.filled_qty,
655        last_px,
656        position_id,
657        report.ts_last,
658    );
659
660    log::info!(
661        "Generated inferred fill for {} ({}) qty={} px={}",
662        order.client_order_id(),
663        report.venue_order_id,
664        report.filled_qty,
665        last_px,
666    );
667
668    Some(OrderEventAny::Filled(OrderFilled::new(
669        order.trader_id(),
670        order.strategy_id(),
671        order.instrument_id(),
672        order.client_order_id(),
673        report.venue_order_id,
674        account_id,
675        trade_id,
676        report.order_side,
677        order.order_type(),
678        report.filled_qty,
679        last_px,
680        instrument.quote_currency(),
681        liquidity_side,
682        UUID4::new(),
683        report.ts_last,
684        ts_now,
685        true, // reconciliation
686        report.venue_position_id,
687        commission,
688    )))
689}
690
691/// Creates an inferred fill for the quantity difference between order and report.
692pub fn create_incremental_inferred_fill(
693    order: &OrderAny,
694    report: &OrderStatusReport,
695    account_id: &AccountId,
696    instrument: &InstrumentAny,
697    ts_now: UnixNanos,
698    commission: Option<Money>,
699) -> Option<OrderEventAny> {
700    let order_filled_qty = order.filled_qty();
701    debug_assert!(
702        report.filled_qty >= order_filled_qty,
703        "incremental inferred fill requires report.filled_qty ({}) >= order.filled_qty ({}) for {}",
704        report.filled_qty,
705        order_filled_qty,
706        order.client_order_id(),
707    );
708    let last_qty = report.filled_qty - order_filled_qty;
709
710    if last_qty <= Quantity::zero(instrument.size_precision()) {
711        return None;
712    }
713
714    let liquidity_side = match order.order_type() {
715        OrderType::Market
716        | OrderType::StopMarket
717        | OrderType::MarketToLimit
718        | OrderType::TrailingStopMarket => LiquiditySide::Taker,
719        _ if order.is_post_only() => LiquiditySide::Maker,
720        _ => LiquiditySide::NoLiquiditySide,
721    };
722
723    let last_px = calculate_incremental_fill_price(order, report, instrument)?;
724    let last_px = clamp_inferred_fill_price(last_px, instrument);
725
726    let venue_order_id = order.venue_order_id().unwrap_or(report.venue_order_id);
727    let position_id = reconciliation_position_id(report, instrument);
728    let trade_id = create_inferred_reconciliation_trade_id(
729        *account_id,
730        order.instrument_id(),
731        order.client_order_id(),
732        Some(venue_order_id),
733        order.order_side(),
734        order.order_type(),
735        report.filled_qty,
736        last_qty,
737        last_px,
738        position_id,
739        report.ts_last,
740    );
741
742    log::info!(
743        color = LogColor::Blue as u8;
744        "Generated inferred fill for {}: qty={}, px={}",
745        order.client_order_id(),
746        last_qty,
747        last_px,
748    );
749
750    Some(OrderEventAny::Filled(OrderFilled::new(
751        order.trader_id(),
752        order.strategy_id(),
753        order.instrument_id(),
754        order.client_order_id(),
755        venue_order_id,
756        *account_id,
757        trade_id,
758        order.order_side(),
759        order.order_type(),
760        last_qty,
761        last_px,
762        instrument.quote_currency(),
763        liquidity_side,
764        UUID4::new(),
765        report.ts_last,
766        ts_now,
767        true, // reconciliation
768        None, // venue_position_id
769        commission,
770    )))
771}
772
773/// Creates an inferred fill with a specific quantity.
774///
775/// Unlike `create_incremental_inferred_fill`, this takes the fill quantity directly
776/// rather than calculating it from order state. Useful when order state hasn't been
777/// updated yet (e.g., during external order processing).
778pub fn create_inferred_fill_for_qty(
779    order: &OrderAny,
780    report: &OrderStatusReport,
781    account_id: &AccountId,
782    instrument: &InstrumentAny,
783    fill_qty: Quantity,
784    ts_now: UnixNanos,
785    commission: Option<Money>,
786) -> Option<OrderEventAny> {
787    if fill_qty.is_zero() {
788        return None;
789    }
790
791    let liquidity_side = match order.order_type() {
792        OrderType::Market
793        | OrderType::StopMarket
794        | OrderType::MarketToLimit
795        | OrderType::TrailingStopMarket => LiquiditySide::Taker,
796        _ if order.is_post_only() => LiquiditySide::Maker,
797        _ => LiquiditySide::NoLiquiditySide,
798    };
799
800    let last_px = if let Some(avg_px) = report.avg_px {
801        Price::from_decimal_dp(avg_px, instrument.price_precision()).ok()?
802    } else if let Some(price) = report.price {
803        price
804    } else if let Some(price) = order.price() {
805        price
806    } else {
807        log::warn!(
808            "Cannot determine fill price for {}: no avg_px or price available",
809            order.client_order_id()
810        );
811        return None;
812    };
813    let last_px = clamp_inferred_fill_price(last_px, instrument);
814
815    let venue_order_id = order.venue_order_id().unwrap_or(report.venue_order_id);
816    let position_id = reconciliation_position_id(report, instrument);
817    let trade_id = create_inferred_reconciliation_trade_id(
818        *account_id,
819        order.instrument_id(),
820        order.client_order_id(),
821        Some(venue_order_id),
822        order.order_side(),
823        order.order_type(),
824        report.filled_qty,
825        fill_qty,
826        last_px,
827        position_id,
828        report.ts_last,
829    );
830
831    log::info!(
832        color = LogColor::Blue as u8;
833        "Generated inferred fill for {}: qty={}, px={}",
834        order.client_order_id(),
835        fill_qty,
836        last_px,
837    );
838
839    Some(OrderEventAny::Filled(OrderFilled::new(
840        order.trader_id(),
841        order.strategy_id(),
842        order.instrument_id(),
843        order.client_order_id(),
844        venue_order_id,
845        *account_id,
846        trade_id,
847        order.order_side(),
848        order.order_type(),
849        fill_qty,
850        last_px,
851        instrument.quote_currency(),
852        liquidity_side,
853        UUID4::new(),
854        report.ts_last,
855        ts_now,
856        true, // reconciliation
857        None, // venue_position_id
858        commission,
859    )))
860}
861
862fn report_is_confirmed_state(report: &OrderStatusReport) -> bool {
863    matches!(
864        report.order_status,
865        OrderStatus::Accepted
866            | OrderStatus::Triggered
867            | OrderStatus::PartiallyFilled
868            | OrderStatus::Filled
869    )
870}
871
872fn local_accepts_amendment(order: &OrderAny) -> bool {
873    matches!(
874        order.status(),
875        OrderStatus::Accepted | OrderStatus::Triggered | OrderStatus::PartiallyFilled
876    )
877}
878
879fn should_accept_before_reconciliation(order: &OrderAny, report: &OrderStatusReport) -> bool {
880    order.status() == OrderStatus::Submitted && report.order_status != OrderStatus::Rejected
881}
882
883/// Handles fill quantity mismatch between cached order and venue report.
884///
885/// Returns an inferred fill event if the venue reports more filled quantity than we have.
886fn reconcile_fill_quantity_mismatch(
887    order: &OrderAny,
888    report: &OrderStatusReport,
889    instrument: Option<&InstrumentAny>,
890    ts_now: UnixNanos,
891) -> Option<OrderEventAny> {
892    let order_filled_qty = order.filled_qty();
893    let report_filled_qty = report.filled_qty;
894
895    if report_filled_qty < order_filled_qty {
896        // Venue cumulative below cached: apply no event so cached state is
897        // preserved. Suppress sub-unit gaps as precision noise.
898        let precision = order_filled_qty.precision.max(report_filled_qty.precision);
899        if is_within_single_unit_tolerance(
900            report_filled_qty.as_decimal(),
901            order_filled_qty.as_decimal(),
902            precision,
903        ) {
904            return None;
905        }
906
907        log::warn!(
908            "Fill qty mismatch for {} ({}): cached={}, venue={}, order_qty={} (venue < cached)",
909            order.client_order_id(),
910            report.venue_order_id,
911            order_filled_qty,
912            report_filled_qty,
913            order.quantity(),
914        );
915        return None;
916    }
917
918    if report_filled_qty > order_filled_qty {
919        // Check if order is already closed - skip inferred fill to avoid invalid state
920        // (matching Python behavior in _handle_fill_quantity_mismatch)
921        if order.is_closed() {
922            let precision = order_filled_qty.precision.max(report_filled_qty.precision);
923
924            if is_within_single_unit_tolerance(
925                report_filled_qty.as_decimal(),
926                order_filled_qty.as_decimal(),
927                precision,
928            ) {
929                return None;
930            }
931
932            log::debug!(
933                "{} {} already closed but reported difference in filled_qty: \
934                report={}, cached={}, skipping inferred fill generation for closed order",
935                order.instrument_id(),
936                order.client_order_id(),
937                report_filled_qty,
938                order_filled_qty,
939            );
940            return None;
941        }
942
943        // Venue has more fills - generate inferred fill for the difference
944        let Some(instrument) = instrument else {
945            log::warn!(
946                "Cannot generate inferred fill for {}: instrument not available",
947                order.client_order_id()
948            );
949            return None;
950        };
951
952        let account_id = order.account_id()?;
953        return create_incremental_inferred_fill(
954            order,
955            report,
956            &account_id,
957            instrument,
958            ts_now,
959            None,
960        );
961    }
962
963    // Quantities match but status differs: if the venue reduced the order
964    // quantity (e.g. partial cancel leaving filled_qty==quantity), emit
965    // OrderUpdated so the local state machine can transition; do not
966    // synthesize a fill since filled_qty already matches.
967    if order.status() != report.order_status {
968        if should_reconciliation_update(order, report) {
969            log::info!(
970                "Status mismatch with matching fill qty for {}: local={:?}, venue={:?}, \
971                 filled_qty={}, updating quantity {}->{}",
972                order.client_order_id(),
973                order.status(),
974                report.order_status,
975                report.filled_qty,
976                order.quantity(),
977                report.quantity,
978            );
979            return Some(create_reconciliation_updated(order, report, ts_now));
980        }
981
982        log::warn!(
983            "Status mismatch with matching fill qty for {}: local={:?}, venue={:?}, filled_qty={}",
984            order.client_order_id(),
985            order.status(),
986            report.order_status,
987            report.filled_qty
988        );
989    }
990
991    None
992}
993
994/// Calculates the fill price for an incremental inferred fill.
995fn calculate_incremental_fill_price(
996    order: &OrderAny,
997    report: &OrderStatusReport,
998    instrument: &InstrumentAny,
999) -> Option<Price> {
1000    let order_filled_qty = order.filled_qty();
1001    debug_assert!(
1002        report.filled_qty >= order_filled_qty,
1003        "incremental fill price requires report.filled_qty ({}) >= order.filled_qty ({}) for {}",
1004        report.filled_qty,
1005        order_filled_qty,
1006        order.client_order_id(),
1007    );
1008
1009    // First fill - use avg_px from report or order price
1010    if order_filled_qty.is_zero() {
1011        if let Some(avg_px) = report.avg_px {
1012            return Price::from_decimal_dp(avg_px, instrument.price_precision()).ok();
1013        }
1014
1015        if let Some(price) = report.price {
1016            return Some(price);
1017        }
1018
1019        if let Some(price) = order.price() {
1020            return Some(price);
1021        }
1022        log::warn!(
1023            "Cannot determine fill price for {}: no avg_px, report price, or order price",
1024            order.client_order_id()
1025        );
1026        return None;
1027    }
1028
1029    // Incremental fill - calculate price using weighted average
1030    if let Some(report_avg_px) = report.avg_px {
1031        let Some(order_avg_px) = order.avg_px() else {
1032            // No previous avg_px, use report avg_px
1033            return Price::from_decimal_dp(report_avg_px, instrument.price_precision()).ok();
1034        };
1035        let report_filled_qty = report.filled_qty;
1036        let last_qty = report_filled_qty - order_filled_qty;
1037
1038        let report_notional = report_avg_px * report_filled_qty.as_decimal();
1039        let order_notional = Decimal::from_str(&order_avg_px.to_string()).unwrap_or_default()
1040            * order_filled_qty.as_decimal();
1041        let last_notional = report_notional - order_notional;
1042        let last_px_decimal = last_notional / last_qty.as_decimal();
1043
1044        return Price::from_decimal_dp(last_px_decimal, instrument.price_precision()).ok();
1045    }
1046
1047    // Fallback to report price or order price
1048    if let Some(price) = report.price {
1049        return Some(price);
1050    }
1051
1052    order.price()
1053}
1054
1055/// Caps an inferred fill price at the instrument's maximum price.
1056///
1057/// Thin `Price` wrapper over [`cap_price_at_instrument_max`]; see that
1058/// function for why reconciliation needs to bound synthetic fill prices.
1059fn clamp_inferred_fill_price(price: Price, instrument: &InstrumentAny) -> Price {
1060    let px = cap_price_at_instrument_max(price.as_decimal(), instrument);
1061    Price::from_decimal_dp(px, instrument.price_precision()).unwrap_or(price)
1062}
1063
1064fn reconciliation_position_id(
1065    report: &OrderStatusReport,
1066    instrument: &InstrumentAny,
1067) -> PositionId {
1068    report
1069        .venue_position_id
1070        .unwrap_or_else(|| PositionId::new(format!("{}-EXTERNAL", instrument.id())))
1071}