1use std::str::FromStr;
21
22use ahash::AHashMap;
23use nautilus_common::enums::LogColor;
24use nautilus_core::{UUID4, UnixNanos};
25use nautilus_model::{
26 enums::{LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSideSpecified, TimeInForce},
27 events::{
28 OrderAccepted, OrderCanceled, OrderEventAny, OrderExpired, OrderFilled, OrderRejected,
29 OrderTriggered, OrderUpdated,
30 },
31 identifiers::{AccountId, InstrumentId, TradeId, VenueOrderId},
32 instruments::{Instrument, InstrumentAny},
33 orders::{Order, OrderAny},
34 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
35 types::{Money, Price, Quantity},
36};
37use rust_decimal::Decimal;
38use ustr::Ustr;
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct FillSnapshot {
43 pub ts_event: u64,
45 pub side: OrderSide,
47 pub qty: Decimal,
49 pub px: Decimal,
51 pub venue_order_id: VenueOrderId,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct VenuePositionSnapshot {
58 pub side: OrderSide, pub qty: Decimal,
62 pub avg_px: Decimal,
64}
65
66#[derive(Debug, Clone, PartialEq)]
68pub enum FillAdjustmentResult {
69 NoAdjustment,
71 AddSyntheticOpening {
73 synthetic_fill: FillSnapshot,
75 existing_fills: Vec<FillSnapshot>,
77 },
78 ReplaceCurrentLifecycle {
80 synthetic_fill: FillSnapshot,
82 first_venue_order_id: VenueOrderId,
84 },
85 FilterToCurrentLifecycle {
87 last_zero_crossing_ts: u64,
89 current_lifecycle_fills: Vec<FillSnapshot>,
91 },
92}
93
94impl FillSnapshot {
95 #[must_use]
97 pub fn new(
98 ts_event: u64,
99 side: OrderSide,
100 qty: Decimal,
101 px: Decimal,
102 venue_order_id: VenueOrderId,
103 ) -> Self {
104 Self {
105 ts_event,
106 side,
107 qty,
108 px,
109 venue_order_id,
110 }
111 }
112
113 #[must_use]
115 pub fn direction(&self) -> i8 {
116 match self.side {
117 OrderSide::Buy => 1,
118 OrderSide::Sell => -1,
119 _ => 0,
120 }
121 }
122}
123
124#[must_use]
130pub fn simulate_position(fills: &[FillSnapshot]) -> (Decimal, Decimal) {
131 let mut qty = Decimal::ZERO;
132 let mut value = Decimal::ZERO;
133
134 for fill in fills {
135 let direction = Decimal::from(fill.direction());
136 let new_qty = qty + (direction * fill.qty);
137
138 if (qty >= Decimal::ZERO && direction > Decimal::ZERO)
140 || (qty <= Decimal::ZERO && direction < Decimal::ZERO)
141 {
142 value += fill.qty * fill.px;
144 qty = new_qty;
145 } else {
146 if qty.abs() >= fill.qty {
148 let close_ratio = fill.qty / qty.abs();
150 value *= Decimal::ONE - close_ratio;
151 qty = new_qty;
152 } else {
153 let remaining = fill.qty - qty.abs();
155 qty = direction * remaining;
156 value = remaining * fill.px;
157 }
158 }
159 }
160
161 (qty, value)
162}
163
164#[must_use]
173pub fn detect_zero_crossings(fills: &[FillSnapshot]) -> Vec<u64> {
174 let mut running_qty = Decimal::ZERO;
175 let mut zero_crossings = Vec::new();
176
177 for fill in fills {
178 let prev_qty = running_qty;
179 running_qty += Decimal::from(fill.direction()) * fill.qty;
180
181 if prev_qty != Decimal::ZERO {
183 if running_qty == Decimal::ZERO {
184 zero_crossings.push(fill.ts_event);
186 } else if (prev_qty > Decimal::ZERO) != (running_qty > Decimal::ZERO) {
187 zero_crossings.push(fill.ts_event);
189 }
190 }
191 }
192
193 zero_crossings
194}
195
196#[must_use]
202pub fn check_position_match(
203 simulated_qty: Decimal,
204 simulated_value: Decimal,
205 venue_qty: Decimal,
206 venue_avg_px: Decimal,
207 tolerance: Decimal,
208) -> bool {
209 if simulated_qty != venue_qty {
210 return false;
211 }
212
213 if simulated_qty == Decimal::ZERO {
214 return true; }
216
217 let abs_qty = simulated_qty.abs();
219 if abs_qty == Decimal::ZERO {
220 return false;
221 }
222
223 let simulated_avg_px = simulated_value / abs_qty;
224
225 if venue_avg_px == Decimal::ZERO {
227 return false;
228 }
229
230 let relative_diff = (simulated_avg_px - venue_avg_px).abs() / venue_avg_px;
231
232 relative_diff <= tolerance
233}
234
235pub fn calculate_reconciliation_price(
253 current_position_qty: Decimal,
254 current_position_avg_px: Option<Decimal>,
255 target_position_qty: Decimal,
256 target_position_avg_px: Option<Decimal>,
257) -> Option<Decimal> {
258 let qty_diff = target_position_qty - current_position_qty;
259
260 if qty_diff == Decimal::ZERO {
261 return None; }
263
264 if target_position_qty == Decimal::ZERO {
267 return current_position_avg_px;
268 }
269
270 let target_avg_px = target_position_avg_px?;
272 if target_avg_px == Decimal::ZERO {
273 return None;
274 }
275
276 if current_position_qty == Decimal::ZERO || current_position_avg_px.is_none() {
278 return Some(target_avg_px);
279 }
280
281 let current_avg_px = current_position_avg_px?;
282
283 let is_flip = (current_position_qty > Decimal::ZERO) != (target_position_qty > Decimal::ZERO)
286 && target_position_qty != Decimal::ZERO;
287
288 if is_flip {
289 return Some(target_avg_px);
290 }
291
292 let target_value = target_position_qty * target_avg_px;
295 let current_value = current_position_qty * current_avg_px;
296 let diff_value = target_value - current_value;
297
298 let reconciliation_px = diff_value / qty_diff;
300
301 if reconciliation_px > Decimal::ZERO {
303 return Some(reconciliation_px);
304 }
305
306 None
307}
308
309#[must_use]
322pub fn adjust_fills_for_partial_window(
323 fills: &[FillSnapshot],
324 venue_position: &VenuePositionSnapshot,
325 _instrument: &InstrumentAny,
326 tolerance: Decimal,
327) -> FillAdjustmentResult {
328 if fills.is_empty() {
330 return FillAdjustmentResult::NoAdjustment;
331 }
332
333 if venue_position.qty == Decimal::ZERO {
335 return FillAdjustmentResult::NoAdjustment;
336 }
337
338 let zero_crossings = detect_zero_crossings(fills);
340
341 let venue_qty_signed = match venue_position.side {
343 OrderSide::Buy => venue_position.qty,
344 OrderSide::Sell => -venue_position.qty,
345 _ => Decimal::ZERO,
346 };
347
348 if !zero_crossings.is_empty() {
350 let mut last_flat_crossing_ts = None;
353 let mut running_qty = Decimal::ZERO;
354
355 for fill in fills {
356 let prev_qty = running_qty;
357 running_qty += Decimal::from(fill.direction()) * fill.qty;
358
359 if prev_qty != Decimal::ZERO && running_qty == Decimal::ZERO {
360 last_flat_crossing_ts = Some(fill.ts_event);
361 }
362 }
363
364 let lifecycle_boundary_ts =
365 last_flat_crossing_ts.unwrap_or(*zero_crossings.last().unwrap());
366
367 let current_lifecycle_fills: Vec<FillSnapshot> = fills
369 .iter()
370 .filter(|f| f.ts_event > lifecycle_boundary_ts)
371 .cloned()
372 .collect();
373
374 if current_lifecycle_fills.is_empty() {
375 return FillAdjustmentResult::NoAdjustment;
376 }
377
378 let (current_qty, current_value) = simulate_position(¤t_lifecycle_fills);
380
381 if check_position_match(
383 current_qty,
384 current_value,
385 venue_qty_signed,
386 venue_position.avg_px,
387 tolerance,
388 ) {
389 return FillAdjustmentResult::FilterToCurrentLifecycle {
391 last_zero_crossing_ts: lifecycle_boundary_ts,
392 current_lifecycle_fills,
393 };
394 }
395
396 if let Some(first_fill) = current_lifecycle_fills.first() {
398 let synthetic_fill = FillSnapshot::new(
399 first_fill.ts_event.saturating_sub(1), venue_position.side,
401 venue_position.qty,
402 venue_position.avg_px,
403 first_fill.venue_order_id,
404 );
405
406 return FillAdjustmentResult::ReplaceCurrentLifecycle {
407 synthetic_fill,
408 first_venue_order_id: first_fill.venue_order_id,
409 };
410 }
411
412 return FillAdjustmentResult::NoAdjustment;
413 }
414
415 let oldest_lifecycle_fills: Vec<FillSnapshot> =
418 if let Some(&first_zero_crossing_ts) = zero_crossings.first() {
419 fills
421 .iter()
422 .filter(|f| f.ts_event <= first_zero_crossing_ts)
423 .cloned()
424 .collect()
425 } else {
426 fills.to_vec()
428 };
429
430 if oldest_lifecycle_fills.is_empty() {
431 return FillAdjustmentResult::NoAdjustment;
432 }
433
434 let (oldest_qty, oldest_value) = simulate_position(&oldest_lifecycle_fills);
436
437 if zero_crossings.is_empty() {
439 if check_position_match(
441 oldest_qty,
442 oldest_value,
443 venue_qty_signed,
444 venue_position.avg_px,
445 tolerance,
446 ) {
447 return FillAdjustmentResult::NoAdjustment;
448 }
449
450 if let Some(first_fill) = oldest_lifecycle_fills.first() {
452 let oldest_avg_px = if oldest_qty == Decimal::ZERO {
455 None
456 } else {
457 Some(oldest_value / oldest_qty.abs())
458 };
459
460 let reconciliation_price = calculate_reconciliation_price(
461 oldest_qty,
462 oldest_avg_px,
463 venue_qty_signed,
464 Some(venue_position.avg_px),
465 );
466
467 if let Some(opening_px) = reconciliation_price {
468 let opening_qty = if oldest_qty == Decimal::ZERO {
470 venue_qty_signed
471 } else {
472 venue_qty_signed - oldest_qty
474 };
475
476 if opening_qty.abs() > Decimal::ZERO {
477 let synthetic_side = if opening_qty > Decimal::ZERO {
478 OrderSide::Buy
479 } else {
480 OrderSide::Sell
481 };
482
483 let synthetic_fill = FillSnapshot::new(
484 first_fill.ts_event.saturating_sub(1),
485 synthetic_side,
486 opening_qty.abs(),
487 opening_px,
488 first_fill.venue_order_id,
489 );
490
491 return FillAdjustmentResult::AddSyntheticOpening {
492 synthetic_fill,
493 existing_fills: oldest_lifecycle_fills,
494 };
495 }
496 }
497 }
498
499 return FillAdjustmentResult::NoAdjustment;
500 }
501
502 if oldest_qty == Decimal::ZERO {
504 return FillAdjustmentResult::NoAdjustment;
506 }
507
508 if !oldest_lifecycle_fills.is_empty()
510 && let Some(&first_zero_crossing_ts) = zero_crossings.first()
511 {
512 let current_lifecycle_fills: Vec<FillSnapshot> = fills
514 .iter()
515 .filter(|f| f.ts_event > first_zero_crossing_ts)
516 .cloned()
517 .collect();
518
519 if !current_lifecycle_fills.is_empty()
520 && let Some(first_current_fill) = current_lifecycle_fills.first()
521 {
522 let synthetic_fill = FillSnapshot::new(
523 first_current_fill.ts_event.saturating_sub(1),
524 venue_position.side,
525 venue_position.qty,
526 venue_position.avg_px,
527 first_current_fill.venue_order_id,
528 );
529
530 return FillAdjustmentResult::AddSyntheticOpening {
531 synthetic_fill,
532 existing_fills: oldest_lifecycle_fills,
533 };
534 }
535 }
536
537 FillAdjustmentResult::NoAdjustment
538}
539
540#[must_use]
544pub fn create_synthetic_venue_order_id(ts_event: u64) -> VenueOrderId {
545 let uuid = UUID4::new();
546 let uuid_str = uuid.to_string();
547 let uuid_suffix = &uuid_str[..8];
548 let venue_order_id_value = format!("S-{ts_event:x}-{uuid_suffix}");
549 VenueOrderId::new(&venue_order_id_value)
550}
551
552#[must_use]
556pub fn create_synthetic_trade_id(ts_event: u64) -> TradeId {
557 let uuid = UUID4::new();
558 let uuid_str = uuid.to_string();
559 let uuid_suffix = &uuid_str[..8];
560 let trade_id_value = format!("S-{ts_event:x}-{uuid_suffix}");
561 TradeId::new(&trade_id_value)
562}
563
564pub fn create_synthetic_order_report(
570 fill: &FillSnapshot,
571 account_id: AccountId,
572 instrument_id: InstrumentId,
573 instrument: &InstrumentAny,
574 venue_order_id: VenueOrderId,
575) -> anyhow::Result<OrderStatusReport> {
576 let order_qty = Quantity::from_decimal_dp(fill.qty, instrument.size_precision())?;
577
578 Ok(OrderStatusReport::new(
579 account_id,
580 instrument_id,
581 None, venue_order_id,
583 fill.side,
584 OrderType::Market,
585 TimeInForce::Gtc,
586 OrderStatus::Filled,
587 order_qty,
588 order_qty, UnixNanos::from(fill.ts_event),
590 UnixNanos::from(fill.ts_event),
591 UnixNanos::from(fill.ts_event),
592 None, ))
594}
595
596pub fn create_synthetic_fill_report(
602 fill: &FillSnapshot,
603 account_id: AccountId,
604 instrument_id: InstrumentId,
605 instrument: &InstrumentAny,
606 venue_order_id: VenueOrderId,
607) -> anyhow::Result<FillReport> {
608 let trade_id = create_synthetic_trade_id(fill.ts_event);
609 let qty = Quantity::from_decimal_dp(fill.qty, instrument.size_precision())?;
610 let px = Price::from_decimal_dp(fill.px, instrument.price_precision())?;
611
612 Ok(FillReport::new(
613 account_id,
614 instrument_id,
615 venue_order_id,
616 trade_id,
617 fill.side,
618 qty,
619 px,
620 Money::new(0.0, instrument.quote_currency()),
621 LiquiditySide::NoLiquiditySide,
622 None, None, fill.ts_event.into(),
625 fill.ts_event.into(),
626 None, ))
628}
629
630#[derive(Debug, Clone)]
632pub struct ReconciliationResult {
633 pub orders: AHashMap<VenueOrderId, OrderStatusReport>,
635 pub fills: AHashMap<VenueOrderId, Vec<FillReport>>,
637}
638
639const DEFAULT_TOLERANCE: Decimal = Decimal::from_parts(1, 0, 0, false, 4); pub fn process_mass_status_for_reconciliation(
652 mass_status: &ExecutionMassStatus,
653 instrument: &InstrumentAny,
654 tolerance: Option<Decimal>,
655) -> anyhow::Result<ReconciliationResult> {
656 let instrument_id = instrument.id();
657 let account_id = mass_status.account_id;
658 let tol = tolerance.unwrap_or(DEFAULT_TOLERANCE);
659
660 let position_reports = mass_status.position_reports();
662 let venue_position = match position_reports.get(&instrument_id).and_then(|r| r.first()) {
663 Some(report) => position_report_to_snapshot(report),
664 None => {
665 return Ok(extract_instrument_reports(mass_status, instrument_id));
667 }
668 };
669
670 let extracted = extract_fills_for_instrument(mass_status, instrument_id);
672 let fill_snapshots = extracted.snapshots;
673 let mut order_map = extracted.orders;
674 let mut fill_map = extracted.fills;
675
676 if fill_snapshots.is_empty() {
677 return Ok(ReconciliationResult {
678 orders: order_map,
679 fills: fill_map,
680 });
681 }
682
683 let result = adjust_fills_for_partial_window(&fill_snapshots, &venue_position, instrument, tol);
685
686 match result {
688 FillAdjustmentResult::NoAdjustment => {}
689
690 FillAdjustmentResult::AddSyntheticOpening {
691 synthetic_fill,
692 existing_fills: _,
693 } => {
694 let venue_order_id = create_synthetic_venue_order_id(synthetic_fill.ts_event);
695 let order = create_synthetic_order_report(
696 &synthetic_fill,
697 account_id,
698 instrument_id,
699 instrument,
700 venue_order_id,
701 )?;
702 let fill = create_synthetic_fill_report(
703 &synthetic_fill,
704 account_id,
705 instrument_id,
706 instrument,
707 venue_order_id,
708 )?;
709
710 order_map.insert(venue_order_id, order);
711 fill_map.entry(venue_order_id).or_default().insert(0, fill);
712 }
713
714 FillAdjustmentResult::ReplaceCurrentLifecycle {
715 synthetic_fill,
716 first_venue_order_id,
717 } => {
718 let order = create_synthetic_order_report(
719 &synthetic_fill,
720 account_id,
721 instrument_id,
722 instrument,
723 first_venue_order_id,
724 )?;
725 let fill = create_synthetic_fill_report(
726 &synthetic_fill,
727 account_id,
728 instrument_id,
729 instrument,
730 first_venue_order_id,
731 )?;
732
733 order_map.clear();
735 fill_map.clear();
736 order_map.insert(first_venue_order_id, order);
737 fill_map.insert(first_venue_order_id, vec![fill]);
738 }
739
740 FillAdjustmentResult::FilterToCurrentLifecycle {
741 last_zero_crossing_ts,
742 current_lifecycle_fills: _,
743 } => {
744 for fills in fill_map.values_mut() {
746 fills.retain(|f| f.ts_event.as_u64() > last_zero_crossing_ts);
747 }
748 fill_map.retain(|_, fills| !fills.is_empty());
749
750 let orders_with_fills: ahash::AHashSet<VenueOrderId> =
752 fill_map.keys().copied().collect();
753 order_map.retain(|id, order| {
754 orders_with_fills.contains(id)
755 || !matches!(
756 order.order_status,
757 OrderStatus::Denied
758 | OrderStatus::Rejected
759 | OrderStatus::Canceled
760 | OrderStatus::Expired
761 | OrderStatus::Filled
762 )
763 });
764 }
765 }
766
767 Ok(ReconciliationResult {
768 orders: order_map,
769 fills: fill_map,
770 })
771}
772
773fn position_report_to_snapshot(report: &PositionStatusReport) -> VenuePositionSnapshot {
775 let side = match report.position_side {
776 PositionSideSpecified::Long => OrderSide::Buy,
777 PositionSideSpecified::Short => OrderSide::Sell,
778 PositionSideSpecified::Flat => OrderSide::Buy,
779 };
780
781 VenuePositionSnapshot {
782 side,
783 qty: report.quantity.into(),
784 avg_px: report.avg_px_open.unwrap_or(Decimal::ZERO),
785 }
786}
787
788fn extract_instrument_reports(
790 mass_status: &ExecutionMassStatus,
791 instrument_id: InstrumentId,
792) -> ReconciliationResult {
793 let mut orders = AHashMap::new();
794 let mut fills = AHashMap::new();
795
796 for (id, order) in mass_status.order_reports() {
797 if order.instrument_id == instrument_id {
798 orders.insert(id, order.clone());
799 }
800 }
801
802 for (id, fill_list) in mass_status.fill_reports() {
803 let filtered: Vec<_> = fill_list
804 .iter()
805 .filter(|f| f.instrument_id == instrument_id)
806 .cloned()
807 .collect();
808 if !filtered.is_empty() {
809 fills.insert(id, filtered);
810 }
811 }
812
813 ReconciliationResult { orders, fills }
814}
815
816struct ExtractedFills {
818 snapshots: Vec<FillSnapshot>,
819 orders: AHashMap<VenueOrderId, OrderStatusReport>,
820 fills: AHashMap<VenueOrderId, Vec<FillReport>>,
821}
822
823fn extract_fills_for_instrument(
825 mass_status: &ExecutionMassStatus,
826 instrument_id: InstrumentId,
827) -> ExtractedFills {
828 let mut snapshots = Vec::new();
829 let mut order_map = AHashMap::new();
830 let mut fill_map = AHashMap::new();
831
832 for (id, order) in mass_status.order_reports() {
834 if order.instrument_id == instrument_id {
835 order_map.insert(id, order.clone());
836 }
837 }
838
839 for (venue_order_id, fill_reports) in mass_status.fill_reports() {
841 for fill in fill_reports {
842 if fill.instrument_id == instrument_id {
843 let side = mass_status
844 .order_reports()
845 .get(&venue_order_id)
846 .map_or(fill.order_side, |o| o.order_side);
847
848 snapshots.push(FillSnapshot::new(
849 fill.ts_event.as_u64(),
850 side,
851 fill.last_qty.into(),
852 fill.last_px.into(),
853 venue_order_id,
854 ));
855
856 fill_map
857 .entry(venue_order_id)
858 .or_insert_with(Vec::new)
859 .push(fill.clone());
860 }
861 }
862 }
863
864 snapshots.sort_by_key(|f| f.ts_event);
866
867 ExtractedFills {
868 snapshots,
869 orders: order_map,
870 fills: fill_map,
871 }
872}
873
874#[must_use]
881pub fn generate_external_order_status_events(
882 order: &OrderAny,
883 report: &OrderStatusReport,
884 account_id: &AccountId,
885 instrument: &InstrumentAny,
886 ts_now: UnixNanos,
887) -> Vec<OrderEventAny> {
888 let accepted = OrderEventAny::Accepted(OrderAccepted::new(
889 order.trader_id(),
890 order.strategy_id(),
891 order.instrument_id(),
892 order.client_order_id(),
893 report.venue_order_id,
894 *account_id,
895 UUID4::new(),
896 report.ts_accepted,
897 ts_now,
898 true, ));
900
901 match report.order_status {
902 OrderStatus::Accepted | OrderStatus::Triggered => vec![accepted],
903 OrderStatus::PartiallyFilled | OrderStatus::Filled => {
904 let mut events = vec![accepted];
905
906 if !report.filled_qty.is_zero()
907 && let Some(filled) =
908 create_inferred_fill(order, report, account_id, instrument, ts_now)
909 {
910 events.push(filled);
911 }
912
913 events
914 }
915 OrderStatus::Canceled => {
916 let canceled = OrderEventAny::Canceled(OrderCanceled::new(
917 order.trader_id(),
918 order.strategy_id(),
919 order.instrument_id(),
920 order.client_order_id(),
921 UUID4::new(),
922 report.ts_last,
923 ts_now,
924 true, Some(report.venue_order_id),
926 Some(*account_id),
927 ));
928 vec![accepted, canceled]
929 }
930 OrderStatus::Expired => {
931 let expired = OrderEventAny::Expired(OrderExpired::new(
932 order.trader_id(),
933 order.strategy_id(),
934 order.instrument_id(),
935 order.client_order_id(),
936 UUID4::new(),
937 report.ts_last,
938 ts_now,
939 true, Some(report.venue_order_id),
941 Some(*account_id),
942 ));
943 vec![accepted, expired]
944 }
945 OrderStatus::Rejected => {
946 vec![OrderEventAny::Rejected(OrderRejected::new(
948 order.trader_id(),
949 order.strategy_id(),
950 order.instrument_id(),
951 order.client_order_id(),
952 *account_id,
953 Ustr::from(report.cancel_reason.as_deref().unwrap_or("UNKNOWN")),
954 UUID4::new(),
955 report.ts_last,
956 ts_now,
957 true, false,
959 ))]
960 }
961 _ => {
962 log::warn!(
963 "Unhandled order status {} for external order {}",
964 report.order_status,
965 order.client_order_id()
966 );
967 Vec::new()
968 }
969 }
970}
971
972#[must_use]
974pub fn create_inferred_fill(
975 order: &OrderAny,
976 report: &OrderStatusReport,
977 account_id: &AccountId,
978 instrument: &InstrumentAny,
979 ts_now: UnixNanos,
980) -> Option<OrderEventAny> {
981 let liquidity_side = match order.order_type() {
982 OrderType::Market | OrderType::StopMarket | OrderType::TrailingStopMarket => {
983 LiquiditySide::Taker
984 }
985 _ if report.post_only => LiquiditySide::Maker,
986 _ => LiquiditySide::NoLiquiditySide,
987 };
988
989 let last_px = if let Some(avg_px) = report.avg_px {
990 match Price::from_decimal_dp(avg_px, instrument.price_precision()) {
991 Ok(px) => px,
992 Err(e) => {
993 log::warn!("Failed to create price from avg_px for inferred fill: {e}");
994 return None;
995 }
996 }
997 } else if let Some(price) = report.price {
998 price
999 } else {
1000 log::warn!(
1001 "Cannot create inferred fill for {}: no avg_px or price available",
1002 order.client_order_id()
1003 );
1004 return None;
1005 };
1006
1007 let trade_id = TradeId::from(UUID4::new().as_str());
1008
1009 log::info!(
1010 "Generated inferred fill for {} ({}) qty={} px={}",
1011 order.client_order_id(),
1012 report.venue_order_id,
1013 report.filled_qty,
1014 last_px,
1015 );
1016
1017 Some(OrderEventAny::Filled(OrderFilled::new(
1018 order.trader_id(),
1019 order.strategy_id(),
1020 order.instrument_id(),
1021 order.client_order_id(),
1022 report.venue_order_id,
1023 *account_id,
1024 trade_id,
1025 report.order_side,
1026 order.order_type(),
1027 report.filled_qty,
1028 last_px,
1029 instrument.quote_currency(),
1030 liquidity_side,
1031 UUID4::new(),
1032 report.ts_last,
1033 ts_now,
1034 true, report.venue_position_id,
1036 None, )))
1038}
1039
1040#[must_use]
1046pub fn create_reconciliation_accepted(
1047 order: &OrderAny,
1048 report: &OrderStatusReport,
1049 ts_now: UnixNanos,
1050) -> OrderEventAny {
1051 OrderEventAny::Accepted(OrderAccepted::new(
1052 order.trader_id(),
1053 order.strategy_id(),
1054 order.instrument_id(),
1055 order.client_order_id(),
1056 order.venue_order_id().unwrap_or(report.venue_order_id),
1057 order
1058 .account_id()
1059 .expect("Order should have account_id for reconciliation"),
1060 UUID4::new(),
1061 report.ts_accepted,
1062 ts_now,
1063 true, ))
1065}
1066
1067#[must_use]
1069pub fn create_reconciliation_rejected(
1070 order: &OrderAny,
1071 reason: Option<&str>,
1072 ts_now: UnixNanos,
1073) -> Option<OrderEventAny> {
1074 let account_id = order.account_id()?;
1075 let reason = reason.unwrap_or("UNKNOWN");
1076
1077 Some(OrderEventAny::Rejected(OrderRejected::new(
1078 order.trader_id(),
1079 order.strategy_id(),
1080 order.instrument_id(),
1081 order.client_order_id(),
1082 account_id,
1083 Ustr::from(reason),
1084 UUID4::new(),
1085 ts_now,
1086 ts_now,
1087 true, false, )))
1090}
1091
1092#[must_use]
1094pub fn create_reconciliation_triggered(
1095 order: &OrderAny,
1096 report: &OrderStatusReport,
1097 ts_now: UnixNanos,
1098) -> OrderEventAny {
1099 OrderEventAny::Triggered(OrderTriggered::new(
1100 order.trader_id(),
1101 order.strategy_id(),
1102 order.instrument_id(),
1103 order.client_order_id(),
1104 UUID4::new(),
1105 report.ts_triggered.unwrap_or(ts_now),
1106 ts_now,
1107 true, order.venue_order_id(),
1109 order.account_id(),
1110 ))
1111}
1112
1113#[must_use]
1115pub fn create_reconciliation_canceled(
1116 order: &OrderAny,
1117 report: &OrderStatusReport,
1118 ts_now: UnixNanos,
1119) -> OrderEventAny {
1120 OrderEventAny::Canceled(OrderCanceled::new(
1121 order.trader_id(),
1122 order.strategy_id(),
1123 order.instrument_id(),
1124 order.client_order_id(),
1125 UUID4::new(),
1126 report.ts_last,
1127 ts_now,
1128 true, order.venue_order_id(),
1130 order.account_id(),
1131 ))
1132}
1133
1134#[must_use]
1136pub fn create_reconciliation_expired(
1137 order: &OrderAny,
1138 report: &OrderStatusReport,
1139 ts_now: UnixNanos,
1140) -> OrderEventAny {
1141 OrderEventAny::Expired(OrderExpired::new(
1142 order.trader_id(),
1143 order.strategy_id(),
1144 order.instrument_id(),
1145 order.client_order_id(),
1146 UUID4::new(),
1147 report.ts_last,
1148 ts_now,
1149 true, order.venue_order_id(),
1151 order.account_id(),
1152 ))
1153}
1154
1155#[must_use]
1157pub fn create_reconciliation_updated(
1158 order: &OrderAny,
1159 report: &OrderStatusReport,
1160 ts_now: UnixNanos,
1161) -> OrderEventAny {
1162 OrderEventAny::Updated(OrderUpdated::new(
1163 order.trader_id(),
1164 order.strategy_id(),
1165 order.instrument_id(),
1166 order.client_order_id(),
1167 report.quantity,
1168 UUID4::new(),
1169 report.ts_last,
1170 ts_now,
1171 true, order.venue_order_id(),
1173 order.account_id(),
1174 report.price,
1175 report.trigger_price,
1176 None, ))
1178}
1179
1180#[must_use]
1183pub fn should_reconciliation_update(order: &OrderAny, report: &OrderStatusReport) -> bool {
1184 if report.quantity != order.quantity() && report.quantity >= order.filled_qty() {
1186 return true;
1187 }
1188
1189 match order.order_type() {
1190 OrderType::Limit => report.price != order.price(),
1191 OrderType::StopMarket | OrderType::TrailingStopMarket => {
1192 report.trigger_price != order.trigger_price()
1193 }
1194 OrderType::StopLimit | OrderType::TrailingStopLimit => {
1195 report.trigger_price != order.trigger_price() || report.price != order.price()
1196 }
1197 _ => false,
1198 }
1199}
1200
1201#[must_use]
1206pub fn reconcile_order_report(
1207 order: &OrderAny,
1208 report: &OrderStatusReport,
1209 instrument: Option<&InstrumentAny>,
1210 ts_now: UnixNanos,
1211) -> Option<OrderEventAny> {
1212 if order.status() == report.order_status && order.filled_qty() == report.filled_qty {
1213 if should_reconciliation_update(order, report) {
1214 log::info!(
1215 "Order {} has been updated at venue: qty={}->{}, price={:?}->{:?}",
1216 order.client_order_id(),
1217 order.quantity(),
1218 report.quantity,
1219 order.price(),
1220 report.price
1221 );
1222 return Some(create_reconciliation_updated(order, report, ts_now));
1223 }
1224 return None; }
1226
1227 match report.order_status {
1228 OrderStatus::Accepted => {
1229 if order.status() == OrderStatus::Accepted
1230 && should_reconciliation_update(order, report)
1231 {
1232 return Some(create_reconciliation_updated(order, report, ts_now));
1233 }
1234 Some(create_reconciliation_accepted(order, report, ts_now))
1235 }
1236 OrderStatus::Rejected => {
1237 create_reconciliation_rejected(order, report.cancel_reason.as_deref(), ts_now)
1238 }
1239 OrderStatus::Triggered => Some(create_reconciliation_triggered(order, report, ts_now)),
1240 OrderStatus::Canceled => Some(create_reconciliation_canceled(order, report, ts_now)),
1241 OrderStatus::Expired => Some(create_reconciliation_expired(order, report, ts_now)),
1242
1243 OrderStatus::PartiallyFilled | OrderStatus::Filled => {
1244 reconcile_fill_quantity_mismatch(order, report, instrument, ts_now)
1245 }
1246
1247 OrderStatus::PendingUpdate | OrderStatus::PendingCancel => {
1249 log::debug!(
1250 "Order {} in pending state: {:?}",
1251 order.client_order_id(),
1252 report.order_status
1253 );
1254 None
1255 }
1256
1257 OrderStatus::Initialized
1259 | OrderStatus::Submitted
1260 | OrderStatus::Denied
1261 | OrderStatus::Emulated
1262 | OrderStatus::Released => {
1263 log::warn!(
1264 "Unexpected order status in venue report for {}: {:?}",
1265 order.client_order_id(),
1266 report.order_status
1267 );
1268 None
1269 }
1270 }
1271}
1272
1273fn reconcile_fill_quantity_mismatch(
1277 order: &OrderAny,
1278 report: &OrderStatusReport,
1279 instrument: Option<&InstrumentAny>,
1280 ts_now: UnixNanos,
1281) -> Option<OrderEventAny> {
1282 let order_filled_qty = order.filled_qty();
1283 let report_filled_qty = report.filled_qty;
1284
1285 if report_filled_qty < order_filled_qty {
1286 log::error!(
1288 "Fill qty mismatch for {}: cached={}, venue={} (venue < cached)",
1289 order.client_order_id(),
1290 order_filled_qty,
1291 report_filled_qty
1292 );
1293 return None;
1294 }
1295
1296 if report_filled_qty > order_filled_qty {
1297 if order.is_closed() {
1300 let precision = order_filled_qty.precision.max(report_filled_qty.precision);
1301 if is_within_single_unit_tolerance(
1302 report_filled_qty.as_decimal(),
1303 order_filled_qty.as_decimal(),
1304 precision,
1305 ) {
1306 return None;
1307 }
1308
1309 log::debug!(
1310 "{} {} already closed but reported difference in filled_qty: \
1311 report={}, cached={}, skipping inferred fill generation for closed order",
1312 order.instrument_id(),
1313 order.client_order_id(),
1314 report_filled_qty,
1315 order_filled_qty,
1316 );
1317 return None;
1318 }
1319
1320 let Some(instrument) = instrument else {
1322 log::warn!(
1323 "Cannot generate inferred fill for {}: instrument not available",
1324 order.client_order_id()
1325 );
1326 return None;
1327 };
1328
1329 let account_id = order.account_id()?;
1330 return create_incremental_inferred_fill(order, report, &account_id, instrument, ts_now);
1331 }
1332
1333 if order.status() != report.order_status {
1335 log::warn!(
1336 "Status mismatch with matching fill qty for {}: local={:?}, venue={:?}, filled_qty={}",
1337 order.client_order_id(),
1338 order.status(),
1339 report.order_status,
1340 report.filled_qty
1341 );
1342 }
1343
1344 None
1345}
1346
1347pub fn create_incremental_inferred_fill(
1351 order: &OrderAny,
1352 report: &OrderStatusReport,
1353 account_id: &AccountId,
1354 instrument: &InstrumentAny,
1355 ts_now: UnixNanos,
1356) -> Option<OrderEventAny> {
1357 let order_filled_qty = order.filled_qty();
1358 let last_qty = report.filled_qty - order_filled_qty;
1359
1360 if last_qty <= Quantity::zero(instrument.size_precision()) {
1361 return None;
1362 }
1363
1364 let liquidity_side = match order.order_type() {
1365 OrderType::Market
1366 | OrderType::StopMarket
1367 | OrderType::MarketToLimit
1368 | OrderType::TrailingStopMarket => LiquiditySide::Taker,
1369 _ if order.is_post_only() => LiquiditySide::Maker,
1370 _ => LiquiditySide::NoLiquiditySide,
1371 };
1372
1373 let last_px = calculate_incremental_fill_price(order, report, instrument)?;
1374
1375 let trade_id = TradeId::new(UUID4::new().to_string());
1376
1377 let venue_order_id = order.venue_order_id().unwrap_or(report.venue_order_id);
1378
1379 log::info!(
1380 color = LogColor::Blue as u8;
1381 "Generated inferred fill for {}: qty={}, px={}",
1382 order.client_order_id(),
1383 last_qty,
1384 last_px,
1385 );
1386
1387 Some(OrderEventAny::Filled(OrderFilled::new(
1388 order.trader_id(),
1389 order.strategy_id(),
1390 order.instrument_id(),
1391 order.client_order_id(),
1392 venue_order_id,
1393 *account_id,
1394 trade_id,
1395 order.order_side(),
1396 order.order_type(),
1397 last_qty,
1398 last_px,
1399 instrument.quote_currency(),
1400 liquidity_side,
1401 UUID4::new(),
1402 report.ts_last,
1403 ts_now,
1404 true, None, None, )))
1408}
1409
1410pub fn create_inferred_fill_for_qty(
1416 order: &OrderAny,
1417 report: &OrderStatusReport,
1418 account_id: &AccountId,
1419 instrument: &InstrumentAny,
1420 fill_qty: Quantity,
1421 ts_now: UnixNanos,
1422) -> Option<OrderEventAny> {
1423 if fill_qty.is_zero() {
1424 return None;
1425 }
1426
1427 let liquidity_side = match order.order_type() {
1428 OrderType::Market
1429 | OrderType::StopMarket
1430 | OrderType::MarketToLimit
1431 | OrderType::TrailingStopMarket => LiquiditySide::Taker,
1432 _ if order.is_post_only() => LiquiditySide::Maker,
1433 _ => LiquiditySide::NoLiquiditySide,
1434 };
1435
1436 let last_px = if let Some(avg_px) = report.avg_px {
1437 Price::from_decimal_dp(avg_px, instrument.price_precision()).ok()?
1438 } else if let Some(price) = report.price {
1439 price
1440 } else if let Some(price) = order.price() {
1441 price
1442 } else {
1443 log::warn!(
1444 "Cannot determine fill price for {}: no avg_px or price available",
1445 order.client_order_id()
1446 );
1447 return None;
1448 };
1449
1450 let trade_id = TradeId::new(UUID4::new().to_string());
1451
1452 let venue_order_id = order.venue_order_id().unwrap_or(report.venue_order_id);
1453
1454 log::info!(
1455 color = LogColor::Blue as u8;
1456 "Generated inferred fill for {}: qty={}, px={}",
1457 order.client_order_id(),
1458 fill_qty,
1459 last_px,
1460 );
1461
1462 Some(OrderEventAny::Filled(OrderFilled::new(
1463 order.trader_id(),
1464 order.strategy_id(),
1465 order.instrument_id(),
1466 order.client_order_id(),
1467 venue_order_id,
1468 *account_id,
1469 trade_id,
1470 order.order_side(),
1471 order.order_type(),
1472 fill_qty,
1473 last_px,
1474 instrument.quote_currency(),
1475 liquidity_side,
1476 UUID4::new(),
1477 report.ts_last,
1478 ts_now,
1479 true, None, None, )))
1483}
1484
1485fn calculate_incremental_fill_price(
1487 order: &OrderAny,
1488 report: &OrderStatusReport,
1489 instrument: &InstrumentAny,
1490) -> Option<Price> {
1491 let order_filled_qty = order.filled_qty();
1492
1493 if order_filled_qty.is_zero() {
1495 if let Some(avg_px) = report.avg_px {
1496 return Price::from_decimal_dp(avg_px, instrument.price_precision()).ok();
1497 }
1498 if let Some(price) = report.price {
1499 return Some(price);
1500 }
1501 if let Some(price) = order.price() {
1502 return Some(price);
1503 }
1504 log::warn!(
1505 "Cannot determine fill price for {}: no avg_px, report price, or order price",
1506 order.client_order_id()
1507 );
1508 return None;
1509 }
1510
1511 if let Some(report_avg_px) = report.avg_px {
1513 let Some(order_avg_px) = order.avg_px() else {
1514 return Price::from_decimal_dp(report_avg_px, instrument.price_precision()).ok();
1516 };
1517 let report_filled_qty = report.filled_qty;
1518 let last_qty = report_filled_qty - order_filled_qty;
1519
1520 let report_notional = report_avg_px * report_filled_qty.as_decimal();
1521 let order_notional = Decimal::from_str(&order_avg_px.to_string()).unwrap_or_default()
1522 * order_filled_qty.as_decimal();
1523 let last_notional = report_notional - order_notional;
1524 let last_px_decimal = last_notional / last_qty.as_decimal();
1525
1526 return Price::from_decimal_dp(last_px_decimal, instrument.price_precision()).ok();
1527 }
1528
1529 if let Some(price) = report.price {
1531 return Some(price);
1532 }
1533
1534 order.price()
1535}
1536
1537#[must_use]
1542pub fn reconcile_fill_report(
1543 order: &OrderAny,
1544 report: &FillReport,
1545 instrument: &InstrumentAny,
1546 ts_now: UnixNanos,
1547 allow_overfills: bool,
1548) -> Option<OrderEventAny> {
1549 if order.trade_ids().iter().any(|id| **id == report.trade_id) {
1550 log::debug!(
1551 "Duplicate fill detected: trade_id {} already exists for order {}",
1552 report.trade_id,
1553 order.client_order_id()
1554 );
1555 return None;
1556 }
1557
1558 let potential_filled_qty = order.filled_qty() + report.last_qty;
1559 if potential_filled_qty > order.quantity() {
1560 if !allow_overfills {
1561 log::warn!(
1562 "Rejecting fill that would cause overfill for {}: order.quantity={}, order.filled_qty={}, fill.last_qty={}, would result in filled_qty={}",
1563 order.client_order_id(),
1564 order.quantity(),
1565 order.filled_qty(),
1566 report.last_qty,
1567 potential_filled_qty
1568 );
1569 return None;
1570 }
1571 log::warn!(
1572 "Allowing overfill during reconciliation for {}: order.quantity={}, order.filled_qty={}, fill.last_qty={}, will result in filled_qty={}",
1573 order.client_order_id(),
1574 order.quantity(),
1575 order.filled_qty(),
1576 report.last_qty,
1577 potential_filled_qty
1578 );
1579 }
1580
1581 let account_id = order.account_id().unwrap_or(report.account_id);
1583 let venue_order_id = order.venue_order_id().unwrap_or(report.venue_order_id);
1584
1585 log::info!(
1586 color = LogColor::Blue as u8;
1587 "Reconciling fill for {}: qty={}, px={}, trade_id={}",
1588 order.client_order_id(),
1589 report.last_qty,
1590 report.last_px,
1591 report.trade_id,
1592 );
1593
1594 Some(OrderEventAny::Filled(OrderFilled::new(
1595 order.trader_id(),
1596 order.strategy_id(),
1597 order.instrument_id(),
1598 order.client_order_id(),
1599 venue_order_id,
1600 account_id,
1601 report.trade_id,
1602 order.order_side(),
1603 order.order_type(),
1604 report.last_qty,
1605 report.last_px,
1606 instrument.quote_currency(),
1607 report.liquidity_side,
1608 UUID4::new(),
1609 report.ts_event,
1610 ts_now,
1611 true, report.venue_position_id,
1613 Some(report.commission),
1614 )))
1615}
1616
1617#[must_use]
1622pub fn check_position_reconciliation(
1623 report: &PositionStatusReport,
1624 cached_signed_qty: Decimal,
1625 size_precision: Option<u8>,
1626) -> bool {
1627 let venue_signed_qty = report.signed_decimal_qty;
1628
1629 if venue_signed_qty == Decimal::ZERO && cached_signed_qty == Decimal::ZERO {
1630 return true;
1631 }
1632
1633 if let Some(precision) = size_precision
1634 && is_within_single_unit_tolerance(cached_signed_qty, venue_signed_qty, precision)
1635 {
1636 log::debug!(
1637 "Position for {} within tolerance: cached={}, venue={}",
1638 report.instrument_id,
1639 cached_signed_qty,
1640 venue_signed_qty
1641 );
1642 return true;
1643 }
1644
1645 if cached_signed_qty == venue_signed_qty {
1646 return true;
1647 }
1648
1649 log::warn!(
1650 "Position discrepancy for {}: cached={}, venue={}",
1651 report.instrument_id,
1652 cached_signed_qty,
1653 venue_signed_qty
1654 );
1655
1656 false
1657}
1658
1659#[must_use]
1664pub fn is_within_single_unit_tolerance(value1: Decimal, value2: Decimal, precision: u8) -> bool {
1665 if precision == 0 {
1666 return value1 == value2;
1667 }
1668
1669 let tolerance = Decimal::new(1, u32::from(precision));
1670 let difference = (value1 - value2).abs();
1671 difference <= tolerance
1672}
1673
1674#[cfg(test)]
1675#[allow(clippy::too_many_arguments)]
1676mod tests {
1677 use nautilus_model::{
1678 enums::TimeInForce,
1679 events::{OrderAccepted, OrderSubmitted},
1680 identifiers::{AccountId, ClientOrderId, VenueOrderId},
1681 instruments::stubs::{audusd_sim, crypto_perpetual_ethusdt},
1682 orders::OrderTestBuilder,
1683 reports::OrderStatusReport,
1684 types::Currency,
1685 };
1686 use rstest::{fixture, rstest};
1687 use rust_decimal_macros::dec;
1688
1689 use super::*;
1690
1691 #[fixture]
1692 fn instrument() -> InstrumentAny {
1693 InstrumentAny::CurrencyPair(audusd_sim())
1694 }
1695
1696 fn create_test_venue_order_id(value: &str) -> VenueOrderId {
1697 VenueOrderId::new(value)
1698 }
1699
1700 #[rstest]
1701 fn test_fill_snapshot_direction() {
1702 let venue_order_id = create_test_venue_order_id("ORDER1");
1703 let buy_fill = FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id);
1704 assert_eq!(buy_fill.direction(), 1);
1705
1706 let sell_fill =
1707 FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id);
1708 assert_eq!(sell_fill.direction(), -1);
1709 }
1710
1711 #[rstest]
1712 fn test_simulate_position_accumulate_long() {
1713 let venue_order_id = create_test_venue_order_id("ORDER1");
1714 let fills = vec![
1715 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1716 FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(102), venue_order_id),
1717 ];
1718
1719 let (qty, value) = simulate_position(&fills);
1720 assert_eq!(qty, dec!(15));
1721 assert_eq!(value, dec!(1510)); }
1723
1724 #[rstest]
1725 fn test_simulate_position_close_and_flip() {
1726 let venue_order_id = create_test_venue_order_id("ORDER1");
1727 let fills = vec![
1728 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1729 FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(102), venue_order_id),
1730 ];
1731
1732 let (qty, value) = simulate_position(&fills);
1733 assert_eq!(qty, dec!(-5)); assert_eq!(value, dec!(510)); }
1736
1737 #[rstest]
1738 fn test_simulate_position_partial_close() {
1739 let venue_order_id = create_test_venue_order_id("ORDER1");
1740 let fills = vec![
1741 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1742 FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(102), venue_order_id),
1743 ];
1744
1745 let (qty, value) = simulate_position(&fills);
1746 assert_eq!(qty, dec!(5));
1747 assert_eq!(value, dec!(500)); let avg_px = value / qty;
1751 assert_eq!(avg_px, dec!(100));
1752 }
1753
1754 #[rstest]
1755 fn test_simulate_position_multiple_partial_closes() {
1756 let venue_order_id = create_test_venue_order_id("ORDER1");
1757 let fills = vec![
1758 FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(10.0), venue_order_id),
1759 FillSnapshot::new(2000, OrderSide::Sell, dec!(25), dec!(11.0), venue_order_id), FillSnapshot::new(3000, OrderSide::Sell, dec!(25), dec!(12.0), venue_order_id), ];
1762
1763 let (qty, value) = simulate_position(&fills);
1764 assert_eq!(qty, dec!(50));
1765 assert!((value - dec!(500)).abs() < dec!(0.01));
1769
1770 let avg_px = value / qty;
1772 assert!((avg_px - dec!(10.0)).abs() < dec!(0.01));
1773 }
1774
1775 #[rstest]
1776 fn test_simulate_position_short_partial_close() {
1777 let venue_order_id = create_test_venue_order_id("ORDER1");
1778 let fills = vec![
1779 FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
1780 FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(98), venue_order_id), ];
1782
1783 let (qty, value) = simulate_position(&fills);
1784 assert_eq!(qty, dec!(-5));
1785 assert_eq!(value, dec!(500)); let avg_px = value / qty.abs();
1789 assert_eq!(avg_px, dec!(100));
1790 }
1791
1792 #[rstest]
1793 fn test_detect_zero_crossings() {
1794 let venue_order_id = create_test_venue_order_id("ORDER1");
1795 let fills = vec![
1796 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1797 FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), FillSnapshot::new(3000, OrderSide::Buy, dec!(5), dec!(103), venue_order_id),
1799 FillSnapshot::new(4000, OrderSide::Sell, dec!(5), dec!(104), venue_order_id), ];
1801
1802 let crossings = detect_zero_crossings(&fills);
1803 assert_eq!(crossings.len(), 2);
1804 assert_eq!(crossings[0], 2000);
1805 assert_eq!(crossings[1], 4000);
1806 }
1807
1808 #[rstest]
1809 fn test_check_position_match_exact() {
1810 let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100), dec!(0.0001));
1811 assert!(result);
1812 }
1813
1814 #[rstest]
1815 fn test_check_position_match_within_tolerance() {
1816 let result =
1819 check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100.005), dec!(0.0001));
1820 assert!(result);
1821 }
1822
1823 #[rstest]
1824 fn test_check_position_match_qty_mismatch() {
1825 let result = check_position_match(dec!(10), dec!(1000), dec!(11), dec!(100), dec!(0.0001));
1826 assert!(!result);
1827 }
1828
1829 #[rstest]
1830 fn test_check_position_match_both_flat() {
1831 let result = check_position_match(dec!(0), dec!(0), dec!(0), dec!(0), dec!(0.0001));
1832 assert!(result);
1833 }
1834
1835 #[rstest]
1836 fn test_reconciliation_price_flat_to_long(_instrument: InstrumentAny) {
1837 let result = calculate_reconciliation_price(dec!(0), None, dec!(10), Some(dec!(100)));
1838 assert!(result.is_some());
1839 assert_eq!(result.unwrap(), dec!(100));
1840 }
1841
1842 #[rstest]
1843 fn test_reconciliation_price_no_target_avg_px(_instrument: InstrumentAny) {
1844 let result = calculate_reconciliation_price(dec!(5), Some(dec!(100)), dec!(10), None);
1845 assert!(result.is_none());
1846 }
1847
1848 #[rstest]
1849 fn test_reconciliation_price_no_quantity_change(_instrument: InstrumentAny) {
1850 let result =
1851 calculate_reconciliation_price(dec!(10), Some(dec!(100)), dec!(10), Some(dec!(105)));
1852 assert!(result.is_none());
1853 }
1854
1855 #[rstest]
1856 fn test_reconciliation_price_long_position_increase(_instrument: InstrumentAny) {
1857 let result =
1858 calculate_reconciliation_price(dec!(10), Some(dec!(100)), dec!(15), Some(dec!(102)));
1859 assert!(result.is_some());
1860 assert_eq!(result.unwrap(), dec!(106));
1862 }
1863
1864 #[rstest]
1865 fn test_reconciliation_price_flat_to_short(_instrument: InstrumentAny) {
1866 let result = calculate_reconciliation_price(dec!(0), None, dec!(-10), Some(dec!(100)));
1867 assert!(result.is_some());
1868 assert_eq!(result.unwrap(), dec!(100));
1869 }
1870
1871 #[rstest]
1872 fn test_reconciliation_price_long_to_flat(_instrument: InstrumentAny) {
1873 let result =
1876 calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(0), Some(dec!(0)));
1877 assert!(result.is_some());
1878 assert_eq!(result.unwrap(), dec!(1.20));
1879 }
1880
1881 #[rstest]
1882 fn test_reconciliation_price_short_to_flat(_instrument: InstrumentAny) {
1883 let result = calculate_reconciliation_price(dec!(-50), Some(dec!(2.50)), dec!(0), None);
1886 assert!(result.is_some());
1887 assert_eq!(result.unwrap(), dec!(2.50));
1888 }
1889
1890 #[rstest]
1891 fn test_reconciliation_price_short_position_increase(_instrument: InstrumentAny) {
1892 let result = calculate_reconciliation_price(
1897 dec!(-100),
1898 Some(dec!(1.30)),
1899 dec!(-200),
1900 Some(dec!(1.28)),
1901 );
1902 assert!(result.is_some());
1903 assert_eq!(result.unwrap(), dec!(1.26));
1904 }
1905
1906 #[rstest]
1907 fn test_reconciliation_price_long_position_decrease(_instrument: InstrumentAny) {
1908 let result = calculate_reconciliation_price(
1910 dec!(200),
1911 Some(dec!(1.20)),
1912 dec!(100),
1913 Some(dec!(1.20)),
1914 );
1915 assert!(result.is_some());
1916 assert_eq!(result.unwrap(), dec!(1.20));
1917 }
1918
1919 #[rstest]
1920 fn test_reconciliation_price_long_to_short_flip(_instrument: InstrumentAny) {
1921 let result = calculate_reconciliation_price(
1924 dec!(100),
1925 Some(dec!(1.20)),
1926 dec!(-100),
1927 Some(dec!(1.25)),
1928 );
1929 assert!(result.is_some());
1930 assert_eq!(result.unwrap(), dec!(1.25));
1931 }
1932
1933 #[rstest]
1934 fn test_reconciliation_price_short_to_long_flip(_instrument: InstrumentAny) {
1935 let result = calculate_reconciliation_price(
1938 dec!(-100),
1939 Some(dec!(1.30)),
1940 dec!(100),
1941 Some(dec!(1.25)),
1942 );
1943 assert!(result.is_some());
1944 assert_eq!(result.unwrap(), dec!(1.25));
1945 }
1946
1947 #[rstest]
1948 fn test_reconciliation_price_complex_scenario(_instrument: InstrumentAny) {
1949 let result = calculate_reconciliation_price(
1954 dec!(150),
1955 Some(dec!(1.23456)),
1956 dec!(250),
1957 Some(dec!(1.24567)),
1958 );
1959 assert!(result.is_some());
1960 assert_eq!(result.unwrap(), dec!(1.262335));
1961 }
1962
1963 #[rstest]
1964 fn test_reconciliation_price_zero_target_avg_px(_instrument: InstrumentAny) {
1965 let result =
1966 calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(200), Some(dec!(0)));
1967 assert!(result.is_none());
1968 }
1969
1970 #[rstest]
1971 fn test_reconciliation_price_negative_price(_instrument: InstrumentAny) {
1972 let result = calculate_reconciliation_price(
1977 dec!(100),
1978 Some(dec!(2.00)),
1979 dec!(200),
1980 Some(dec!(1.00)),
1981 );
1982 assert!(result.is_none());
1983 }
1984
1985 #[rstest]
1986 fn test_reconciliation_price_flip_simulation_compatibility() {
1987 let venue_order_id = create_test_venue_order_id("ORDER1");
1988 let recon_px = calculate_reconciliation_price(
1992 dec!(100),
1993 Some(dec!(1.20)),
1994 dec!(-100),
1995 Some(dec!(1.25)),
1996 )
1997 .expect("reconciliation price");
1998
1999 assert_eq!(recon_px, dec!(1.25));
2000
2001 let fills = vec![
2003 FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
2004 FillSnapshot::new(2000, OrderSide::Sell, dec!(200), recon_px, venue_order_id),
2005 ];
2006
2007 let (final_qty, final_value) = simulate_position(&fills);
2008 assert_eq!(final_qty, dec!(-100));
2009 let final_avg = final_value / final_qty.abs();
2010 assert_eq!(final_avg, dec!(1.25), "Final average should match target");
2011 }
2012
2013 #[rstest]
2014 fn test_reconciliation_price_accumulation_simulation_compatibility() {
2015 let venue_order_id = create_test_venue_order_id("ORDER1");
2016 let recon_px = calculate_reconciliation_price(
2019 dec!(100),
2020 Some(dec!(1.20)),
2021 dec!(200),
2022 Some(dec!(1.22)),
2023 )
2024 .expect("reconciliation price");
2025
2026 let fills = vec![
2028 FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
2029 FillSnapshot::new(2000, OrderSide::Buy, dec!(100), recon_px, venue_order_id),
2030 ];
2031
2032 let (final_qty, final_value) = simulate_position(&fills);
2033 assert_eq!(final_qty, dec!(200));
2034 let final_avg = final_value / final_qty.abs();
2035 assert_eq!(final_avg, dec!(1.22), "Final average should match target");
2036 }
2037
2038 #[rstest]
2039 fn test_simulate_position_accumulate_short() {
2040 let venue_order_id = create_test_venue_order_id("ORDER1");
2041 let fills = vec![
2042 FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
2043 FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(98), venue_order_id),
2044 ];
2045
2046 let (qty, value) = simulate_position(&fills);
2047 assert_eq!(qty, dec!(-15));
2048 assert_eq!(value, dec!(1490)); }
2050
2051 #[rstest]
2052 fn test_simulate_position_short_to_long_flip() {
2053 let venue_order_id = create_test_venue_order_id("ORDER1");
2054 let fills = vec![
2055 FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
2056 FillSnapshot::new(2000, OrderSide::Buy, dec!(15), dec!(102), venue_order_id),
2057 ];
2058
2059 let (qty, value) = simulate_position(&fills);
2060 assert_eq!(qty, dec!(5)); assert_eq!(value, dec!(510)); }
2063
2064 #[rstest]
2065 fn test_simulate_position_multiple_flips() {
2066 let venue_order_id = create_test_venue_order_id("ORDER1");
2067 let fills = vec![
2068 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2069 FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(105), venue_order_id), FillSnapshot::new(3000, OrderSide::Buy, dec!(10), dec!(110), venue_order_id), ];
2072
2073 let (qty, value) = simulate_position(&fills);
2074 assert_eq!(qty, dec!(5)); assert_eq!(value, dec!(550)); }
2077
2078 #[rstest]
2079 fn test_simulate_position_empty_fills() {
2080 let fills: Vec<FillSnapshot> = vec![];
2081 let (qty, value) = simulate_position(&fills);
2082 assert_eq!(qty, dec!(0));
2083 assert_eq!(value, dec!(0));
2084 }
2085
2086 #[rstest]
2087 fn test_detect_zero_crossings_no_crossings() {
2088 let venue_order_id = create_test_venue_order_id("ORDER1");
2089 let fills = vec![
2090 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2091 FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(102), venue_order_id),
2092 ];
2093
2094 let crossings = detect_zero_crossings(&fills);
2095 assert_eq!(crossings.len(), 0);
2096 }
2097
2098 #[rstest]
2099 fn test_detect_zero_crossings_single_crossing() {
2100 let venue_order_id = create_test_venue_order_id("ORDER1");
2101 let fills = vec![
2102 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2103 FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), ];
2105
2106 let crossings = detect_zero_crossings(&fills);
2107 assert_eq!(crossings.len(), 1);
2108 assert_eq!(crossings[0], 2000);
2109 }
2110
2111 #[rstest]
2112 fn test_detect_zero_crossings_empty_fills() {
2113 let fills: Vec<FillSnapshot> = vec![];
2114 let crossings = detect_zero_crossings(&fills);
2115 assert_eq!(crossings.len(), 0);
2116 }
2117
2118 #[rstest]
2119 fn test_detect_zero_crossings_long_to_short_flip() {
2120 let venue_order_id = create_test_venue_order_id("ORDER1");
2121 let fills = vec![
2123 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2124 FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(102), venue_order_id), ];
2126
2127 let crossings = detect_zero_crossings(&fills);
2128 assert_eq!(crossings.len(), 1);
2129 assert_eq!(crossings[0], 2000); }
2131
2132 #[rstest]
2133 fn test_detect_zero_crossings_short_to_long_flip() {
2134 let venue_order_id = create_test_venue_order_id("ORDER1");
2135 let fills = vec![
2137 FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
2138 FillSnapshot::new(2000, OrderSide::Buy, dec!(20), dec!(102), venue_order_id), ];
2140
2141 let crossings = detect_zero_crossings(&fills);
2142 assert_eq!(crossings.len(), 1);
2143 assert_eq!(crossings[0], 2000);
2144 }
2145
2146 #[rstest]
2147 fn test_detect_zero_crossings_multiple_flips() {
2148 let venue_order_id = create_test_venue_order_id("ORDER1");
2149 let fills = vec![
2150 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2151 FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), FillSnapshot::new(3000, OrderSide::Sell, dec!(5), dec!(103), venue_order_id), FillSnapshot::new(4000, OrderSide::Buy, dec!(15), dec!(104), venue_order_id), ];
2155
2156 let crossings = detect_zero_crossings(&fills);
2157 assert_eq!(crossings.len(), 2);
2158 assert_eq!(crossings[0], 2000); assert_eq!(crossings[1], 4000); }
2161
2162 #[rstest]
2163 fn test_check_position_match_outside_tolerance() {
2164 let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(101), dec!(0.0001));
2167 assert!(!result);
2168 }
2169
2170 #[rstest]
2171 fn test_check_position_match_edge_of_tolerance() {
2172 let result =
2175 check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100.01), dec!(0.0001));
2176 assert!(result);
2177 }
2178
2179 #[rstest]
2180 fn test_check_position_match_zero_venue_avg_px() {
2181 let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(0), dec!(0.0001));
2182 assert!(!result); }
2184
2185 #[rstest]
2186 fn test_adjust_fills_no_fills() {
2187 let venue_position = VenuePositionSnapshot {
2188 side: OrderSide::Buy,
2189 qty: dec!(0.02),
2190 avg_px: dec!(4100.00),
2191 };
2192 let instrument = instrument();
2193 let result =
2194 adjust_fills_for_partial_window(&[], &venue_position, &instrument, dec!(0.0001));
2195 assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
2196 }
2197
2198 #[rstest]
2199 fn test_adjust_fills_flat_position() {
2200 let venue_order_id = create_test_venue_order_id("ORDER1");
2201 let fills = vec![FillSnapshot::new(
2202 1000,
2203 OrderSide::Buy,
2204 dec!(0.01),
2205 dec!(4100.00),
2206 venue_order_id,
2207 )];
2208 let venue_position = VenuePositionSnapshot {
2209 side: OrderSide::Buy,
2210 qty: dec!(0),
2211 avg_px: dec!(0),
2212 };
2213 let instrument = instrument();
2214 let result =
2215 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2216 assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
2217 }
2218
2219 #[rstest]
2220 fn test_adjust_fills_complete_lifecycle_no_adjustment() {
2221 let venue_order_id = create_test_venue_order_id("ORDER1");
2222 let venue_order_id2 = create_test_venue_order_id("ORDER2");
2223 let fills = vec![
2224 FillSnapshot::new(
2225 1000,
2226 OrderSide::Buy,
2227 dec!(0.01),
2228 dec!(4100.00),
2229 venue_order_id,
2230 ),
2231 FillSnapshot::new(
2232 2000,
2233 OrderSide::Buy,
2234 dec!(0.01),
2235 dec!(4100.00),
2236 venue_order_id2,
2237 ),
2238 ];
2239 let venue_position = VenuePositionSnapshot {
2240 side: OrderSide::Buy,
2241 qty: dec!(0.02),
2242 avg_px: dec!(4100.00),
2243 };
2244 let instrument = instrument();
2245 let result =
2246 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2247 assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
2248 }
2249
2250 #[rstest]
2251 fn test_adjust_fills_incomplete_lifecycle_adds_synthetic() {
2252 let venue_order_id = create_test_venue_order_id("ORDER1");
2253 let fills = vec![FillSnapshot::new(
2255 2000,
2256 OrderSide::Buy,
2257 dec!(0.02),
2258 dec!(4200.00),
2259 venue_order_id,
2260 )];
2261 let venue_position = VenuePositionSnapshot {
2262 side: OrderSide::Buy,
2263 qty: dec!(0.04),
2264 avg_px: dec!(4100.00),
2265 };
2266 let instrument = instrument();
2267 let result =
2268 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2269
2270 match result {
2271 FillAdjustmentResult::AddSyntheticOpening {
2272 synthetic_fill,
2273 existing_fills,
2274 } => {
2275 assert_eq!(synthetic_fill.side, OrderSide::Buy);
2276 assert_eq!(synthetic_fill.qty, dec!(0.02)); assert_eq!(existing_fills.len(), 1);
2278 }
2279 _ => panic!("Expected AddSyntheticOpening"),
2280 }
2281 }
2282
2283 #[rstest]
2284 fn test_adjust_fills_with_zero_crossings() {
2285 let venue_order_id1 = create_test_venue_order_id("ORDER1");
2286 let venue_order_id2 = create_test_venue_order_id("ORDER2");
2287 let venue_order_id3 = create_test_venue_order_id("ORDER3");
2288
2289 let fills = vec![
2292 FillSnapshot::new(
2293 1000,
2294 OrderSide::Buy,
2295 dec!(0.02),
2296 dec!(4100.00),
2297 venue_order_id1,
2298 ),
2299 FillSnapshot::new(
2300 2000,
2301 OrderSide::Sell,
2302 dec!(0.02),
2303 dec!(4150.00),
2304 venue_order_id2,
2305 ), FillSnapshot::new(
2307 3000,
2308 OrderSide::Buy,
2309 dec!(0.03),
2310 dec!(4200.00),
2311 venue_order_id3,
2312 ), ];
2314
2315 let venue_position = VenuePositionSnapshot {
2316 side: OrderSide::Buy,
2317 qty: dec!(0.03),
2318 avg_px: dec!(4200.00),
2319 };
2320
2321 let instrument = instrument();
2322 let result =
2323 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2324
2325 match result {
2327 FillAdjustmentResult::FilterToCurrentLifecycle {
2328 last_zero_crossing_ts,
2329 current_lifecycle_fills,
2330 } => {
2331 assert_eq!(last_zero_crossing_ts, 2000);
2332 assert_eq!(current_lifecycle_fills.len(), 1);
2333 assert_eq!(current_lifecycle_fills[0].venue_order_id, venue_order_id3);
2334 }
2335 _ => panic!("Expected FilterToCurrentLifecycle, was {result:?}"),
2336 }
2337 }
2338
2339 #[rstest]
2340 fn test_adjust_fills_multiple_zero_crossings_mismatch() {
2341 let venue_order_id1 = create_test_venue_order_id("ORDER1");
2342 let venue_order_id2 = create_test_venue_order_id("ORDER2");
2343 let _venue_order_id3 = create_test_venue_order_id("ORDER3");
2344 let venue_order_id4 = create_test_venue_order_id("ORDER4");
2345 let venue_order_id5 = create_test_venue_order_id("ORDER5");
2346
2347 let fills = vec![
2350 FillSnapshot::new(
2351 1000,
2352 OrderSide::Buy,
2353 dec!(0.05),
2354 dec!(4000.00),
2355 venue_order_id1,
2356 ),
2357 FillSnapshot::new(
2358 2000,
2359 OrderSide::Sell,
2360 dec!(0.05),
2361 dec!(4050.00),
2362 venue_order_id2,
2363 ), FillSnapshot::new(
2365 3000,
2366 OrderSide::Buy,
2367 dec!(0.05),
2368 dec!(4000.00),
2369 venue_order_id4,
2370 ), FillSnapshot::new(
2372 4000,
2373 OrderSide::Buy,
2374 dec!(0.05),
2375 dec!(4100.00),
2376 venue_order_id5,
2377 ), ];
2379
2380 let venue_position = VenuePositionSnapshot {
2381 side: OrderSide::Buy,
2382 qty: dec!(0.05),
2383 avg_px: dec!(4142.04),
2384 };
2385
2386 let instrument = instrument();
2387 let result =
2388 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2389
2390 match result {
2392 FillAdjustmentResult::ReplaceCurrentLifecycle {
2393 synthetic_fill,
2394 first_venue_order_id,
2395 } => {
2396 assert_eq!(synthetic_fill.qty, dec!(0.05));
2397 assert_eq!(synthetic_fill.px, dec!(4142.04));
2398 assert_eq!(synthetic_fill.side, OrderSide::Buy);
2399 assert_eq!(first_venue_order_id, venue_order_id4);
2400 }
2401 _ => panic!("Expected ReplaceCurrentLifecycle, was {result:?}"),
2402 }
2403 }
2404
2405 #[rstest]
2406 fn test_adjust_fills_short_position() {
2407 let venue_order_id = create_test_venue_order_id("ORDER1");
2408
2409 let fills = vec![FillSnapshot::new(
2411 1000,
2412 OrderSide::Sell,
2413 dec!(0.02),
2414 dec!(4120.00),
2415 venue_order_id,
2416 )];
2417
2418 let venue_position = VenuePositionSnapshot {
2419 side: OrderSide::Sell,
2420 qty: dec!(0.05),
2421 avg_px: dec!(4100.00),
2422 };
2423
2424 let instrument = instrument();
2425 let result =
2426 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2427
2428 match result {
2430 FillAdjustmentResult::AddSyntheticOpening {
2431 synthetic_fill,
2432 existing_fills,
2433 } => {
2434 assert_eq!(synthetic_fill.side, OrderSide::Sell);
2435 assert_eq!(synthetic_fill.qty, dec!(0.03)); assert_eq!(existing_fills.len(), 1);
2437 }
2438 _ => panic!("Expected AddSyntheticOpening, was {result:?}"),
2439 }
2440 }
2441
2442 #[rstest]
2443 fn test_adjust_fills_timestamp_underflow_protection() {
2444 let venue_order_id = create_test_venue_order_id("ORDER1");
2445
2446 let fills = vec![FillSnapshot::new(
2448 0,
2449 OrderSide::Buy,
2450 dec!(0.01),
2451 dec!(4100.00),
2452 venue_order_id,
2453 )];
2454
2455 let venue_position = VenuePositionSnapshot {
2456 side: OrderSide::Buy,
2457 qty: dec!(0.02),
2458 avg_px: dec!(4100.00),
2459 };
2460
2461 let instrument = instrument();
2462 let result =
2463 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2464
2465 match result {
2467 FillAdjustmentResult::AddSyntheticOpening { synthetic_fill, .. } => {
2468 assert_eq!(synthetic_fill.ts_event, 0); }
2470 _ => panic!("Expected AddSyntheticOpening, was {result:?}"),
2471 }
2472 }
2473
2474 #[rstest]
2475 fn test_adjust_fills_with_flip_scenario() {
2476 let venue_order_id1 = create_test_venue_order_id("ORDER1");
2477 let venue_order_id2 = create_test_venue_order_id("ORDER2");
2478
2479 let fills = vec![
2481 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id1),
2482 FillSnapshot::new(2000, OrderSide::Sell, dec!(20), dec!(105), venue_order_id2), ];
2484
2485 let venue_position = VenuePositionSnapshot {
2486 side: OrderSide::Sell,
2487 qty: dec!(10),
2488 avg_px: dec!(105),
2489 };
2490
2491 let instrument = instrument();
2492 let result =
2493 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2494
2495 match result {
2497 FillAdjustmentResult::NoAdjustment => {
2498 let (qty, value) = simulate_position(&fills);
2500 assert_eq!(qty, dec!(-10));
2501 let avg = value / qty.abs();
2502 assert_eq!(avg, dec!(105));
2503 }
2504 _ => panic!("Expected NoAdjustment for matching flip, was {result:?}"),
2505 }
2506 }
2507
2508 #[rstest]
2509 fn test_detect_zero_crossings_complex_lifecycle() {
2510 let venue_order_id = create_test_venue_order_id("ORDER1");
2511 let fills = vec![
2513 FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
2514 FillSnapshot::new(2000, OrderSide::Sell, dec!(50), dec!(1.25), venue_order_id), FillSnapshot::new(3000, OrderSide::Sell, dec!(100), dec!(1.30), venue_order_id), FillSnapshot::new(4000, OrderSide::Buy, dec!(50), dec!(1.28), venue_order_id), FillSnapshot::new(5000, OrderSide::Buy, dec!(75), dec!(1.22), venue_order_id), FillSnapshot::new(6000, OrderSide::Sell, dec!(150), dec!(1.24), venue_order_id), ];
2520
2521 let crossings = detect_zero_crossings(&fills);
2522 assert_eq!(crossings.len(), 3);
2523 assert_eq!(crossings[0], 3000); assert_eq!(crossings[1], 4000); assert_eq!(crossings[2], 6000); }
2527
2528 #[rstest]
2529 fn test_reconciliation_price_partial_close() {
2530 let venue_order_id = create_test_venue_order_id("ORDER1");
2531 let recon_px =
2533 calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(50), Some(dec!(1.20)))
2534 .expect("reconciliation price");
2535
2536 let fills = vec![
2538 FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
2539 FillSnapshot::new(2000, OrderSide::Sell, dec!(50), recon_px, venue_order_id),
2540 ];
2541
2542 let (final_qty, final_value) = simulate_position(&fills);
2543 assert_eq!(final_qty, dec!(50));
2544 let final_avg = final_value / final_qty.abs();
2545 assert_eq!(final_avg, dec!(1.20), "Average should be maintained");
2546 }
2547
2548 #[rstest]
2549 fn test_detect_zero_crossings_identical_timestamps() {
2550 let venue_order_id1 = create_test_venue_order_id("ORDER1");
2551 let venue_order_id2 = create_test_venue_order_id("ORDER2");
2552
2553 let fills = vec![
2555 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id1),
2556 FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(102), venue_order_id1),
2557 FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(103), venue_order_id2), ];
2559
2560 let crossings = detect_zero_crossings(&fills);
2561
2562 assert_eq!(crossings.len(), 1);
2564 assert_eq!(crossings[0], 2000);
2565
2566 let (qty, _) = simulate_position(&fills);
2568 assert_eq!(qty, dec!(0));
2569 }
2570
2571 #[rstest]
2572 fn test_detect_zero_crossings_five_lifecycles() {
2573 let venue_order_id = create_test_venue_order_id("ORDER1");
2574
2575 let fills = vec![
2577 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2579 FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(101), venue_order_id),
2580 FillSnapshot::new(3000, OrderSide::Sell, dec!(20), dec!(102), venue_order_id),
2582 FillSnapshot::new(4000, OrderSide::Buy, dec!(20), dec!(101), venue_order_id),
2583 FillSnapshot::new(5000, OrderSide::Buy, dec!(15), dec!(103), venue_order_id),
2585 FillSnapshot::new(6000, OrderSide::Sell, dec!(15), dec!(104), venue_order_id),
2586 FillSnapshot::new(7000, OrderSide::Sell, dec!(25), dec!(105), venue_order_id),
2588 FillSnapshot::new(8000, OrderSide::Buy, dec!(25), dec!(104), venue_order_id),
2589 FillSnapshot::new(9000, OrderSide::Buy, dec!(30), dec!(106), venue_order_id),
2591 ];
2592
2593 let crossings = detect_zero_crossings(&fills);
2594
2595 assert_eq!(crossings.len(), 4);
2597 assert_eq!(crossings[0], 2000);
2598 assert_eq!(crossings[1], 4000);
2599 assert_eq!(crossings[2], 6000);
2600 assert_eq!(crossings[3], 8000);
2601
2602 let (qty, _) = simulate_position(&fills);
2604 assert_eq!(qty, dec!(30));
2605 }
2606
2607 #[rstest]
2608 fn test_adjust_fills_five_zero_crossings(instrument: InstrumentAny) {
2609 let venue_order_id = create_test_venue_order_id("ORDER1");
2610
2611 let fills = vec![
2613 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2615 FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(101), venue_order_id),
2616 FillSnapshot::new(3000, OrderSide::Sell, dec!(20), dec!(102), venue_order_id),
2617 FillSnapshot::new(4000, OrderSide::Buy, dec!(20), dec!(101), venue_order_id),
2618 FillSnapshot::new(5000, OrderSide::Buy, dec!(15), dec!(103), venue_order_id),
2619 FillSnapshot::new(6000, OrderSide::Sell, dec!(15), dec!(104), venue_order_id),
2620 FillSnapshot::new(7000, OrderSide::Sell, dec!(25), dec!(105), venue_order_id),
2621 FillSnapshot::new(8000, OrderSide::Buy, dec!(25), dec!(104), venue_order_id),
2622 FillSnapshot::new(9000, OrderSide::Buy, dec!(30), dec!(106), venue_order_id),
2624 ];
2625
2626 let venue_position = VenuePositionSnapshot {
2627 side: OrderSide::Buy,
2628 qty: dec!(30),
2629 avg_px: dec!(106),
2630 };
2631
2632 let result =
2633 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2634
2635 match result {
2637 FillAdjustmentResult::FilterToCurrentLifecycle {
2638 last_zero_crossing_ts,
2639 current_lifecycle_fills,
2640 } => {
2641 assert_eq!(last_zero_crossing_ts, 8000);
2642 assert_eq!(current_lifecycle_fills.len(), 1);
2643 assert_eq!(current_lifecycle_fills[0].ts_event, 9000);
2644 assert_eq!(current_lifecycle_fills[0].qty, dec!(30));
2645 }
2646 _ => panic!("Expected FilterToCurrentLifecycle, was {result:?}"),
2647 }
2648 }
2649
2650 #[rstest]
2651 fn test_adjust_fills_alternating_long_short_positions(instrument: InstrumentAny) {
2652 let venue_order_id = create_test_venue_order_id("ORDER1");
2653
2654 let fills = vec![
2657 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2658 FillSnapshot::new(2000, OrderSide::Sell, dec!(20), dec!(102), venue_order_id), FillSnapshot::new(3000, OrderSide::Buy, dec!(20), dec!(101), venue_order_id), FillSnapshot::new(4000, OrderSide::Sell, dec!(20), dec!(103), venue_order_id), FillSnapshot::new(5000, OrderSide::Buy, dec!(20), dec!(102), venue_order_id), ];
2663
2664 let venue_position = VenuePositionSnapshot {
2666 side: OrderSide::Buy,
2667 qty: dec!(10),
2668 avg_px: dec!(102),
2669 };
2670
2671 let result =
2672 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2673
2674 assert!(
2678 matches!(result, FillAdjustmentResult::NoAdjustment),
2679 "Expected NoAdjustment (continuous lifecycle with matching position), was {result:?}"
2680 );
2681 }
2682
2683 #[rstest]
2684 fn test_adjust_fills_with_flat_crossings(instrument: InstrumentAny) {
2685 let venue_order_id = create_test_venue_order_id("ORDER1");
2686
2687 let fills = vec![
2689 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2690 FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), FillSnapshot::new(3000, OrderSide::Sell, dec!(10), dec!(101), venue_order_id), FillSnapshot::new(4000, OrderSide::Buy, dec!(10), dec!(99), venue_order_id), FillSnapshot::new(5000, OrderSide::Buy, dec!(10), dec!(98), venue_order_id), ];
2695
2696 let venue_position = VenuePositionSnapshot {
2698 side: OrderSide::Buy,
2699 qty: dec!(10),
2700 avg_px: dec!(98),
2701 };
2702
2703 let result =
2704 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2705
2706 match result {
2709 FillAdjustmentResult::FilterToCurrentLifecycle {
2710 last_zero_crossing_ts,
2711 current_lifecycle_fills,
2712 } => {
2713 assert_eq!(last_zero_crossing_ts, 4000);
2714 assert_eq!(current_lifecycle_fills.len(), 1);
2715 assert_eq!(current_lifecycle_fills[0].ts_event, 5000);
2716 assert_eq!(current_lifecycle_fills[0].qty, dec!(10));
2717 }
2718 _ => panic!("Expected FilterToCurrentLifecycle, was {result:?}"),
2719 }
2720 }
2721
2722 #[rstest]
2723 fn test_replace_current_lifecycle_uses_first_venue_order_id(instrument: InstrumentAny) {
2724 let order_id_1 = create_test_venue_order_id("ORDER1");
2725 let order_id_2 = create_test_venue_order_id("ORDER2");
2726 let order_id_3 = create_test_venue_order_id("ORDER3");
2727
2728 let fills = vec![
2730 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), order_id_1),
2731 FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), order_id_1), FillSnapshot::new(3000, OrderSide::Buy, dec!(5), dec!(103), order_id_2),
2734 FillSnapshot::new(4000, OrderSide::Buy, dec!(5), dec!(104), order_id_3),
2735 ];
2736
2737 let venue_position = VenuePositionSnapshot {
2739 side: OrderSide::Buy,
2740 qty: dec!(15),
2741 avg_px: dec!(105),
2742 };
2743
2744 let result =
2745 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2746
2747 match result {
2749 FillAdjustmentResult::ReplaceCurrentLifecycle {
2750 synthetic_fill,
2751 first_venue_order_id,
2752 } => {
2753 assert_eq!(first_venue_order_id, order_id_2);
2754 assert_eq!(synthetic_fill.venue_order_id, order_id_2);
2755 assert_eq!(synthetic_fill.qty, dec!(15));
2756 assert_eq!(synthetic_fill.px, dec!(105));
2757 }
2758 _ => panic!("Expected ReplaceCurrentLifecycle, was {result:?}"),
2759 }
2760 }
2761
2762 fn make_test_report(
2763 instrument_id: InstrumentId,
2764 order_type: OrderType,
2765 status: OrderStatus,
2766 filled_qty: &str,
2767 post_only: bool,
2768 ) -> OrderStatusReport {
2769 let account_id = AccountId::from("TEST-001");
2770 let mut report = OrderStatusReport::new(
2771 account_id,
2772 instrument_id,
2773 None,
2774 VenueOrderId::from("V-001"),
2775 OrderSide::Buy,
2776 order_type,
2777 TimeInForce::Gtc,
2778 status,
2779 Quantity::from("1.0"),
2780 Quantity::from(filled_qty),
2781 UnixNanos::from(1_000_000),
2782 UnixNanos::from(1_000_000),
2783 UnixNanos::from(1_000_000),
2784 None,
2785 )
2786 .with_price(Price::from("100.00"))
2787 .with_avg_px(100.0)
2788 .unwrap();
2789 report.post_only = post_only;
2790 report
2791 }
2792
2793 #[rstest]
2794 #[case::accepted(OrderStatus::Accepted, "0", 1, "Accepted")]
2795 #[case::triggered(OrderStatus::Triggered, "0", 1, "Accepted")]
2796 #[case::canceled(OrderStatus::Canceled, "0", 2, "Canceled")]
2797 #[case::expired(OrderStatus::Expired, "0", 2, "Expired")]
2798 #[case::filled(OrderStatus::Filled, "1.0", 2, "Filled")]
2799 #[case::partially_filled(OrderStatus::PartiallyFilled, "0.5", 2, "Filled")]
2800 #[case::rejected(OrderStatus::Rejected, "0", 1, "Rejected")]
2801 fn test_external_order_status_event_generation(
2802 #[case] status: OrderStatus,
2803 #[case] filled_qty: &str,
2804 #[case] expected_events: usize,
2805 #[case] last_event_type: &str,
2806 ) {
2807 let instrument = crypto_perpetual_ethusdt();
2808 let order = OrderTestBuilder::new(OrderType::Limit)
2809 .instrument_id(instrument.id())
2810 .side(OrderSide::Buy)
2811 .quantity(Quantity::from("1.0"))
2812 .price(Price::from("100.00"))
2813 .build();
2814 let report = make_test_report(instrument.id(), OrderType::Limit, status, filled_qty, false);
2815
2816 let events = generate_external_order_status_events(
2817 &order,
2818 &report,
2819 &AccountId::from("TEST-001"),
2820 &InstrumentAny::CryptoPerpetual(instrument),
2821 UnixNanos::from(2_000_000),
2822 );
2823
2824 assert_eq!(events.len(), expected_events, "status={status}");
2825 let last = events.last().unwrap();
2826 let actual_type = match last {
2827 OrderEventAny::Accepted(_) => "Accepted",
2828 OrderEventAny::Canceled(_) => "Canceled",
2829 OrderEventAny::Expired(_) => "Expired",
2830 OrderEventAny::Filled(_) => "Filled",
2831 OrderEventAny::Rejected(_) => "Rejected",
2832 _ => "Other",
2833 };
2834 assert_eq!(actual_type, last_event_type, "status={status}");
2835 }
2836
2837 #[rstest]
2838 #[case::market(OrderType::Market, false, LiquiditySide::Taker)]
2839 #[case::stop_market(OrderType::StopMarket, false, LiquiditySide::Taker)]
2840 #[case::trailing_stop_market(OrderType::TrailingStopMarket, false, LiquiditySide::Taker)]
2841 #[case::limit_post_only(OrderType::Limit, true, LiquiditySide::Maker)]
2842 #[case::limit_default(OrderType::Limit, false, LiquiditySide::NoLiquiditySide)]
2843 fn test_inferred_fill_liquidity_side(
2844 #[case] order_type: OrderType,
2845 #[case] post_only: bool,
2846 #[case] expected: LiquiditySide,
2847 ) {
2848 let instrument = crypto_perpetual_ethusdt();
2849 let order = match order_type {
2850 OrderType::Limit => OrderTestBuilder::new(order_type)
2851 .instrument_id(instrument.id())
2852 .side(OrderSide::Buy)
2853 .quantity(Quantity::from("1.0"))
2854 .price(Price::from("100.00"))
2855 .build(),
2856 OrderType::StopMarket => OrderTestBuilder::new(order_type)
2857 .instrument_id(instrument.id())
2858 .side(OrderSide::Buy)
2859 .quantity(Quantity::from("1.0"))
2860 .trigger_price(Price::from("100.00"))
2861 .build(),
2862 OrderType::TrailingStopMarket => OrderTestBuilder::new(order_type)
2863 .instrument_id(instrument.id())
2864 .side(OrderSide::Buy)
2865 .quantity(Quantity::from("1.0"))
2866 .trigger_price(Price::from("100.00"))
2867 .trailing_offset(dec!(1.0))
2868 .build(),
2869 _ => OrderTestBuilder::new(order_type)
2870 .instrument_id(instrument.id())
2871 .side(OrderSide::Buy)
2872 .quantity(Quantity::from("1.0"))
2873 .build(),
2874 };
2875 let report = make_test_report(
2876 instrument.id(),
2877 order_type,
2878 OrderStatus::Filled,
2879 "1.0",
2880 post_only,
2881 );
2882
2883 let fill = create_inferred_fill(
2884 &order,
2885 &report,
2886 &AccountId::from("TEST-001"),
2887 &InstrumentAny::CryptoPerpetual(instrument),
2888 UnixNanos::from(2_000_000),
2889 );
2890
2891 let filled = match fill.unwrap() {
2892 OrderEventAny::Filled(f) => f,
2893 _ => panic!("Expected Filled event"),
2894 };
2895 assert_eq!(
2896 filled.liquidity_side, expected,
2897 "order_type={order_type}, post_only={post_only}"
2898 );
2899 }
2900
2901 #[rstest]
2902 fn test_inferred_fill_no_price_returns_none() {
2903 let instrument = crypto_perpetual_ethusdt();
2904 let order = OrderTestBuilder::new(OrderType::Market)
2905 .instrument_id(instrument.id())
2906 .side(OrderSide::Buy)
2907 .quantity(Quantity::from("1.0"))
2908 .build();
2909
2910 let report = OrderStatusReport::new(
2911 AccountId::from("TEST-001"),
2912 instrument.id(),
2913 None,
2914 VenueOrderId::from("V-001"),
2915 OrderSide::Buy,
2916 OrderType::Market,
2917 TimeInForce::Ioc,
2918 OrderStatus::Filled,
2919 Quantity::from("1.0"),
2920 Quantity::from("1.0"),
2921 UnixNanos::from(1_000_000),
2922 UnixNanos::from(1_000_000),
2923 UnixNanos::from(1_000_000),
2924 None,
2925 );
2926
2927 let fill = create_inferred_fill(
2928 &order,
2929 &report,
2930 &AccountId::from("TEST-001"),
2931 &InstrumentAny::CryptoPerpetual(instrument),
2932 UnixNanos::from(2_000_000),
2933 );
2934
2935 assert!(fill.is_none());
2936 }
2937
2938 fn create_test_fill_report(
2941 instrument_id: InstrumentId,
2942 venue_order_id: VenueOrderId,
2943 trade_id: TradeId,
2944 last_qty: Quantity,
2945 last_px: Price,
2946 ) -> FillReport {
2947 FillReport::new(
2948 AccountId::from("TEST-001"),
2949 instrument_id,
2950 venue_order_id,
2951 trade_id,
2952 OrderSide::Buy,
2953 last_qty,
2954 last_px,
2955 Money::new(0.10, Currency::USD()),
2956 LiquiditySide::Taker,
2957 None,
2958 None,
2959 UnixNanos::from(1_000_000),
2960 UnixNanos::from(1_000_000),
2961 None,
2962 )
2963 }
2964
2965 #[rstest]
2966 fn test_reconcile_fill_report_success(instrument: InstrumentAny) {
2967 let order = OrderTestBuilder::new(OrderType::Market)
2968 .instrument_id(instrument.id())
2969 .side(OrderSide::Buy)
2970 .quantity(Quantity::from("100"))
2971 .build();
2972
2973 let fill_report = create_test_fill_report(
2974 instrument.id(),
2975 VenueOrderId::from("V-001"),
2976 TradeId::from("T-001"),
2977 Quantity::from("50"),
2978 Price::from("1.00000"),
2979 );
2980
2981 let result = reconcile_fill_report(
2982 &order,
2983 &fill_report,
2984 &instrument,
2985 UnixNanos::from(2_000_000),
2986 false,
2987 );
2988
2989 assert!(result.is_some());
2990 if let Some(OrderEventAny::Filled(filled)) = result {
2991 assert_eq!(filled.last_qty, Quantity::from("50"));
2992 assert_eq!(filled.last_px, Price::from("1.00000"));
2993 assert_eq!(filled.trade_id, TradeId::from("T-001"));
2994 assert!(filled.reconciliation);
2995 } else {
2996 panic!("Expected OrderFilled event");
2997 }
2998 }
2999
3000 #[rstest]
3001 fn test_reconcile_fill_report_duplicate_detected(instrument: InstrumentAny) {
3002 let mut order = OrderTestBuilder::new(OrderType::Market)
3004 .instrument_id(instrument.id())
3005 .side(OrderSide::Buy)
3006 .quantity(Quantity::from("100"))
3007 .build();
3008
3009 let account_id = AccountId::from("TEST-001");
3010 let venue_order_id = VenueOrderId::from("V-001");
3011 let trade_id = TradeId::from("T-001");
3012
3013 let submitted = OrderSubmitted::new(
3015 order.trader_id(),
3016 order.strategy_id(),
3017 order.instrument_id(),
3018 order.client_order_id(),
3019 account_id,
3020 UUID4::new(),
3021 UnixNanos::from(500_000),
3022 UnixNanos::from(500_000),
3023 );
3024 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3025
3026 let accepted = OrderAccepted::new(
3028 order.trader_id(),
3029 order.strategy_id(),
3030 order.instrument_id(),
3031 order.client_order_id(),
3032 venue_order_id,
3033 account_id,
3034 UUID4::new(),
3035 UnixNanos::from(600_000),
3036 UnixNanos::from(600_000),
3037 false,
3038 );
3039 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
3040
3041 let filled_event = OrderFilled::new(
3043 order.trader_id(),
3044 order.strategy_id(),
3045 order.instrument_id(),
3046 order.client_order_id(),
3047 venue_order_id,
3048 account_id,
3049 trade_id,
3050 OrderSide::Buy,
3051 order.order_type(),
3052 Quantity::from("50"),
3053 Price::from("1.00000"),
3054 Currency::USD(),
3055 LiquiditySide::Taker,
3056 UUID4::new(),
3057 UnixNanos::from(1_000_000),
3058 UnixNanos::from(1_000_000),
3059 false,
3060 None,
3061 None,
3062 );
3063 order.apply(OrderEventAny::Filled(filled_event)).unwrap();
3064
3065 let fill_report = create_test_fill_report(
3067 instrument.id(),
3068 venue_order_id,
3069 trade_id, Quantity::from("50"),
3071 Price::from("1.00000"),
3072 );
3073
3074 let result = reconcile_fill_report(
3075 &order,
3076 &fill_report,
3077 &instrument,
3078 UnixNanos::from(2_000_000),
3079 false,
3080 );
3081
3082 assert!(result.is_none());
3083 }
3084
3085 #[rstest]
3086 fn test_reconcile_fill_report_overfill_rejected(instrument: InstrumentAny) {
3087 let order = OrderTestBuilder::new(OrderType::Market)
3088 .instrument_id(instrument.id())
3089 .side(OrderSide::Buy)
3090 .quantity(Quantity::from("100"))
3091 .build();
3092
3093 let fill_report = create_test_fill_report(
3095 instrument.id(),
3096 VenueOrderId::from("V-001"),
3097 TradeId::from("T-001"),
3098 Quantity::from("150"),
3099 Price::from("1.00000"),
3100 );
3101
3102 let result = reconcile_fill_report(
3103 &order,
3104 &fill_report,
3105 &instrument,
3106 UnixNanos::from(2_000_000),
3107 false, );
3109
3110 assert!(result.is_none());
3111 }
3112
3113 #[rstest]
3114 fn test_reconcile_fill_report_overfill_allowed(instrument: InstrumentAny) {
3115 let order = OrderTestBuilder::new(OrderType::Market)
3116 .instrument_id(instrument.id())
3117 .side(OrderSide::Buy)
3118 .quantity(Quantity::from("100"))
3119 .build();
3120
3121 let fill_report = create_test_fill_report(
3122 instrument.id(),
3123 VenueOrderId::from("V-001"),
3124 TradeId::from("T-001"),
3125 Quantity::from("150"),
3126 Price::from("1.00000"),
3127 );
3128
3129 let result = reconcile_fill_report(
3130 &order,
3131 &fill_report,
3132 &instrument,
3133 UnixNanos::from(2_000_000),
3134 true, );
3136
3137 assert!(result.is_some());
3139 }
3140
3141 #[rstest]
3144 fn test_check_position_reconciliation_both_flat() {
3145 let report = PositionStatusReport::new(
3146 AccountId::from("TEST-001"),
3147 InstrumentId::from("AUDUSD.SIM"),
3148 PositionSideSpecified::Flat,
3149 Quantity::from("0"),
3150 UnixNanos::from(1_000_000),
3151 UnixNanos::from(1_000_000),
3152 None,
3153 None,
3154 None,
3155 );
3156
3157 let result = check_position_reconciliation(&report, dec!(0), Some(5));
3158 assert!(result);
3159 }
3160
3161 #[rstest]
3162 fn test_check_position_reconciliation_exact_match_long() {
3163 let report = PositionStatusReport::new(
3164 AccountId::from("TEST-001"),
3165 InstrumentId::from("AUDUSD.SIM"),
3166 PositionSideSpecified::Long,
3167 Quantity::from("100"),
3168 UnixNanos::from(1_000_000),
3169 UnixNanos::from(1_000_000),
3170 None,
3171 None,
3172 None,
3173 );
3174
3175 let result = check_position_reconciliation(&report, dec!(100), Some(0));
3176 assert!(result);
3177 }
3178
3179 #[rstest]
3180 fn test_check_position_reconciliation_exact_match_short() {
3181 let report = PositionStatusReport::new(
3182 AccountId::from("TEST-001"),
3183 InstrumentId::from("AUDUSD.SIM"),
3184 PositionSideSpecified::Short,
3185 Quantity::from("50"),
3186 UnixNanos::from(1_000_000),
3187 UnixNanos::from(1_000_000),
3188 None,
3189 None,
3190 None,
3191 );
3192
3193 let result = check_position_reconciliation(&report, dec!(-50), Some(0));
3194 assert!(result);
3195 }
3196
3197 #[rstest]
3198 fn test_check_position_reconciliation_within_tolerance() {
3199 let report = PositionStatusReport::new(
3200 AccountId::from("TEST-001"),
3201 InstrumentId::from("AUDUSD.SIM"),
3202 PositionSideSpecified::Long,
3203 Quantity::from("100.00001"),
3204 UnixNanos::from(1_000_000),
3205 UnixNanos::from(1_000_000),
3206 None,
3207 None,
3208 None,
3209 );
3210
3211 let result = check_position_reconciliation(&report, dec!(100.00000), Some(5));
3213 assert!(result);
3214 }
3215
3216 #[rstest]
3217 fn test_check_position_reconciliation_discrepancy() {
3218 let report = PositionStatusReport::new(
3219 AccountId::from("TEST-001"),
3220 InstrumentId::from("AUDUSD.SIM"),
3221 PositionSideSpecified::Long,
3222 Quantity::from("100"),
3223 UnixNanos::from(1_000_000),
3224 UnixNanos::from(1_000_000),
3225 None,
3226 None,
3227 None,
3228 );
3229
3230 let result = check_position_reconciliation(&report, dec!(50), Some(0));
3232 assert!(!result);
3233 }
3234
3235 #[rstest]
3238 fn test_is_within_single_unit_tolerance_exact_match() {
3239 assert!(is_within_single_unit_tolerance(dec!(100), dec!(100), 0));
3240 assert!(is_within_single_unit_tolerance(
3241 dec!(100.12345),
3242 dec!(100.12345),
3243 5
3244 ));
3245 }
3246
3247 #[rstest]
3248 fn test_is_within_single_unit_tolerance_integer_precision() {
3249 assert!(is_within_single_unit_tolerance(dec!(100), dec!(100), 0));
3251 assert!(!is_within_single_unit_tolerance(dec!(100), dec!(101), 0));
3252 }
3253
3254 #[rstest]
3255 fn test_is_within_single_unit_tolerance_fractional_precision() {
3256 assert!(is_within_single_unit_tolerance(dec!(100), dec!(100.01), 2));
3258 assert!(is_within_single_unit_tolerance(dec!(100), dec!(99.99), 2));
3259 assert!(!is_within_single_unit_tolerance(dec!(100), dec!(100.02), 2));
3260 }
3261
3262 #[rstest]
3263 fn test_is_within_single_unit_tolerance_high_precision() {
3264 assert!(is_within_single_unit_tolerance(
3266 dec!(100),
3267 dec!(100.00001),
3268 5
3269 ));
3270 assert!(is_within_single_unit_tolerance(
3271 dec!(100),
3272 dec!(99.99999),
3273 5
3274 ));
3275 assert!(!is_within_single_unit_tolerance(
3276 dec!(100),
3277 dec!(100.00002),
3278 5
3279 ));
3280 }
3281
3282 fn create_test_order_status_report(
3283 client_order_id: ClientOrderId,
3284 venue_order_id: VenueOrderId,
3285 instrument_id: InstrumentId,
3286 order_type: OrderType,
3287 order_status: OrderStatus,
3288 quantity: Quantity,
3289 filled_qty: Quantity,
3290 ) -> OrderStatusReport {
3291 OrderStatusReport::new(
3292 AccountId::from("SIM-001"),
3293 instrument_id,
3294 Some(client_order_id),
3295 venue_order_id,
3296 OrderSide::Buy,
3297 order_type,
3298 TimeInForce::Gtc,
3299 order_status,
3300 quantity,
3301 filled_qty,
3302 UnixNanos::from(1_000_000),
3303 UnixNanos::from(1_000_000),
3304 UnixNanos::from(1_000_000),
3305 None,
3306 )
3307 }
3308
3309 #[rstest]
3310 #[case::identical_limit_order(
3311 OrderType::Limit,
3312 Quantity::from(100),
3313 Some(Price::from("1.00000")),
3314 None,
3315 Quantity::from(100),
3316 Some(Price::from("1.00000")),
3317 None,
3318 false
3319 )]
3320 #[case::quantity_changed(
3321 OrderType::Limit,
3322 Quantity::from(100),
3323 Some(Price::from("1.00000")),
3324 None,
3325 Quantity::from(150),
3326 Some(Price::from("1.00000")),
3327 None,
3328 true
3329 )]
3330 #[case::limit_price_changed(
3331 OrderType::Limit,
3332 Quantity::from(100),
3333 Some(Price::from("1.00000")),
3334 None,
3335 Quantity::from(100),
3336 Some(Price::from("1.00100")),
3337 None,
3338 true
3339 )]
3340 #[case::stop_trigger_changed(
3341 OrderType::StopMarket,
3342 Quantity::from(100),
3343 None,
3344 Some(Price::from("0.99000")),
3345 Quantity::from(100),
3346 None,
3347 Some(Price::from("0.98000")),
3348 true
3349 )]
3350 #[case::stop_limit_trigger_changed(
3351 OrderType::StopLimit,
3352 Quantity::from(100),
3353 Some(Price::from("1.00000")),
3354 Some(Price::from("0.99000")),
3355 Quantity::from(100),
3356 Some(Price::from("1.00000")),
3357 Some(Price::from("0.98000")),
3358 true
3359 )]
3360 #[case::stop_limit_price_changed(
3361 OrderType::StopLimit,
3362 Quantity::from(100),
3363 Some(Price::from("1.00000")),
3364 Some(Price::from("0.99000")),
3365 Quantity::from(100),
3366 Some(Price::from("1.00100")),
3367 Some(Price::from("0.99000")),
3368 true
3369 )]
3370 #[case::market_order_no_update(
3371 OrderType::Market,
3372 Quantity::from(100),
3373 None,
3374 None,
3375 Quantity::from(100),
3376 None,
3377 None,
3378 false
3379 )]
3380 fn test_should_reconciliation_update(
3381 instrument: InstrumentAny,
3382 #[case] order_type: OrderType,
3383 #[case] order_qty: Quantity,
3384 #[case] order_price: Option<Price>,
3385 #[case] order_trigger: Option<Price>,
3386 #[case] report_qty: Quantity,
3387 #[case] report_price: Option<Price>,
3388 #[case] report_trigger: Option<Price>,
3389 #[case] expected: bool,
3390 ) {
3391 let client_order_id = ClientOrderId::from("O-001");
3392 let venue_order_id = VenueOrderId::from("V-001");
3393
3394 let mut order = match (order_price, order_trigger) {
3395 (Some(price), Some(trigger)) => OrderTestBuilder::new(order_type)
3396 .instrument_id(instrument.id())
3397 .client_order_id(client_order_id)
3398 .side(OrderSide::Buy)
3399 .quantity(order_qty)
3400 .price(price)
3401 .trigger_price(trigger)
3402 .build(),
3403 (Some(price), None) => OrderTestBuilder::new(order_type)
3404 .instrument_id(instrument.id())
3405 .client_order_id(client_order_id)
3406 .side(OrderSide::Buy)
3407 .quantity(order_qty)
3408 .price(price)
3409 .build(),
3410 (None, Some(trigger)) => OrderTestBuilder::new(order_type)
3411 .instrument_id(instrument.id())
3412 .client_order_id(client_order_id)
3413 .side(OrderSide::Buy)
3414 .quantity(order_qty)
3415 .trigger_price(trigger)
3416 .build(),
3417 (None, None) => OrderTestBuilder::new(order_type)
3418 .instrument_id(instrument.id())
3419 .client_order_id(client_order_id)
3420 .side(OrderSide::Buy)
3421 .quantity(order_qty)
3422 .build(),
3423 };
3424
3425 let submitted = OrderSubmitted::new(
3426 order.trader_id(),
3427 order.strategy_id(),
3428 order.instrument_id(),
3429 order.client_order_id(),
3430 AccountId::from("SIM-001"),
3431 UUID4::new(),
3432 UnixNanos::default(),
3433 UnixNanos::default(),
3434 );
3435 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3436
3437 let accepted = OrderAccepted::new(
3438 order.trader_id(),
3439 order.strategy_id(),
3440 order.instrument_id(),
3441 order.client_order_id(),
3442 venue_order_id,
3443 AccountId::from("SIM-001"),
3444 UUID4::new(),
3445 UnixNanos::default(),
3446 UnixNanos::default(),
3447 false,
3448 );
3449 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
3450
3451 let mut report = create_test_order_status_report(
3452 client_order_id,
3453 venue_order_id,
3454 instrument.id(),
3455 order_type,
3456 OrderStatus::Accepted,
3457 report_qty,
3458 Quantity::from(0),
3459 );
3460 report.price = report_price;
3461 report.trigger_price = report_trigger;
3462
3463 assert_eq!(should_reconciliation_update(&order, &report), expected);
3464 }
3465
3466 #[rstest]
3467 fn test_reconcile_order_report_already_in_sync(instrument: InstrumentAny) {
3468 let client_order_id = ClientOrderId::from("O-001");
3469 let venue_order_id = VenueOrderId::from("V-001");
3470
3471 let mut order = OrderTestBuilder::new(OrderType::Limit)
3472 .instrument_id(instrument.id())
3473 .client_order_id(client_order_id)
3474 .side(OrderSide::Buy)
3475 .quantity(Quantity::from(100))
3476 .price(Price::from("1.00000"))
3477 .build();
3478
3479 let submitted = OrderSubmitted::new(
3480 order.trader_id(),
3481 order.strategy_id(),
3482 order.instrument_id(),
3483 order.client_order_id(),
3484 AccountId::from("SIM-001"),
3485 UUID4::new(),
3486 UnixNanos::default(),
3487 UnixNanos::default(),
3488 );
3489 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3490
3491 let accepted = OrderAccepted::new(
3492 order.trader_id(),
3493 order.strategy_id(),
3494 order.instrument_id(),
3495 order.client_order_id(),
3496 venue_order_id,
3497 AccountId::from("SIM-001"),
3498 UUID4::new(),
3499 UnixNanos::default(),
3500 UnixNanos::default(),
3501 false,
3502 );
3503 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
3504
3505 let mut report = create_test_order_status_report(
3506 client_order_id,
3507 venue_order_id,
3508 instrument.id(),
3509 OrderType::Limit,
3510 OrderStatus::Accepted,
3511 Quantity::from(100),
3512 Quantity::from(0),
3513 );
3514 report.price = Some(Price::from("1.00000"));
3515
3516 let result =
3517 reconcile_order_report(&order, &report, Some(&instrument), UnixNanos::default());
3518 assert!(result.is_none());
3519 }
3520
3521 #[rstest]
3522 fn test_reconcile_order_report_generates_canceled(instrument: InstrumentAny) {
3523 let client_order_id = ClientOrderId::from("O-001");
3524 let venue_order_id = VenueOrderId::from("V-001");
3525
3526 let mut order = OrderTestBuilder::new(OrderType::Limit)
3527 .instrument_id(instrument.id())
3528 .client_order_id(client_order_id)
3529 .side(OrderSide::Buy)
3530 .quantity(Quantity::from(100))
3531 .price(Price::from("1.00000"))
3532 .build();
3533
3534 let submitted = OrderSubmitted::new(
3535 order.trader_id(),
3536 order.strategy_id(),
3537 order.instrument_id(),
3538 order.client_order_id(),
3539 AccountId::from("SIM-001"),
3540 UUID4::new(),
3541 UnixNanos::default(),
3542 UnixNanos::default(),
3543 );
3544 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3545
3546 let accepted = OrderAccepted::new(
3547 order.trader_id(),
3548 order.strategy_id(),
3549 order.instrument_id(),
3550 order.client_order_id(),
3551 venue_order_id,
3552 AccountId::from("SIM-001"),
3553 UUID4::new(),
3554 UnixNanos::default(),
3555 UnixNanos::default(),
3556 false,
3557 );
3558 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
3559
3560 let report = create_test_order_status_report(
3561 client_order_id,
3562 venue_order_id,
3563 instrument.id(),
3564 OrderType::Limit,
3565 OrderStatus::Canceled,
3566 Quantity::from(100),
3567 Quantity::from(0),
3568 );
3569
3570 let result =
3571 reconcile_order_report(&order, &report, Some(&instrument), UnixNanos::default());
3572 assert!(result.is_some());
3573 assert!(matches!(result.unwrap(), OrderEventAny::Canceled(_)));
3574 }
3575
3576 #[rstest]
3577 fn test_reconcile_order_report_generates_expired(instrument: InstrumentAny) {
3578 let client_order_id = ClientOrderId::from("O-001");
3579 let venue_order_id = VenueOrderId::from("V-001");
3580
3581 let mut order = OrderTestBuilder::new(OrderType::Limit)
3582 .instrument_id(instrument.id())
3583 .client_order_id(client_order_id)
3584 .side(OrderSide::Buy)
3585 .quantity(Quantity::from(100))
3586 .price(Price::from("1.00000"))
3587 .build();
3588
3589 let submitted = OrderSubmitted::new(
3590 order.trader_id(),
3591 order.strategy_id(),
3592 order.instrument_id(),
3593 order.client_order_id(),
3594 AccountId::from("SIM-001"),
3595 UUID4::new(),
3596 UnixNanos::default(),
3597 UnixNanos::default(),
3598 );
3599 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3600
3601 let accepted = OrderAccepted::new(
3602 order.trader_id(),
3603 order.strategy_id(),
3604 order.instrument_id(),
3605 order.client_order_id(),
3606 venue_order_id,
3607 AccountId::from("SIM-001"),
3608 UUID4::new(),
3609 UnixNanos::default(),
3610 UnixNanos::default(),
3611 false,
3612 );
3613 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
3614
3615 let report = create_test_order_status_report(
3616 client_order_id,
3617 venue_order_id,
3618 instrument.id(),
3619 OrderType::Limit,
3620 OrderStatus::Expired,
3621 Quantity::from(100),
3622 Quantity::from(0),
3623 );
3624
3625 let result =
3626 reconcile_order_report(&order, &report, Some(&instrument), UnixNanos::default());
3627 assert!(result.is_some());
3628 assert!(matches!(result.unwrap(), OrderEventAny::Expired(_)));
3629 }
3630
3631 #[rstest]
3632 fn test_reconcile_order_report_generates_rejected(instrument: InstrumentAny) {
3633 let client_order_id = ClientOrderId::from("O-001");
3634 let venue_order_id = VenueOrderId::from("V-001");
3635
3636 let mut order = OrderTestBuilder::new(OrderType::Limit)
3637 .instrument_id(instrument.id())
3638 .client_order_id(client_order_id)
3639 .side(OrderSide::Buy)
3640 .quantity(Quantity::from(100))
3641 .price(Price::from("1.00000"))
3642 .build();
3643
3644 let submitted = OrderSubmitted::new(
3645 order.trader_id(),
3646 order.strategy_id(),
3647 order.instrument_id(),
3648 order.client_order_id(),
3649 AccountId::from("SIM-001"),
3650 UUID4::new(),
3651 UnixNanos::default(),
3652 UnixNanos::default(),
3653 );
3654 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3655
3656 let mut report = create_test_order_status_report(
3657 client_order_id,
3658 venue_order_id,
3659 instrument.id(),
3660 OrderType::Limit,
3661 OrderStatus::Rejected,
3662 Quantity::from(100),
3663 Quantity::from(0),
3664 );
3665 report.cancel_reason = Some("INSUFFICIENT_MARGIN".to_string());
3666
3667 let result =
3668 reconcile_order_report(&order, &report, Some(&instrument), UnixNanos::default());
3669 assert!(result.is_some());
3670 if let OrderEventAny::Rejected(rejected) = result.unwrap() {
3671 assert_eq!(rejected.reason.as_str(), "INSUFFICIENT_MARGIN");
3672 assert_eq!(rejected.reconciliation, 1);
3673 } else {
3674 panic!("Expected Rejected event");
3675 }
3676 }
3677
3678 #[rstest]
3679 fn test_reconcile_order_report_generates_updated(instrument: InstrumentAny) {
3680 let client_order_id = ClientOrderId::from("O-001");
3681 let venue_order_id = VenueOrderId::from("V-001");
3682
3683 let mut order = OrderTestBuilder::new(OrderType::Limit)
3684 .instrument_id(instrument.id())
3685 .client_order_id(client_order_id)
3686 .side(OrderSide::Buy)
3687 .quantity(Quantity::from(100))
3688 .price(Price::from("1.00000"))
3689 .build();
3690
3691 let submitted = OrderSubmitted::new(
3692 order.trader_id(),
3693 order.strategy_id(),
3694 order.instrument_id(),
3695 order.client_order_id(),
3696 AccountId::from("SIM-001"),
3697 UUID4::new(),
3698 UnixNanos::default(),
3699 UnixNanos::default(),
3700 );
3701 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3702
3703 let accepted = OrderAccepted::new(
3704 order.trader_id(),
3705 order.strategy_id(),
3706 order.instrument_id(),
3707 order.client_order_id(),
3708 venue_order_id,
3709 AccountId::from("SIM-001"),
3710 UUID4::new(),
3711 UnixNanos::default(),
3712 UnixNanos::default(),
3713 false,
3714 );
3715 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
3716
3717 let mut report = create_test_order_status_report(
3719 client_order_id,
3720 venue_order_id,
3721 instrument.id(),
3722 OrderType::Limit,
3723 OrderStatus::Accepted,
3724 Quantity::from(100),
3725 Quantity::from(0),
3726 );
3727 report.price = Some(Price::from("1.00100"));
3728
3729 let result =
3730 reconcile_order_report(&order, &report, Some(&instrument), UnixNanos::default());
3731 assert!(result.is_some());
3732 assert!(matches!(result.unwrap(), OrderEventAny::Updated(_)));
3733 }
3734
3735 #[rstest]
3736 fn test_reconcile_order_report_generates_fill_for_qty_mismatch(instrument: InstrumentAny) {
3737 let client_order_id = ClientOrderId::from("O-001");
3738 let venue_order_id = VenueOrderId::from("V-001");
3739
3740 let mut order = OrderTestBuilder::new(OrderType::Limit)
3741 .instrument_id(instrument.id())
3742 .client_order_id(client_order_id)
3743 .side(OrderSide::Buy)
3744 .quantity(Quantity::from(100))
3745 .price(Price::from("1.00000"))
3746 .build();
3747
3748 let submitted = OrderSubmitted::new(
3749 order.trader_id(),
3750 order.strategy_id(),
3751 order.instrument_id(),
3752 order.client_order_id(),
3753 AccountId::from("SIM-001"),
3754 UUID4::new(),
3755 UnixNanos::default(),
3756 UnixNanos::default(),
3757 );
3758 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3759
3760 let accepted = OrderAccepted::new(
3761 order.trader_id(),
3762 order.strategy_id(),
3763 order.instrument_id(),
3764 order.client_order_id(),
3765 venue_order_id,
3766 AccountId::from("SIM-001"),
3767 UUID4::new(),
3768 UnixNanos::default(),
3769 UnixNanos::default(),
3770 false,
3771 );
3772 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
3773
3774 let mut report = create_test_order_status_report(
3776 client_order_id,
3777 venue_order_id,
3778 instrument.id(),
3779 OrderType::Limit,
3780 OrderStatus::PartiallyFilled,
3781 Quantity::from(100),
3782 Quantity::from(50),
3783 );
3784 report.avg_px = Some(dec!(1.0));
3785
3786 let result =
3787 reconcile_order_report(&order, &report, Some(&instrument), UnixNanos::default());
3788 assert!(result.is_some());
3789 assert!(matches!(result.unwrap(), OrderEventAny::Filled(_)));
3790 }
3791
3792 #[rstest]
3793 fn test_create_reconciliation_rejected_with_reason() {
3794 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3795 let client_order_id = ClientOrderId::from("O-001");
3796
3797 let mut order = OrderTestBuilder::new(OrderType::Limit)
3798 .instrument_id(instrument.id())
3799 .client_order_id(client_order_id)
3800 .side(OrderSide::Buy)
3801 .quantity(Quantity::from(100))
3802 .price(Price::from("1.00000"))
3803 .build();
3804
3805 let submitted = OrderSubmitted::new(
3806 order.trader_id(),
3807 order.strategy_id(),
3808 order.instrument_id(),
3809 order.client_order_id(),
3810 AccountId::from("SIM-001"),
3811 UUID4::new(),
3812 UnixNanos::default(),
3813 UnixNanos::default(),
3814 );
3815 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3816
3817 let result =
3818 create_reconciliation_rejected(&order, Some("MARGIN_CALL"), UnixNanos::from(1_000));
3819 assert!(result.is_some());
3820 if let OrderEventAny::Rejected(rejected) = result.unwrap() {
3821 assert_eq!(rejected.reason.as_str(), "MARGIN_CALL");
3822 assert_eq!(rejected.reconciliation, 1);
3823 assert_eq!(rejected.due_post_only, 0);
3824 } else {
3825 panic!("Expected Rejected event");
3826 }
3827 }
3828
3829 #[rstest]
3830 fn test_create_reconciliation_rejected_without_reason() {
3831 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3832 let client_order_id = ClientOrderId::from("O-001");
3833
3834 let mut order = OrderTestBuilder::new(OrderType::Limit)
3835 .instrument_id(instrument.id())
3836 .client_order_id(client_order_id)
3837 .side(OrderSide::Buy)
3838 .quantity(Quantity::from(100))
3839 .price(Price::from("1.00000"))
3840 .build();
3841
3842 let submitted = OrderSubmitted::new(
3843 order.trader_id(),
3844 order.strategy_id(),
3845 order.instrument_id(),
3846 order.client_order_id(),
3847 AccountId::from("SIM-001"),
3848 UUID4::new(),
3849 UnixNanos::default(),
3850 UnixNanos::default(),
3851 );
3852 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3853
3854 let result = create_reconciliation_rejected(&order, None, UnixNanos::from(1_000));
3855 assert!(result.is_some());
3856 if let OrderEventAny::Rejected(rejected) = result.unwrap() {
3857 assert_eq!(rejected.reason.as_str(), "UNKNOWN");
3858 } else {
3859 panic!("Expected Rejected event");
3860 }
3861 }
3862
3863 #[rstest]
3864 fn test_create_reconciliation_rejected_no_account_id() {
3865 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3866 let client_order_id = ClientOrderId::from("O-001");
3867
3868 let order = OrderTestBuilder::new(OrderType::Limit)
3870 .instrument_id(instrument.id())
3871 .client_order_id(client_order_id)
3872 .side(OrderSide::Buy)
3873 .quantity(Quantity::from(100))
3874 .price(Price::from("1.00000"))
3875 .build();
3876
3877 let result = create_reconciliation_rejected(&order, Some("TEST"), UnixNanos::from(1_000));
3878 assert!(result.is_none());
3879 }
3880
3881 #[rstest]
3882 fn test_create_synthetic_venue_order_id_format() {
3883 let ts = 1_000_000_u64;
3884
3885 let id = create_synthetic_venue_order_id(ts);
3886
3887 assert!(id.as_str().starts_with("S-"));
3889 let parts: Vec<&str> = id.as_str().split('-').collect();
3890 assert_eq!(parts.len(), 3);
3891 assert_eq!(parts[0], "S");
3892 assert!(!parts[1].is_empty());
3893 }
3894
3895 #[rstest]
3896 fn test_create_synthetic_trade_id_format() {
3897 let ts = 1_000_000_u64;
3898
3899 let id = create_synthetic_trade_id(ts);
3900
3901 assert!(id.as_str().starts_with("S-"));
3903 let parts: Vec<&str> = id.as_str().split('-').collect();
3904 assert_eq!(parts.len(), 3);
3905 assert_eq!(parts[0], "S");
3906 assert!(!parts[1].is_empty());
3907 }
3908
3909 #[rstest]
3910 fn test_create_inferred_fill_for_qty_zero_quantity_returns_none() {
3911 let instrument = crypto_perpetual_ethusdt();
3912 let order = OrderTestBuilder::new(OrderType::Limit)
3913 .instrument_id(instrument.id())
3914 .side(OrderSide::Buy)
3915 .quantity(Quantity::from("10.0"))
3916 .price(Price::from("100.00"))
3917 .build();
3918
3919 let report = make_test_report(
3920 instrument.id(),
3921 OrderType::Limit,
3922 OrderStatus::Filled,
3923 "10.0",
3924 false,
3925 );
3926
3927 let result = create_inferred_fill_for_qty(
3928 &order,
3929 &report,
3930 &AccountId::from("TEST-001"),
3931 &InstrumentAny::CryptoPerpetual(instrument),
3932 Quantity::zero(0),
3933 UnixNanos::from(1_000_000),
3934 );
3935
3936 assert!(result.is_none());
3937 }
3938
3939 #[rstest]
3940 fn test_create_inferred_fill_for_qty_uses_report_avg_px() {
3941 let instrument = crypto_perpetual_ethusdt();
3942 let order = OrderTestBuilder::new(OrderType::Limit)
3943 .instrument_id(instrument.id())
3944 .side(OrderSide::Buy)
3945 .quantity(Quantity::from("10.0"))
3946 .price(Price::from("100.00"))
3947 .build();
3948
3949 let report = OrderStatusReport::new(
3951 AccountId::from("TEST-001"),
3952 instrument.id(),
3953 Some(order.client_order_id()),
3954 VenueOrderId::from("V-001"),
3955 OrderSide::Buy,
3956 OrderType::Limit,
3957 TimeInForce::Gtc,
3958 OrderStatus::Filled,
3959 Quantity::from("10.0"),
3960 Quantity::from("10.0"),
3961 UnixNanos::from(1_000_000),
3962 UnixNanos::from(1_000_000),
3963 UnixNanos::from(1_000_000),
3964 None,
3965 )
3966 .with_avg_px(105.50)
3967 .unwrap();
3968
3969 let result = create_inferred_fill_for_qty(
3970 &order,
3971 &report,
3972 &AccountId::from("TEST-001"),
3973 &InstrumentAny::CryptoPerpetual(instrument),
3974 Quantity::from("5.0"),
3975 UnixNanos::from(2_000_000),
3976 );
3977
3978 let filled = match result.unwrap() {
3979 OrderEventAny::Filled(f) => f,
3980 _ => panic!("Expected Filled event"),
3981 };
3982
3983 assert_eq!(filled.last_px, Price::from("105.50"));
3985 assert_eq!(filled.last_qty, Quantity::from("5.0"));
3986 }
3987
3988 #[rstest]
3989 fn test_create_inferred_fill_for_qty_uses_report_price_when_no_avg_px() {
3990 let instrument = crypto_perpetual_ethusdt();
3991 let order = OrderTestBuilder::new(OrderType::Limit)
3992 .instrument_id(instrument.id())
3993 .side(OrderSide::Buy)
3994 .quantity(Quantity::from("10.0"))
3995 .price(Price::from("100.00"))
3996 .build();
3997
3998 let report = OrderStatusReport::new(
4000 AccountId::from("TEST-001"),
4001 instrument.id(),
4002 Some(order.client_order_id()),
4003 VenueOrderId::from("V-001"),
4004 OrderSide::Buy,
4005 OrderType::Limit,
4006 TimeInForce::Gtc,
4007 OrderStatus::Filled,
4008 Quantity::from("10.0"),
4009 Quantity::from("10.0"),
4010 UnixNanos::from(1_000_000),
4011 UnixNanos::from(1_000_000),
4012 UnixNanos::from(1_000_000),
4013 None,
4014 )
4015 .with_price(Price::from("102.00"));
4016
4017 let result = create_inferred_fill_for_qty(
4018 &order,
4019 &report,
4020 &AccountId::from("TEST-001"),
4021 &InstrumentAny::CryptoPerpetual(instrument),
4022 Quantity::from("5.0"),
4023 UnixNanos::from(2_000_000),
4024 );
4025
4026 let filled = match result.unwrap() {
4027 OrderEventAny::Filled(f) => f,
4028 _ => panic!("Expected Filled event"),
4029 };
4030
4031 assert_eq!(filled.last_px, Price::from("102.00"));
4033 }
4034
4035 #[rstest]
4036 fn test_create_inferred_fill_for_qty_uses_order_price_as_fallback() {
4037 let instrument = crypto_perpetual_ethusdt();
4038 let order = OrderTestBuilder::new(OrderType::Limit)
4039 .instrument_id(instrument.id())
4040 .side(OrderSide::Buy)
4041 .quantity(Quantity::from("10.0"))
4042 .price(Price::from("100.00"))
4043 .build();
4044
4045 let report = OrderStatusReport::new(
4047 AccountId::from("TEST-001"),
4048 instrument.id(),
4049 Some(order.client_order_id()),
4050 VenueOrderId::from("V-001"),
4051 OrderSide::Buy,
4052 OrderType::Limit,
4053 TimeInForce::Gtc,
4054 OrderStatus::Filled,
4055 Quantity::from("10.0"),
4056 Quantity::from("10.0"),
4057 UnixNanos::from(1_000_000),
4058 UnixNanos::from(1_000_000),
4059 UnixNanos::from(1_000_000),
4060 None,
4061 );
4062
4063 let result = create_inferred_fill_for_qty(
4064 &order,
4065 &report,
4066 &AccountId::from("TEST-001"),
4067 &InstrumentAny::CryptoPerpetual(instrument),
4068 Quantity::from("5.0"),
4069 UnixNanos::from(2_000_000),
4070 );
4071
4072 let filled = match result.unwrap() {
4073 OrderEventAny::Filled(f) => f,
4074 _ => panic!("Expected Filled event"),
4075 };
4076
4077 assert_eq!(filled.last_px, Price::from("100.00"));
4079 }
4080
4081 #[rstest]
4082 fn test_create_inferred_fill_for_qty_no_price_returns_none() {
4083 let instrument = crypto_perpetual_ethusdt();
4084
4085 let order = OrderTestBuilder::new(OrderType::Market)
4087 .instrument_id(instrument.id())
4088 .side(OrderSide::Buy)
4089 .quantity(Quantity::from("10.0"))
4090 .build();
4091
4092 let report = OrderStatusReport::new(
4094 AccountId::from("TEST-001"),
4095 instrument.id(),
4096 Some(order.client_order_id()),
4097 VenueOrderId::from("V-001"),
4098 OrderSide::Buy,
4099 OrderType::Market,
4100 TimeInForce::Ioc,
4101 OrderStatus::Filled,
4102 Quantity::from("10.0"),
4103 Quantity::from("10.0"),
4104 UnixNanos::from(1_000_000),
4105 UnixNanos::from(1_000_000),
4106 UnixNanos::from(1_000_000),
4107 None,
4108 );
4109
4110 let result = create_inferred_fill_for_qty(
4111 &order,
4112 &report,
4113 &AccountId::from("TEST-001"),
4114 &InstrumentAny::CryptoPerpetual(instrument),
4115 Quantity::from("5.0"),
4116 UnixNanos::from(2_000_000),
4117 );
4118
4119 assert!(result.is_none());
4120 }
4121
4122 #[rstest]
4123 #[case::market_order(OrderType::Market, false, LiquiditySide::Taker)]
4124 #[case::stop_market(OrderType::StopMarket, false, LiquiditySide::Taker)]
4125 #[case::trailing_stop_market(OrderType::TrailingStopMarket, false, LiquiditySide::Taker)]
4126 #[case::limit_post_only(OrderType::Limit, true, LiquiditySide::Maker)]
4127 #[case::limit_default(OrderType::Limit, false, LiquiditySide::NoLiquiditySide)]
4128 fn test_create_inferred_fill_for_qty_liquidity_side(
4129 #[case] order_type: OrderType,
4130 #[case] post_only: bool,
4131 #[case] expected: LiquiditySide,
4132 ) {
4133 let instrument = crypto_perpetual_ethusdt();
4134 let order = match order_type {
4135 OrderType::Limit => OrderTestBuilder::new(order_type)
4136 .instrument_id(instrument.id())
4137 .side(OrderSide::Buy)
4138 .quantity(Quantity::from("10.0"))
4139 .price(Price::from("100.00"))
4140 .post_only(post_only)
4141 .build(),
4142 OrderType::StopMarket => OrderTestBuilder::new(order_type)
4143 .instrument_id(instrument.id())
4144 .side(OrderSide::Buy)
4145 .quantity(Quantity::from("10.0"))
4146 .trigger_price(Price::from("100.00"))
4147 .build(),
4148 OrderType::TrailingStopMarket => OrderTestBuilder::new(order_type)
4149 .instrument_id(instrument.id())
4150 .side(OrderSide::Buy)
4151 .quantity(Quantity::from("10.0"))
4152 .trigger_price(Price::from("100.00"))
4153 .trailing_offset(Decimal::from(1))
4154 .build(),
4155 _ => OrderTestBuilder::new(order_type)
4156 .instrument_id(instrument.id())
4157 .side(OrderSide::Buy)
4158 .quantity(Quantity::from("10.0"))
4159 .build(),
4160 };
4161
4162 let report = make_test_report(
4163 instrument.id(),
4164 order_type,
4165 OrderStatus::Filled,
4166 "10.0",
4167 post_only,
4168 );
4169
4170 let result = create_inferred_fill_for_qty(
4171 &order,
4172 &report,
4173 &AccountId::from("TEST-001"),
4174 &InstrumentAny::CryptoPerpetual(instrument),
4175 Quantity::from("5.0"),
4176 UnixNanos::from(2_000_000),
4177 );
4178
4179 let filled = match result.unwrap() {
4180 OrderEventAny::Filled(f) => f,
4181 _ => panic!("Expected Filled event"),
4182 };
4183
4184 assert_eq!(
4185 filled.liquidity_side, expected,
4186 "order_type={order_type}, post_only={post_only}"
4187 );
4188 }
4189
4190 #[rstest]
4191 fn test_create_inferred_fill_for_qty_trade_id_format() {
4192 let instrument = crypto_perpetual_ethusdt();
4193 let order = OrderTestBuilder::new(OrderType::Limit)
4194 .instrument_id(instrument.id())
4195 .side(OrderSide::Buy)
4196 .quantity(Quantity::from("10.0"))
4197 .price(Price::from("100.00"))
4198 .build();
4199
4200 let report = make_test_report(
4201 instrument.id(),
4202 OrderType::Limit,
4203 OrderStatus::Filled,
4204 "10.0",
4205 false,
4206 );
4207
4208 let ts_now = UnixNanos::from(2_000_000);
4209 let result = create_inferred_fill_for_qty(
4210 &order,
4211 &report,
4212 &AccountId::from("TEST-001"),
4213 &InstrumentAny::CryptoPerpetual(instrument),
4214 Quantity::from("5.0"),
4215 ts_now,
4216 );
4217
4218 let filled = match result.unwrap() {
4219 OrderEventAny::Filled(f) => f,
4220 _ => panic!("Expected Filled event"),
4221 };
4222
4223 assert_eq!(filled.trade_id.as_str().len(), 36);
4225 assert!(filled.trade_id.as_str().contains('-'));
4226 }
4227
4228 #[rstest]
4229 fn test_create_inferred_fill_for_qty_reconciliation_flag() {
4230 let instrument = crypto_perpetual_ethusdt();
4231 let order = OrderTestBuilder::new(OrderType::Limit)
4232 .instrument_id(instrument.id())
4233 .side(OrderSide::Buy)
4234 .quantity(Quantity::from("10.0"))
4235 .price(Price::from("100.00"))
4236 .build();
4237
4238 let report = make_test_report(
4239 instrument.id(),
4240 OrderType::Limit,
4241 OrderStatus::Filled,
4242 "10.0",
4243 false,
4244 );
4245
4246 let result = create_inferred_fill_for_qty(
4247 &order,
4248 &report,
4249 &AccountId::from("TEST-001"),
4250 &InstrumentAny::CryptoPerpetual(instrument),
4251 Quantity::from("5.0"),
4252 UnixNanos::from(2_000_000),
4253 );
4254
4255 let filled = match result.unwrap() {
4256 OrderEventAny::Filled(f) => f,
4257 _ => panic!("Expected Filled event"),
4258 };
4259
4260 assert!(filled.reconciliation, "reconciliation flag should be true");
4261 }
4262}