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, positions::is_within_single_unit_tolerance,
43};
44
45fn reconciliation_position_id(
46    report: &OrderStatusReport,
47    instrument: &InstrumentAny,
48) -> PositionId {
49    report
50        .venue_position_id
51        .unwrap_or_else(|| PositionId::new(format!("{}-EXTERNAL", instrument.id())))
52}
53
54pub fn generate_external_order_status_events(
55    order: &OrderAny,
56    report: &OrderStatusReport,
57    account_id: &AccountId,
58    instrument: &InstrumentAny,
59    ts_now: UnixNanos,
60) -> Vec<OrderEventAny> {
61    let accepted = OrderEventAny::Accepted(OrderAccepted::new(
62        order.trader_id(),
63        order.strategy_id(),
64        order.instrument_id(),
65        order.client_order_id(),
66        report.venue_order_id,
67        *account_id,
68        UUID4::new(),
69        report.ts_accepted,
70        ts_now,
71        true, // reconciliation
72    ));
73
74    match report.order_status {
75        OrderStatus::Accepted | OrderStatus::Triggered => vec![accepted],
76        OrderStatus::PartiallyFilled | OrderStatus::Filled => {
77            let mut events = vec![accepted];
78
79            if !report.filled_qty.is_zero()
80                && let Some(filled) =
81                    create_inferred_fill(order, report, account_id, instrument, ts_now, None)
82            {
83                events.push(filled);
84            }
85
86            events
87        }
88        OrderStatus::Canceled => {
89            let canceled = OrderEventAny::Canceled(OrderCanceled::new(
90                order.trader_id(),
91                order.strategy_id(),
92                order.instrument_id(),
93                order.client_order_id(),
94                UUID4::new(),
95                report.ts_last,
96                ts_now,
97                true, // reconciliation
98                Some(report.venue_order_id),
99                Some(*account_id),
100            ));
101            vec![accepted, canceled]
102        }
103        OrderStatus::Expired => {
104            let expired = OrderEventAny::Expired(OrderExpired::new(
105                order.trader_id(),
106                order.strategy_id(),
107                order.instrument_id(),
108                order.client_order_id(),
109                UUID4::new(),
110                report.ts_last,
111                ts_now,
112                true, // reconciliation
113                Some(report.venue_order_id),
114                Some(*account_id),
115            ));
116            vec![accepted, expired]
117        }
118        OrderStatus::Rejected => {
119            // Rejected goes directly to terminal state without acceptance
120            vec![OrderEventAny::Rejected(OrderRejected::new(
121                order.trader_id(),
122                order.strategy_id(),
123                order.instrument_id(),
124                order.client_order_id(),
125                *account_id,
126                Ustr::from(report.cancel_reason.as_deref().unwrap_or("UNKNOWN")),
127                UUID4::new(),
128                report.ts_last,
129                ts_now,
130                true, // reconciliation
131                false,
132            ))]
133        }
134        _ => {
135            log::warn!(
136                "Unhandled order status {} for external order {}",
137                report.order_status,
138                order.client_order_id()
139            );
140            Vec::new()
141        }
142    }
143}
144
145/// Creates an inferred fill event for reconciliation when fill reports are missing.
146pub fn create_inferred_fill(
147    order: &OrderAny,
148    report: &OrderStatusReport,
149    account_id: &AccountId,
150    instrument: &InstrumentAny,
151    ts_now: UnixNanos,
152    commission: Option<Money>,
153) -> Option<OrderEventAny> {
154    let liquidity_side = match order.order_type() {
155        OrderType::Market | OrderType::StopMarket | OrderType::TrailingStopMarket => {
156            LiquiditySide::Taker
157        }
158        _ if report.post_only => LiquiditySide::Maker,
159        _ => LiquiditySide::NoLiquiditySide,
160    };
161
162    let last_px = if let Some(avg_px) = report.avg_px {
163        match Price::from_decimal_dp(avg_px, instrument.price_precision()) {
164            Ok(px) => px,
165            Err(e) => {
166                log::warn!("Failed to create price from avg_px for inferred fill: {e}");
167                return None;
168            }
169        }
170    } else if let Some(price) = report.price {
171        price
172    } else {
173        log::warn!(
174            "Cannot create inferred fill for {}: no avg_px or price available",
175            order.client_order_id()
176        );
177        return None;
178    };
179
180    let position_id = reconciliation_position_id(report, instrument);
181    let trade_id = create_inferred_reconciliation_trade_id(
182        *account_id,
183        order.instrument_id(),
184        order.client_order_id(),
185        Some(report.venue_order_id),
186        report.order_side,
187        order.order_type(),
188        report.filled_qty,
189        report.filled_qty,
190        last_px,
191        position_id,
192        report.ts_last,
193    );
194
195    log::info!(
196        "Generated inferred fill for {} ({}) qty={} px={}",
197        order.client_order_id(),
198        report.venue_order_id,
199        report.filled_qty,
200        last_px,
201    );
202
203    Some(OrderEventAny::Filled(OrderFilled::new(
204        order.trader_id(),
205        order.strategy_id(),
206        order.instrument_id(),
207        order.client_order_id(),
208        report.venue_order_id,
209        *account_id,
210        trade_id,
211        report.order_side,
212        order.order_type(),
213        report.filled_qty,
214        last_px,
215        instrument.quote_currency(),
216        liquidity_side,
217        UUID4::new(),
218        report.ts_last,
219        ts_now,
220        true, // reconciliation
221        report.venue_position_id,
222        commission,
223    )))
224}
225
226/// Creates an OrderAccepted event for reconciliation.
227///
228/// # Panics
229///
230/// Panics if the order does not have an `account_id` set.
231#[must_use]
232pub fn create_reconciliation_accepted(
233    order: &OrderAny,
234    report: &OrderStatusReport,
235    ts_now: UnixNanos,
236) -> OrderEventAny {
237    OrderEventAny::Accepted(OrderAccepted::new(
238        order.trader_id(),
239        order.strategy_id(),
240        order.instrument_id(),
241        order.client_order_id(),
242        order.venue_order_id().unwrap_or(report.venue_order_id),
243        order
244            .account_id()
245            .expect("Order should have account_id for reconciliation"),
246        UUID4::new(),
247        report.ts_accepted,
248        ts_now,
249        true, // reconciliation
250    ))
251}
252
253/// Creates an OrderRejected event for reconciliation.
254#[must_use]
255pub fn create_reconciliation_rejected(
256    order: &OrderAny,
257    reason: Option<&str>,
258    ts_now: UnixNanos,
259) -> Option<OrderEventAny> {
260    let account_id = order.account_id()?;
261    let reason = reason.unwrap_or("UNKNOWN");
262
263    Some(OrderEventAny::Rejected(OrderRejected::new(
264        order.trader_id(),
265        order.strategy_id(),
266        order.instrument_id(),
267        order.client_order_id(),
268        account_id,
269        Ustr::from(reason),
270        UUID4::new(),
271        ts_now,
272        ts_now,
273        true,  // reconciliation
274        false, // due_post_only
275    )))
276}
277
278/// Creates an OrderTriggered event for reconciliation.
279#[must_use]
280pub fn create_reconciliation_triggered(
281    order: &OrderAny,
282    report: &OrderStatusReport,
283    ts_now: UnixNanos,
284) -> OrderEventAny {
285    OrderEventAny::Triggered(OrderTriggered::new(
286        order.trader_id(),
287        order.strategy_id(),
288        order.instrument_id(),
289        order.client_order_id(),
290        UUID4::new(),
291        report.ts_triggered.unwrap_or(ts_now),
292        ts_now,
293        true, // reconciliation
294        order.venue_order_id(),
295        order.account_id(),
296    ))
297}
298
299/// Creates an OrderCanceled event for reconciliation.
300#[must_use]
301pub fn create_reconciliation_canceled(
302    order: &OrderAny,
303    report: &OrderStatusReport,
304    ts_now: UnixNanos,
305) -> OrderEventAny {
306    OrderEventAny::Canceled(OrderCanceled::new(
307        order.trader_id(),
308        order.strategy_id(),
309        order.instrument_id(),
310        order.client_order_id(),
311        UUID4::new(),
312        report.ts_last,
313        ts_now,
314        true, // reconciliation
315        order.venue_order_id(),
316        order.account_id(),
317    ))
318}
319
320/// Creates an OrderExpired event for reconciliation.
321#[must_use]
322pub fn create_reconciliation_expired(
323    order: &OrderAny,
324    report: &OrderStatusReport,
325    ts_now: UnixNanos,
326) -> OrderEventAny {
327    OrderEventAny::Expired(OrderExpired::new(
328        order.trader_id(),
329        order.strategy_id(),
330        order.instrument_id(),
331        order.client_order_id(),
332        UUID4::new(),
333        report.ts_last,
334        ts_now,
335        true, // reconciliation
336        order.venue_order_id(),
337        order.account_id(),
338    ))
339}
340
341/// Creates an OrderUpdated event for reconciliation.
342#[must_use]
343pub fn create_reconciliation_updated(
344    order: &OrderAny,
345    report: &OrderStatusReport,
346    ts_now: UnixNanos,
347) -> OrderEventAny {
348    // Only pass trigger_price for order types that support it.
349    // Limit, Market, and MarketToLimit orders assert trigger_price.is_none()
350    // in their update() methods — passing a spurious trigger_price from the
351    // venue report (e.g. Bybit sends "0.00" for non-conditional orders)
352    // causes a panic. Positive list ensures new order types without
353    // trigger_price support won't accidentally receive one.
354    let trigger_price = match order.order_type() {
355        OrderType::StopMarket
356        | OrderType::StopLimit
357        | OrderType::MarketIfTouched
358        | OrderType::LimitIfTouched
359        | OrderType::TrailingStopMarket
360        | OrderType::TrailingStopLimit => report.trigger_price,
361        _ => None,
362    };
363
364    OrderEventAny::Updated(OrderUpdated::new(
365        order.trader_id(),
366        order.strategy_id(),
367        order.instrument_id(),
368        order.client_order_id(),
369        report.quantity,
370        UUID4::new(),
371        report.ts_last,
372        ts_now,
373        true, // reconciliation
374        order.venue_order_id(),
375        order.account_id(),
376        report.price,
377        trigger_price,
378        None, // protection_price
379        order.is_quote_quantity(),
380    ))
381}
382
383/// Checks if the order should be updated based on quantity, price, or trigger price
384/// differences from the venue report.
385pub fn should_reconciliation_update(order: &OrderAny, report: &OrderStatusReport) -> bool {
386    // Quantity change only valid if new qty >= filled qty
387    if report.quantity != order.quantity() && report.quantity >= order.filled_qty() {
388        return true;
389    }
390
391    match order.order_type() {
392        OrderType::Limit => report.price != order.price(),
393        OrderType::StopMarket | OrderType::TrailingStopMarket => {
394            report.trigger_price != order.trigger_price()
395        }
396        OrderType::StopLimit | OrderType::TrailingStopLimit => {
397            report.trigger_price != order.trigger_price() || report.price != order.price()
398        }
399        _ => false,
400    }
401}
402
403/// Reconciles an order with a venue status report, generating appropriate events.
404///
405/// This is the core reconciliation logic that handles all order status transitions.
406/// For fill reconciliation with inferred fills, use `reconcile_order_with_fills`.
407#[must_use]
408pub fn reconcile_order_report(
409    order: &OrderAny,
410    report: &OrderStatusReport,
411    instrument: Option<&InstrumentAny>,
412    ts_now: UnixNanos,
413) -> Option<OrderEventAny> {
414    if order.status() == report.order_status && order.filled_qty() == report.filled_qty {
415        if should_reconciliation_update(order, report) {
416            log::info!(
417                "Order {} has been updated at venue: qty={}->{}, price={:?}->{:?}",
418                order.client_order_id(),
419                order.quantity(),
420                report.quantity,
421                order.price(),
422                report.price
423            );
424            return Some(create_reconciliation_updated(order, report, ts_now));
425        }
426        return None; // Already in sync
427    }
428
429    match report.order_status {
430        OrderStatus::Accepted => {
431            if order.status() == OrderStatus::Accepted
432                && should_reconciliation_update(order, report)
433            {
434                return Some(create_reconciliation_updated(order, report, ts_now));
435            }
436            Some(create_reconciliation_accepted(order, report, ts_now))
437        }
438        OrderStatus::Rejected => {
439            create_reconciliation_rejected(order, report.cancel_reason.as_deref(), ts_now)
440        }
441        OrderStatus::Triggered => {
442            if TRIGGERABLE_ORDER_TYPES.contains(&order.order_type()) {
443                Some(create_reconciliation_triggered(order, report, ts_now))
444            } else {
445                log::debug!(
446                    "Skipping OrderTriggered for {} order {}: market-style stops have no TRIGGERED state",
447                    order.order_type(),
448                    order.client_order_id(),
449                );
450                None
451            }
452        }
453        OrderStatus::Canceled => Some(create_reconciliation_canceled(order, report, ts_now)),
454        OrderStatus::Expired => Some(create_reconciliation_expired(order, report, ts_now)),
455
456        OrderStatus::PartiallyFilled | OrderStatus::Filled => {
457            reconcile_fill_quantity_mismatch(order, report, instrument, ts_now)
458        }
459
460        // Pending states - venue will confirm, just log
461        OrderStatus::PendingUpdate | OrderStatus::PendingCancel => {
462            log::debug!(
463                "Order {} in pending state: {:?}",
464                order.client_order_id(),
465                report.order_status
466            );
467            None
468        }
469
470        // Internal states - should not appear in venue reports
471        OrderStatus::Initialized
472        | OrderStatus::Submitted
473        | OrderStatus::Denied
474        | OrderStatus::Emulated
475        | OrderStatus::Released => {
476            log::warn!(
477                "Unexpected order status in venue report for {}: {:?}",
478                order.client_order_id(),
479                report.order_status
480            );
481            None
482        }
483    }
484}
485
486/// Generates reconciliation events for a live order status report.
487///
488/// If a venue report advances a locally submitted order beyond `Submitted`,
489/// this synthesizes the missing `Accepted` event first so downstream order
490/// state transitions stay valid.
491#[must_use]
492pub fn generate_reconciliation_order_events(
493    order: &OrderAny,
494    report: &OrderStatusReport,
495    instrument: Option<&InstrumentAny>,
496    ts_now: UnixNanos,
497) -> Vec<OrderEventAny> {
498    if should_accept_before_reconciliation(order, report) {
499        let accepted = create_reconciliation_accepted(order, report, ts_now);
500        let mut accepted_order = order.clone();
501
502        if let Err(e) = accepted_order.apply(accepted.clone()) {
503            log::warn!(
504                "Failed to pre-apply reconciliation acceptance for {}: {e}",
505                order.client_order_id(),
506            );
507            return reconcile_order_report(order, report, instrument, ts_now)
508                .into_iter()
509                .collect();
510        }
511
512        let mut events = vec![accepted];
513
514        if let Some(event) = reconcile_order_report(&accepted_order, report, instrument, ts_now) {
515            events.push(event);
516        }
517        return events;
518    }
519
520    reconcile_order_report(order, report, instrument, ts_now)
521        .into_iter()
522        .collect()
523}
524
525fn should_accept_before_reconciliation(order: &OrderAny, report: &OrderStatusReport) -> bool {
526    order.status() == OrderStatus::Submitted && report.order_status != OrderStatus::Rejected
527}
528
529/// Handles fill quantity mismatch between cached order and venue report.
530///
531/// Returns an inferred fill event if the venue reports more filled quantity than we have.
532fn reconcile_fill_quantity_mismatch(
533    order: &OrderAny,
534    report: &OrderStatusReport,
535    instrument: Option<&InstrumentAny>,
536    ts_now: UnixNanos,
537) -> Option<OrderEventAny> {
538    let order_filled_qty = order.filled_qty();
539    let report_filled_qty = report.filled_qty;
540
541    if report_filled_qty < order_filled_qty {
542        // Venue reports less filled than we have - potential state corruption
543        log::error!(
544            "Fill qty mismatch for {}: cached={}, venue={} (venue < cached)",
545            order.client_order_id(),
546            order_filled_qty,
547            report_filled_qty
548        );
549        return None;
550    }
551
552    if report_filled_qty > order_filled_qty {
553        // Check if order is already closed - skip inferred fill to avoid invalid state
554        // (matching Python behavior in _handle_fill_quantity_mismatch)
555        if order.is_closed() {
556            let precision = order_filled_qty.precision.max(report_filled_qty.precision);
557
558            if is_within_single_unit_tolerance(
559                report_filled_qty.as_decimal(),
560                order_filled_qty.as_decimal(),
561                precision,
562            ) {
563                return None;
564            }
565
566            log::debug!(
567                "{} {} already closed but reported difference in filled_qty: \
568                report={}, cached={}, skipping inferred fill generation for closed order",
569                order.instrument_id(),
570                order.client_order_id(),
571                report_filled_qty,
572                order_filled_qty,
573            );
574            return None;
575        }
576
577        // Venue has more fills - generate inferred fill for the difference
578        let Some(instrument) = instrument else {
579            log::warn!(
580                "Cannot generate inferred fill for {}: instrument not available",
581                order.client_order_id()
582            );
583            return None;
584        };
585
586        let account_id = order.account_id()?;
587        return create_incremental_inferred_fill(
588            order,
589            report,
590            &account_id,
591            instrument,
592            ts_now,
593            None,
594        );
595    }
596
597    // Quantities match but status differs: if the venue reduced the order
598    // quantity (e.g. partial cancel leaving filled_qty==quantity), emit
599    // OrderUpdated so the local state machine can transition; do not
600    // synthesize a fill since filled_qty already matches.
601    if order.status() != report.order_status {
602        if should_reconciliation_update(order, report) {
603            log::info!(
604                "Status mismatch with matching fill qty for {}: local={:?}, venue={:?}, \
605                 filled_qty={}, updating quantity {}->{}",
606                order.client_order_id(),
607                order.status(),
608                report.order_status,
609                report.filled_qty,
610                order.quantity(),
611                report.quantity,
612            );
613            return Some(create_reconciliation_updated(order, report, ts_now));
614        }
615
616        log::warn!(
617            "Status mismatch with matching fill qty for {}: local={:?}, venue={:?}, filled_qty={}",
618            order.client_order_id(),
619            order.status(),
620            report.order_status,
621            report.filled_qty
622        );
623    }
624
625    None
626}
627
628/// Creates an inferred fill for the quantity difference between order and report.
629pub fn create_incremental_inferred_fill(
630    order: &OrderAny,
631    report: &OrderStatusReport,
632    account_id: &AccountId,
633    instrument: &InstrumentAny,
634    ts_now: UnixNanos,
635    commission: Option<Money>,
636) -> Option<OrderEventAny> {
637    let order_filled_qty = order.filled_qty();
638    debug_assert!(
639        report.filled_qty >= order_filled_qty,
640        "incremental inferred fill requires report.filled_qty ({}) >= order.filled_qty ({}) for {}",
641        report.filled_qty,
642        order_filled_qty,
643        order.client_order_id(),
644    );
645    let last_qty = report.filled_qty - order_filled_qty;
646
647    if last_qty <= Quantity::zero(instrument.size_precision()) {
648        return None;
649    }
650
651    let liquidity_side = match order.order_type() {
652        OrderType::Market
653        | OrderType::StopMarket
654        | OrderType::MarketToLimit
655        | OrderType::TrailingStopMarket => LiquiditySide::Taker,
656        _ if order.is_post_only() => LiquiditySide::Maker,
657        _ => LiquiditySide::NoLiquiditySide,
658    };
659
660    let last_px = calculate_incremental_fill_price(order, report, instrument)?;
661
662    let venue_order_id = order.venue_order_id().unwrap_or(report.venue_order_id);
663    let position_id = reconciliation_position_id(report, instrument);
664    let trade_id = create_inferred_reconciliation_trade_id(
665        *account_id,
666        order.instrument_id(),
667        order.client_order_id(),
668        Some(venue_order_id),
669        order.order_side(),
670        order.order_type(),
671        report.filled_qty,
672        last_qty,
673        last_px,
674        position_id,
675        report.ts_last,
676    );
677
678    log::info!(
679        color = LogColor::Blue as u8;
680        "Generated inferred fill for {}: qty={}, px={}",
681        order.client_order_id(),
682        last_qty,
683        last_px,
684    );
685
686    Some(OrderEventAny::Filled(OrderFilled::new(
687        order.trader_id(),
688        order.strategy_id(),
689        order.instrument_id(),
690        order.client_order_id(),
691        venue_order_id,
692        *account_id,
693        trade_id,
694        order.order_side(),
695        order.order_type(),
696        last_qty,
697        last_px,
698        instrument.quote_currency(),
699        liquidity_side,
700        UUID4::new(),
701        report.ts_last,
702        ts_now,
703        true, // reconciliation
704        None, // venue_position_id
705        commission,
706    )))
707}
708
709/// Creates an inferred fill with a specific quantity.
710///
711/// Unlike `create_incremental_inferred_fill`, this takes the fill quantity directly
712/// rather than calculating it from order state. Useful when order state hasn't been
713/// updated yet (e.g., during external order processing).
714pub fn create_inferred_fill_for_qty(
715    order: &OrderAny,
716    report: &OrderStatusReport,
717    account_id: &AccountId,
718    instrument: &InstrumentAny,
719    fill_qty: Quantity,
720    ts_now: UnixNanos,
721    commission: Option<Money>,
722) -> Option<OrderEventAny> {
723    if fill_qty.is_zero() {
724        return None;
725    }
726
727    let liquidity_side = match order.order_type() {
728        OrderType::Market
729        | OrderType::StopMarket
730        | OrderType::MarketToLimit
731        | OrderType::TrailingStopMarket => LiquiditySide::Taker,
732        _ if order.is_post_only() => LiquiditySide::Maker,
733        _ => LiquiditySide::NoLiquiditySide,
734    };
735
736    let last_px = if let Some(avg_px) = report.avg_px {
737        Price::from_decimal_dp(avg_px, instrument.price_precision()).ok()?
738    } else if let Some(price) = report.price {
739        price
740    } else if let Some(price) = order.price() {
741        price
742    } else {
743        log::warn!(
744            "Cannot determine fill price for {}: no avg_px or price available",
745            order.client_order_id()
746        );
747        return None;
748    };
749
750    let venue_order_id = order.venue_order_id().unwrap_or(report.venue_order_id);
751    let position_id = reconciliation_position_id(report, instrument);
752    let trade_id = create_inferred_reconciliation_trade_id(
753        *account_id,
754        order.instrument_id(),
755        order.client_order_id(),
756        Some(venue_order_id),
757        order.order_side(),
758        order.order_type(),
759        report.filled_qty,
760        fill_qty,
761        last_px,
762        position_id,
763        report.ts_last,
764    );
765
766    log::info!(
767        color = LogColor::Blue as u8;
768        "Generated inferred fill for {}: qty={}, px={}",
769        order.client_order_id(),
770        fill_qty,
771        last_px,
772    );
773
774    Some(OrderEventAny::Filled(OrderFilled::new(
775        order.trader_id(),
776        order.strategy_id(),
777        order.instrument_id(),
778        order.client_order_id(),
779        venue_order_id,
780        *account_id,
781        trade_id,
782        order.order_side(),
783        order.order_type(),
784        fill_qty,
785        last_px,
786        instrument.quote_currency(),
787        liquidity_side,
788        UUID4::new(),
789        report.ts_last,
790        ts_now,
791        true, // reconciliation
792        None, // venue_position_id
793        commission,
794    )))
795}
796
797/// Calculates the fill price for an incremental inferred fill.
798fn calculate_incremental_fill_price(
799    order: &OrderAny,
800    report: &OrderStatusReport,
801    instrument: &InstrumentAny,
802) -> Option<Price> {
803    let order_filled_qty = order.filled_qty();
804    debug_assert!(
805        report.filled_qty >= order_filled_qty,
806        "incremental fill price requires report.filled_qty ({}) >= order.filled_qty ({}) for {}",
807        report.filled_qty,
808        order_filled_qty,
809        order.client_order_id(),
810    );
811
812    // First fill - use avg_px from report or order price
813    if order_filled_qty.is_zero() {
814        if let Some(avg_px) = report.avg_px {
815            return Price::from_decimal_dp(avg_px, instrument.price_precision()).ok();
816        }
817
818        if let Some(price) = report.price {
819            return Some(price);
820        }
821
822        if let Some(price) = order.price() {
823            return Some(price);
824        }
825        log::warn!(
826            "Cannot determine fill price for {}: no avg_px, report price, or order price",
827            order.client_order_id()
828        );
829        return None;
830    }
831
832    // Incremental fill - calculate price using weighted average
833    if let Some(report_avg_px) = report.avg_px {
834        let Some(order_avg_px) = order.avg_px() else {
835            // No previous avg_px, use report avg_px
836            return Price::from_decimal_dp(report_avg_px, instrument.price_precision()).ok();
837        };
838        let report_filled_qty = report.filled_qty;
839        let last_qty = report_filled_qty - order_filled_qty;
840
841        let report_notional = report_avg_px * report_filled_qty.as_decimal();
842        let order_notional = Decimal::from_str(&order_avg_px.to_string()).unwrap_or_default()
843            * order_filled_qty.as_decimal();
844        let last_notional = report_notional - order_notional;
845        let last_px_decimal = last_notional / last_qty.as_decimal();
846
847        return Price::from_decimal_dp(last_px_decimal, instrument.price_precision()).ok();
848    }
849
850    // Fallback to report price or order price
851    if let Some(price) = report.price {
852        return Some(price);
853    }
854
855    order.price()
856}
857
858/// Creates an OrderFilled event from a FillReport.
859///
860/// This is used during reconciliation when a fill report is received from the venue.
861/// Returns `None` if the fill is a duplicate or would cause an overfill.
862pub fn reconcile_fill_report(
863    order: &OrderAny,
864    report: &FillReport,
865    instrument: &InstrumentAny,
866    ts_now: UnixNanos,
867    allow_overfills: bool,
868) -> Option<OrderEventAny> {
869    debug_assert!(
870        !report.last_qty.is_zero(),
871        "fill report last_qty must be non-zero for {}",
872        order.client_order_id(),
873    );
874
875    if order.trade_ids().iter().any(|id| **id == report.trade_id) {
876        log::debug!(
877            "Duplicate fill detected: trade_id {} already exists for order {}",
878            report.trade_id,
879            order.client_order_id()
880        );
881        return None;
882    }
883
884    let potential_filled_qty = order.filled_qty() + report.last_qty;
885    if potential_filled_qty > order.quantity() {
886        if !allow_overfills {
887            log::warn!(
888                "Rejecting fill that would cause overfill for {}: order.quantity={}, order.filled_qty={}, fill.last_qty={}, would result in filled_qty={}",
889                order.client_order_id(),
890                order.quantity(),
891                order.filled_qty(),
892                report.last_qty,
893                potential_filled_qty
894            );
895            return None;
896        }
897        log::warn!(
898            "Allowing overfill during reconciliation for {}: order.quantity={}, order.filled_qty={}, fill.last_qty={}, will result in filled_qty={}",
899            order.client_order_id(),
900            order.quantity(),
901            order.filled_qty(),
902            report.last_qty,
903            potential_filled_qty
904        );
905    }
906
907    // Use order's account_id if available, fallback to report's account_id
908    let account_id = order.account_id().unwrap_or(report.account_id);
909    let venue_order_id = order.venue_order_id().unwrap_or(report.venue_order_id);
910
911    log::info!(
912        color = LogColor::Blue as u8;
913        "Reconciling fill for {}: qty={}, px={}, trade_id={}",
914        order.client_order_id(),
915        report.last_qty,
916        report.last_px,
917        report.trade_id,
918    );
919
920    Some(OrderEventAny::Filled(OrderFilled::new(
921        order.trader_id(),
922        order.strategy_id(),
923        order.instrument_id(),
924        order.client_order_id(),
925        venue_order_id,
926        account_id,
927        report.trade_id,
928        order.order_side(),
929        order.order_type(),
930        report.last_qty,
931        report.last_px,
932        instrument.quote_currency(),
933        report.liquidity_side,
934        UUID4::new(),
935        report.ts_event,
936        ts_now,
937        true, // reconciliation
938        report.venue_position_id,
939        Some(report.commission),
940    )))
941}