1use 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#[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#[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; }
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 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 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
231pub 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, ));
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, 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, Some(report.venue_order_id),
297 Some(*account_id),
298 ));
299 vec![accepted, expired]
300 }
301 OrderStatus::Rejected => {
302 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, 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
329pub 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, report.venue_position_id,
409 Some(report.commission),
410 )))
411}
412
413pub 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#[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, )))
462}
463
464#[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, 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#[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, order.venue_order_id(),
521 order.account_id(),
522 ))
523}
524
525#[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, order.venue_order_id(),
542 order.account_id(),
543 ))
544}
545
546#[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, order.venue_order_id(),
563 order.account_id(),
564 ))
565}
566
567#[must_use]
569pub(super) fn create_reconciliation_updated(
570 order: &OrderAny,
571 report: &OrderStatusReport,
572 ts_now: UnixNanos,
573) -> OrderEventAny {
574 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, order.venue_order_id(),
601 order.account_id(),
602 report.price,
603 trigger_price,
604 None, order.is_quote_quantity(),
606 ))
607}
608
609pub(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, report.venue_position_id,
687 commission,
688 )))
689}
690
691pub 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, None, commission,
770 )))
771}
772
773pub 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, None, 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
883fn 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 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 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 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 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
994fn 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 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 if let Some(report_avg_px) = report.avg_px {
1031 let Some(order_avg_px) = order.avg_px() else {
1032 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 if let Some(price) = report.price {
1049 return Some(price);
1050 }
1051
1052 order.price()
1053}
1054
1055fn 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}