1use ahash::AHashMap;
21use nautilus_common::enums::LogColor;
22use nautilus_core::{UUID4, UnixNanos};
23use nautilus_model::{
24 enums::{LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSideSpecified, TimeInForce},
25 events::{
26 OrderAccepted, OrderCanceled, OrderEventAny, OrderExpired, OrderFilled, OrderRejected,
27 OrderTriggered, OrderUpdated,
28 },
29 identifiers::{AccountId, InstrumentId, TradeId, VenueOrderId},
30 instruments::{Instrument, InstrumentAny},
31 orders::{Order, OrderAny},
32 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
33 types::{Money, Price, Quantity},
34};
35use rust_decimal::Decimal;
36use ustr::Ustr;
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct FillSnapshot {
41 pub ts_event: u64,
43 pub side: OrderSide,
45 pub qty: Decimal,
47 pub px: Decimal,
49 pub venue_order_id: VenueOrderId,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct VenuePositionSnapshot {
56 pub side: OrderSide, pub qty: Decimal,
60 pub avg_px: Decimal,
62}
63
64#[derive(Debug, Clone, PartialEq)]
66pub enum FillAdjustmentResult {
67 NoAdjustment,
69 AddSyntheticOpening {
71 synthetic_fill: FillSnapshot,
73 existing_fills: Vec<FillSnapshot>,
75 },
76 ReplaceCurrentLifecycle {
78 synthetic_fill: FillSnapshot,
80 first_venue_order_id: VenueOrderId,
82 },
83 FilterToCurrentLifecycle {
85 last_zero_crossing_ts: u64,
87 current_lifecycle_fills: Vec<FillSnapshot>,
89 },
90}
91
92impl FillSnapshot {
93 #[must_use]
95 pub fn new(
96 ts_event: u64,
97 side: OrderSide,
98 qty: Decimal,
99 px: Decimal,
100 venue_order_id: VenueOrderId,
101 ) -> Self {
102 Self {
103 ts_event,
104 side,
105 qty,
106 px,
107 venue_order_id,
108 }
109 }
110
111 #[must_use]
113 pub fn direction(&self) -> i8 {
114 match self.side {
115 OrderSide::Buy => 1,
116 OrderSide::Sell => -1,
117 _ => 0,
118 }
119 }
120}
121
122#[must_use]
128pub fn simulate_position(fills: &[FillSnapshot]) -> (Decimal, Decimal) {
129 let mut qty = Decimal::ZERO;
130 let mut value = Decimal::ZERO;
131
132 for fill in fills {
133 let direction = Decimal::from(fill.direction());
134 let new_qty = qty + (direction * fill.qty);
135
136 if (qty >= Decimal::ZERO && direction > Decimal::ZERO)
138 || (qty <= Decimal::ZERO && direction < Decimal::ZERO)
139 {
140 value += fill.qty * fill.px;
142 qty = new_qty;
143 } else {
144 if qty.abs() >= fill.qty {
146 let close_ratio = fill.qty / qty.abs();
148 value *= Decimal::ONE - close_ratio;
149 qty = new_qty;
150 } else {
151 let remaining = fill.qty - qty.abs();
153 qty = direction * remaining;
154 value = remaining * fill.px;
155 }
156 }
157 }
158
159 (qty, value)
160}
161
162#[must_use]
171pub fn detect_zero_crossings(fills: &[FillSnapshot]) -> Vec<u64> {
172 let mut running_qty = Decimal::ZERO;
173 let mut zero_crossings = Vec::new();
174
175 for fill in fills {
176 let prev_qty = running_qty;
177 running_qty += Decimal::from(fill.direction()) * fill.qty;
178
179 if prev_qty != Decimal::ZERO {
181 if running_qty == Decimal::ZERO {
182 zero_crossings.push(fill.ts_event);
184 } else if (prev_qty > Decimal::ZERO) != (running_qty > Decimal::ZERO) {
185 zero_crossings.push(fill.ts_event);
187 }
188 }
189 }
190
191 zero_crossings
192}
193
194#[must_use]
200pub fn check_position_match(
201 simulated_qty: Decimal,
202 simulated_value: Decimal,
203 venue_qty: Decimal,
204 venue_avg_px: Decimal,
205 tolerance: Decimal,
206) -> bool {
207 if simulated_qty != venue_qty {
208 return false;
209 }
210
211 if simulated_qty == Decimal::ZERO {
212 return true; }
214
215 let abs_qty = simulated_qty.abs();
217 if abs_qty == Decimal::ZERO {
218 return false;
219 }
220
221 let simulated_avg_px = simulated_value / abs_qty;
222
223 if venue_avg_px == Decimal::ZERO {
225 return false;
226 }
227
228 let relative_diff = (simulated_avg_px - venue_avg_px).abs() / venue_avg_px;
229
230 relative_diff <= tolerance
231}
232
233pub fn calculate_reconciliation_price(
251 current_position_qty: Decimal,
252 current_position_avg_px: Option<Decimal>,
253 target_position_qty: Decimal,
254 target_position_avg_px: Option<Decimal>,
255) -> Option<Decimal> {
256 let qty_diff = target_position_qty - current_position_qty;
257
258 if qty_diff == Decimal::ZERO {
259 return None; }
261
262 if target_position_qty == Decimal::ZERO {
265 return current_position_avg_px;
266 }
267
268 let target_avg_px = target_position_avg_px?;
270 if target_avg_px == Decimal::ZERO {
271 return None;
272 }
273
274 if current_position_qty == Decimal::ZERO || current_position_avg_px.is_none() {
276 return Some(target_avg_px);
277 }
278
279 let current_avg_px = current_position_avg_px?;
280
281 let is_flip = (current_position_qty > Decimal::ZERO) != (target_position_qty > Decimal::ZERO)
284 && target_position_qty != Decimal::ZERO;
285
286 if is_flip {
287 return Some(target_avg_px);
288 }
289
290 let target_value = target_position_qty * target_avg_px;
293 let current_value = current_position_qty * current_avg_px;
294 let diff_value = target_value - current_value;
295
296 let reconciliation_px = diff_value / qty_diff;
298
299 if reconciliation_px > Decimal::ZERO {
301 return Some(reconciliation_px);
302 }
303
304 None
305}
306
307#[must_use]
320pub fn adjust_fills_for_partial_window(
321 fills: &[FillSnapshot],
322 venue_position: &VenuePositionSnapshot,
323 _instrument: &InstrumentAny,
324 tolerance: Decimal,
325) -> FillAdjustmentResult {
326 if fills.is_empty() {
328 return FillAdjustmentResult::NoAdjustment;
329 }
330
331 if venue_position.qty == Decimal::ZERO {
333 return FillAdjustmentResult::NoAdjustment;
334 }
335
336 let zero_crossings = detect_zero_crossings(fills);
338
339 let venue_qty_signed = match venue_position.side {
341 OrderSide::Buy => venue_position.qty,
342 OrderSide::Sell => -venue_position.qty,
343 _ => Decimal::ZERO,
344 };
345
346 if !zero_crossings.is_empty() {
348 let mut last_flat_crossing_ts = None;
351 let mut running_qty = Decimal::ZERO;
352
353 for fill in fills {
354 let prev_qty = running_qty;
355 running_qty += Decimal::from(fill.direction()) * fill.qty;
356
357 if prev_qty != Decimal::ZERO && running_qty == Decimal::ZERO {
358 last_flat_crossing_ts = Some(fill.ts_event);
359 }
360 }
361
362 let lifecycle_boundary_ts =
363 last_flat_crossing_ts.unwrap_or(*zero_crossings.last().unwrap());
364
365 let current_lifecycle_fills: Vec<FillSnapshot> = fills
367 .iter()
368 .filter(|f| f.ts_event > lifecycle_boundary_ts)
369 .cloned()
370 .collect();
371
372 if current_lifecycle_fills.is_empty() {
373 return FillAdjustmentResult::NoAdjustment;
374 }
375
376 let (current_qty, current_value) = simulate_position(¤t_lifecycle_fills);
378
379 if check_position_match(
381 current_qty,
382 current_value,
383 venue_qty_signed,
384 venue_position.avg_px,
385 tolerance,
386 ) {
387 return FillAdjustmentResult::FilterToCurrentLifecycle {
389 last_zero_crossing_ts: lifecycle_boundary_ts,
390 current_lifecycle_fills,
391 };
392 }
393
394 if let Some(first_fill) = current_lifecycle_fills.first() {
396 let synthetic_fill = FillSnapshot::new(
397 first_fill.ts_event.saturating_sub(1), venue_position.side,
399 venue_position.qty,
400 venue_position.avg_px,
401 first_fill.venue_order_id,
402 );
403
404 return FillAdjustmentResult::ReplaceCurrentLifecycle {
405 synthetic_fill,
406 first_venue_order_id: first_fill.venue_order_id,
407 };
408 }
409
410 return FillAdjustmentResult::NoAdjustment;
411 }
412
413 let oldest_lifecycle_fills: Vec<FillSnapshot> =
416 if let Some(&first_zero_crossing_ts) = zero_crossings.first() {
417 fills
419 .iter()
420 .filter(|f| f.ts_event <= first_zero_crossing_ts)
421 .cloned()
422 .collect()
423 } else {
424 fills.to_vec()
426 };
427
428 if oldest_lifecycle_fills.is_empty() {
429 return FillAdjustmentResult::NoAdjustment;
430 }
431
432 let (oldest_qty, oldest_value) = simulate_position(&oldest_lifecycle_fills);
434
435 if zero_crossings.is_empty() {
437 if check_position_match(
439 oldest_qty,
440 oldest_value,
441 venue_qty_signed,
442 venue_position.avg_px,
443 tolerance,
444 ) {
445 return FillAdjustmentResult::NoAdjustment;
446 }
447
448 if let Some(first_fill) = oldest_lifecycle_fills.first() {
450 let oldest_avg_px = if oldest_qty == Decimal::ZERO {
453 None
454 } else {
455 Some(oldest_value / oldest_qty.abs())
456 };
457
458 let reconciliation_price = calculate_reconciliation_price(
459 oldest_qty,
460 oldest_avg_px,
461 venue_qty_signed,
462 Some(venue_position.avg_px),
463 );
464
465 if let Some(opening_px) = reconciliation_price {
466 let opening_qty = if oldest_qty == Decimal::ZERO {
468 venue_qty_signed
469 } else {
470 venue_qty_signed - oldest_qty
472 };
473
474 if opening_qty.abs() > Decimal::ZERO {
475 let synthetic_side = if opening_qty > Decimal::ZERO {
476 OrderSide::Buy
477 } else {
478 OrderSide::Sell
479 };
480
481 let synthetic_fill = FillSnapshot::new(
482 first_fill.ts_event.saturating_sub(1),
483 synthetic_side,
484 opening_qty.abs(),
485 opening_px,
486 first_fill.venue_order_id,
487 );
488
489 return FillAdjustmentResult::AddSyntheticOpening {
490 synthetic_fill,
491 existing_fills: oldest_lifecycle_fills,
492 };
493 }
494 }
495 }
496
497 return FillAdjustmentResult::NoAdjustment;
498 }
499
500 if oldest_qty == Decimal::ZERO {
502 return FillAdjustmentResult::NoAdjustment;
504 }
505
506 if !oldest_lifecycle_fills.is_empty()
508 && let Some(&first_zero_crossing_ts) = zero_crossings.first()
509 {
510 let current_lifecycle_fills: Vec<FillSnapshot> = fills
512 .iter()
513 .filter(|f| f.ts_event > first_zero_crossing_ts)
514 .cloned()
515 .collect();
516
517 if !current_lifecycle_fills.is_empty()
518 && let Some(first_current_fill) = current_lifecycle_fills.first()
519 {
520 let synthetic_fill = FillSnapshot::new(
521 first_current_fill.ts_event.saturating_sub(1),
522 venue_position.side,
523 venue_position.qty,
524 venue_position.avg_px,
525 first_current_fill.venue_order_id,
526 );
527
528 return FillAdjustmentResult::AddSyntheticOpening {
529 synthetic_fill,
530 existing_fills: oldest_lifecycle_fills,
531 };
532 }
533 }
534
535 FillAdjustmentResult::NoAdjustment
536}
537
538#[must_use]
542pub fn create_synthetic_venue_order_id(ts_event: u64) -> VenueOrderId {
543 let uuid = UUID4::new();
544 let uuid_str = uuid.to_string();
545 let uuid_suffix = &uuid_str[..8];
546 let venue_order_id_value = format!("S-{ts_event:x}-{uuid_suffix}");
547 VenueOrderId::new(&venue_order_id_value)
548}
549
550#[must_use]
554pub fn create_synthetic_trade_id(ts_event: u64) -> TradeId {
555 let uuid = UUID4::new();
556 let uuid_str = uuid.to_string();
557 let uuid_suffix = &uuid_str[..8];
558 let trade_id_value = format!("S-{ts_event:x}-{uuid_suffix}");
559 TradeId::new(&trade_id_value)
560}
561
562pub fn create_synthetic_order_report(
568 fill: &FillSnapshot,
569 account_id: AccountId,
570 instrument_id: InstrumentId,
571 instrument: &InstrumentAny,
572 venue_order_id: VenueOrderId,
573) -> anyhow::Result<OrderStatusReport> {
574 let order_qty = Quantity::from_decimal_dp(fill.qty, instrument.size_precision())?;
575
576 Ok(OrderStatusReport::new(
577 account_id,
578 instrument_id,
579 None, venue_order_id,
581 fill.side,
582 OrderType::Market,
583 TimeInForce::Gtc,
584 OrderStatus::Filled,
585 order_qty,
586 order_qty, UnixNanos::from(fill.ts_event),
588 UnixNanos::from(fill.ts_event),
589 UnixNanos::from(fill.ts_event),
590 None, ))
592}
593
594pub fn create_synthetic_fill_report(
600 fill: &FillSnapshot,
601 account_id: AccountId,
602 instrument_id: InstrumentId,
603 instrument: &InstrumentAny,
604 venue_order_id: VenueOrderId,
605) -> anyhow::Result<FillReport> {
606 let trade_id = create_synthetic_trade_id(fill.ts_event);
607 let qty = Quantity::from_decimal_dp(fill.qty, instrument.size_precision())?;
608 let px = Price::from_decimal_dp(fill.px, instrument.price_precision())?;
609
610 Ok(FillReport::new(
611 account_id,
612 instrument_id,
613 venue_order_id,
614 trade_id,
615 fill.side,
616 qty,
617 px,
618 Money::new(0.0, instrument.quote_currency()),
619 LiquiditySide::NoLiquiditySide,
620 None, None, fill.ts_event.into(),
623 fill.ts_event.into(),
624 None, ))
626}
627
628#[derive(Debug, Clone)]
630pub struct ReconciliationResult {
631 pub orders: AHashMap<VenueOrderId, OrderStatusReport>,
633 pub fills: AHashMap<VenueOrderId, Vec<FillReport>>,
635}
636
637const DEFAULT_TOLERANCE: Decimal = Decimal::from_parts(1, 0, 0, false, 4); pub fn process_mass_status_for_reconciliation(
650 mass_status: &ExecutionMassStatus,
651 instrument: &InstrumentAny,
652 tolerance: Option<Decimal>,
653) -> anyhow::Result<ReconciliationResult> {
654 let instrument_id = instrument.id();
655 let account_id = mass_status.account_id;
656 let tol = tolerance.unwrap_or(DEFAULT_TOLERANCE);
657
658 let position_reports = mass_status.position_reports();
660 let venue_position = match position_reports.get(&instrument_id).and_then(|r| r.first()) {
661 Some(report) => position_report_to_snapshot(report),
662 None => {
663 return Ok(extract_instrument_reports(mass_status, instrument_id));
665 }
666 };
667
668 let extracted = extract_fills_for_instrument(mass_status, instrument_id);
670 let fill_snapshots = extracted.snapshots;
671 let mut order_map = extracted.orders;
672 let mut fill_map = extracted.fills;
673
674 if fill_snapshots.is_empty() {
675 return Ok(ReconciliationResult {
676 orders: order_map,
677 fills: fill_map,
678 });
679 }
680
681 let result = adjust_fills_for_partial_window(&fill_snapshots, &venue_position, instrument, tol);
683
684 match result {
686 FillAdjustmentResult::NoAdjustment => {}
687
688 FillAdjustmentResult::AddSyntheticOpening {
689 synthetic_fill,
690 existing_fills: _,
691 } => {
692 let venue_order_id = create_synthetic_venue_order_id(synthetic_fill.ts_event);
693 let order = create_synthetic_order_report(
694 &synthetic_fill,
695 account_id,
696 instrument_id,
697 instrument,
698 venue_order_id,
699 )?;
700 let fill = create_synthetic_fill_report(
701 &synthetic_fill,
702 account_id,
703 instrument_id,
704 instrument,
705 venue_order_id,
706 )?;
707
708 order_map.insert(venue_order_id, order);
709 fill_map.entry(venue_order_id).or_default().insert(0, fill);
710 }
711
712 FillAdjustmentResult::ReplaceCurrentLifecycle {
713 synthetic_fill,
714 first_venue_order_id,
715 } => {
716 let order = create_synthetic_order_report(
717 &synthetic_fill,
718 account_id,
719 instrument_id,
720 instrument,
721 first_venue_order_id,
722 )?;
723 let fill = create_synthetic_fill_report(
724 &synthetic_fill,
725 account_id,
726 instrument_id,
727 instrument,
728 first_venue_order_id,
729 )?;
730
731 order_map.clear();
733 fill_map.clear();
734 order_map.insert(first_venue_order_id, order);
735 fill_map.insert(first_venue_order_id, vec![fill]);
736 }
737
738 FillAdjustmentResult::FilterToCurrentLifecycle {
739 last_zero_crossing_ts,
740 current_lifecycle_fills: _,
741 } => {
742 for fills in fill_map.values_mut() {
744 fills.retain(|f| f.ts_event.as_u64() > last_zero_crossing_ts);
745 }
746 fill_map.retain(|_, fills| !fills.is_empty());
747
748 let orders_with_fills: ahash::AHashSet<VenueOrderId> =
750 fill_map.keys().copied().collect();
751 order_map.retain(|id, order| {
752 orders_with_fills.contains(id)
753 || !matches!(
754 order.order_status,
755 OrderStatus::Denied
756 | OrderStatus::Rejected
757 | OrderStatus::Canceled
758 | OrderStatus::Expired
759 | OrderStatus::Filled
760 )
761 });
762 }
763 }
764
765 Ok(ReconciliationResult {
766 orders: order_map,
767 fills: fill_map,
768 })
769}
770
771fn position_report_to_snapshot(report: &PositionStatusReport) -> VenuePositionSnapshot {
773 let side = match report.position_side {
774 PositionSideSpecified::Long => OrderSide::Buy,
775 PositionSideSpecified::Short => OrderSide::Sell,
776 PositionSideSpecified::Flat => OrderSide::Buy,
777 };
778
779 VenuePositionSnapshot {
780 side,
781 qty: report.quantity.into(),
782 avg_px: report.avg_px_open.unwrap_or(Decimal::ZERO),
783 }
784}
785
786fn extract_instrument_reports(
788 mass_status: &ExecutionMassStatus,
789 instrument_id: InstrumentId,
790) -> ReconciliationResult {
791 let mut orders = AHashMap::new();
792 let mut fills = AHashMap::new();
793
794 for (id, order) in mass_status.order_reports() {
795 if order.instrument_id == instrument_id {
796 orders.insert(id, order.clone());
797 }
798 }
799
800 for (id, fill_list) in mass_status.fill_reports() {
801 let filtered: Vec<_> = fill_list
802 .iter()
803 .filter(|f| f.instrument_id == instrument_id)
804 .cloned()
805 .collect();
806 if !filtered.is_empty() {
807 fills.insert(id, filtered);
808 }
809 }
810
811 ReconciliationResult { orders, fills }
812}
813
814struct ExtractedFills {
816 snapshots: Vec<FillSnapshot>,
817 orders: AHashMap<VenueOrderId, OrderStatusReport>,
818 fills: AHashMap<VenueOrderId, Vec<FillReport>>,
819}
820
821fn extract_fills_for_instrument(
823 mass_status: &ExecutionMassStatus,
824 instrument_id: InstrumentId,
825) -> ExtractedFills {
826 let mut snapshots = Vec::new();
827 let mut order_map = AHashMap::new();
828 let mut fill_map = AHashMap::new();
829
830 for (id, order) in mass_status.order_reports() {
832 if order.instrument_id == instrument_id {
833 order_map.insert(id, order.clone());
834 }
835 }
836
837 for (venue_order_id, fill_reports) in mass_status.fill_reports() {
839 for fill in fill_reports {
840 if fill.instrument_id == instrument_id {
841 let side = mass_status
842 .order_reports()
843 .get(&venue_order_id)
844 .map_or(fill.order_side, |o| o.order_side);
845
846 snapshots.push(FillSnapshot::new(
847 fill.ts_event.as_u64(),
848 side,
849 fill.last_qty.into(),
850 fill.last_px.into(),
851 venue_order_id,
852 ));
853
854 fill_map
855 .entry(venue_order_id)
856 .or_insert_with(Vec::new)
857 .push(fill.clone());
858 }
859 }
860 }
861
862 snapshots.sort_by_key(|f| f.ts_event);
864
865 ExtractedFills {
866 snapshots,
867 orders: order_map,
868 fills: fill_map,
869 }
870}
871
872#[must_use]
879pub fn generate_external_order_status_events(
880 order: &OrderAny,
881 report: &OrderStatusReport,
882 account_id: &AccountId,
883 instrument: &InstrumentAny,
884 ts_now: UnixNanos,
885) -> Vec<OrderEventAny> {
886 let accepted = OrderEventAny::Accepted(OrderAccepted::new(
887 order.trader_id(),
888 order.strategy_id(),
889 order.instrument_id(),
890 order.client_order_id(),
891 report.venue_order_id,
892 *account_id,
893 UUID4::new(),
894 report.ts_accepted,
895 ts_now,
896 true, ));
898
899 match report.order_status {
900 OrderStatus::Accepted | OrderStatus::Triggered => vec![accepted],
901 OrderStatus::PartiallyFilled | OrderStatus::Filled => {
902 let mut events = vec![accepted];
903
904 if !report.filled_qty.is_zero()
905 && let Some(filled) =
906 create_inferred_fill(order, report, account_id, instrument, ts_now)
907 {
908 events.push(filled);
909 }
910
911 events
912 }
913 OrderStatus::Canceled => {
914 let canceled = OrderEventAny::Canceled(OrderCanceled::new(
915 order.trader_id(),
916 order.strategy_id(),
917 order.instrument_id(),
918 order.client_order_id(),
919 UUID4::new(),
920 report.ts_last,
921 ts_now,
922 true, Some(report.venue_order_id),
924 Some(*account_id),
925 ));
926 vec![accepted, canceled]
927 }
928 OrderStatus::Expired => {
929 let expired = OrderEventAny::Expired(OrderExpired::new(
930 order.trader_id(),
931 order.strategy_id(),
932 order.instrument_id(),
933 order.client_order_id(),
934 UUID4::new(),
935 report.ts_last,
936 ts_now,
937 true, Some(report.venue_order_id),
939 Some(*account_id),
940 ));
941 vec![accepted, expired]
942 }
943 OrderStatus::Rejected => {
944 vec![OrderEventAny::Rejected(OrderRejected::new(
946 order.trader_id(),
947 order.strategy_id(),
948 order.instrument_id(),
949 order.client_order_id(),
950 *account_id,
951 Ustr::from(report.cancel_reason.as_deref().unwrap_or("UNKNOWN")),
952 UUID4::new(),
953 report.ts_last,
954 ts_now,
955 true, false,
957 ))]
958 }
959 _ => {
960 log::warn!(
961 "Unhandled order status {} for external order {}",
962 report.order_status,
963 order.client_order_id()
964 );
965 Vec::new()
966 }
967 }
968}
969
970#[must_use]
972pub fn create_inferred_fill(
973 order: &OrderAny,
974 report: &OrderStatusReport,
975 account_id: &AccountId,
976 instrument: &InstrumentAny,
977 ts_now: UnixNanos,
978) -> Option<OrderEventAny> {
979 let liquidity_side = match order.order_type() {
980 OrderType::Market | OrderType::StopMarket | OrderType::TrailingStopMarket => {
981 LiquiditySide::Taker
982 }
983 _ if report.post_only => LiquiditySide::Maker,
984 _ => LiquiditySide::NoLiquiditySide,
985 };
986
987 let last_px = if let Some(avg_px) = report.avg_px {
988 match Price::from_decimal_dp(avg_px, instrument.price_precision()) {
989 Ok(px) => px,
990 Err(e) => {
991 log::warn!("Failed to create price from avg_px for inferred fill: {e}");
992 return None;
993 }
994 }
995 } else if let Some(price) = report.price {
996 price
997 } else {
998 log::warn!(
999 "Cannot create inferred fill for {}: no avg_px or price available",
1000 order.client_order_id()
1001 );
1002 return None;
1003 };
1004
1005 let trade_id = TradeId::from(UUID4::new().as_str());
1006
1007 log::info!(
1008 "Generated inferred fill for {} ({}) qty={} px={}",
1009 order.client_order_id(),
1010 report.venue_order_id,
1011 report.filled_qty,
1012 last_px,
1013 );
1014
1015 Some(OrderEventAny::Filled(OrderFilled::new(
1016 order.trader_id(),
1017 order.strategy_id(),
1018 order.instrument_id(),
1019 order.client_order_id(),
1020 report.venue_order_id,
1021 *account_id,
1022 trade_id,
1023 report.order_side,
1024 order.order_type(),
1025 report.filled_qty,
1026 last_px,
1027 instrument.quote_currency(),
1028 liquidity_side,
1029 UUID4::new(),
1030 report.ts_last,
1031 ts_now,
1032 true, report.venue_position_id,
1034 None, )))
1036}
1037
1038#[must_use]
1044pub fn create_reconciliation_accepted(
1045 order: &OrderAny,
1046 report: &OrderStatusReport,
1047 ts_now: UnixNanos,
1048) -> OrderEventAny {
1049 OrderEventAny::Accepted(OrderAccepted::new(
1050 order.trader_id(),
1051 order.strategy_id(),
1052 order.instrument_id(),
1053 order.client_order_id(),
1054 order.venue_order_id().unwrap_or(report.venue_order_id),
1055 order
1056 .account_id()
1057 .expect("Order should have account_id for reconciliation"),
1058 UUID4::new(),
1059 report.ts_accepted,
1060 ts_now,
1061 true, ))
1063}
1064
1065#[must_use]
1067pub fn create_reconciliation_rejected(
1068 order: &OrderAny,
1069 reason: Option<&str>,
1070 ts_now: UnixNanos,
1071) -> Option<OrderEventAny> {
1072 let account_id = order.account_id()?;
1073 let reason = reason.unwrap_or("UNKNOWN");
1074
1075 Some(OrderEventAny::Rejected(OrderRejected::new(
1076 order.trader_id(),
1077 order.strategy_id(),
1078 order.instrument_id(),
1079 order.client_order_id(),
1080 account_id,
1081 Ustr::from(reason),
1082 UUID4::new(),
1083 ts_now,
1084 ts_now,
1085 true, false, )))
1088}
1089
1090#[must_use]
1092pub fn create_reconciliation_triggered(
1093 order: &OrderAny,
1094 report: &OrderStatusReport,
1095 ts_now: UnixNanos,
1096) -> OrderEventAny {
1097 OrderEventAny::Triggered(OrderTriggered::new(
1098 order.trader_id(),
1099 order.strategy_id(),
1100 order.instrument_id(),
1101 order.client_order_id(),
1102 UUID4::new(),
1103 report.ts_triggered.unwrap_or(ts_now),
1104 ts_now,
1105 true, order.venue_order_id(),
1107 order.account_id(),
1108 ))
1109}
1110
1111#[must_use]
1113pub fn create_reconciliation_canceled(
1114 order: &OrderAny,
1115 report: &OrderStatusReport,
1116 ts_now: UnixNanos,
1117) -> OrderEventAny {
1118 OrderEventAny::Canceled(OrderCanceled::new(
1119 order.trader_id(),
1120 order.strategy_id(),
1121 order.instrument_id(),
1122 order.client_order_id(),
1123 UUID4::new(),
1124 report.ts_last,
1125 ts_now,
1126 true, order.venue_order_id(),
1128 order.account_id(),
1129 ))
1130}
1131
1132#[must_use]
1134pub fn create_reconciliation_expired(
1135 order: &OrderAny,
1136 report: &OrderStatusReport,
1137 ts_now: UnixNanos,
1138) -> OrderEventAny {
1139 OrderEventAny::Expired(OrderExpired::new(
1140 order.trader_id(),
1141 order.strategy_id(),
1142 order.instrument_id(),
1143 order.client_order_id(),
1144 UUID4::new(),
1145 report.ts_last,
1146 ts_now,
1147 true, order.venue_order_id(),
1149 order.account_id(),
1150 ))
1151}
1152
1153#[must_use]
1155pub fn create_reconciliation_updated(
1156 order: &OrderAny,
1157 report: &OrderStatusReport,
1158 ts_now: UnixNanos,
1159) -> OrderEventAny {
1160 OrderEventAny::Updated(OrderUpdated::new(
1161 order.trader_id(),
1162 order.strategy_id(),
1163 order.instrument_id(),
1164 order.client_order_id(),
1165 report.quantity,
1166 UUID4::new(),
1167 report.ts_last,
1168 ts_now,
1169 true, order.venue_order_id(),
1171 order.account_id(),
1172 report.price,
1173 report.trigger_price,
1174 None, ))
1176}
1177
1178#[must_use]
1181pub fn should_reconciliation_update(order: &OrderAny, report: &OrderStatusReport) -> bool {
1182 if report.quantity != order.quantity() && report.quantity >= order.filled_qty() {
1184 return true;
1185 }
1186
1187 match order.order_type() {
1188 OrderType::Limit => report.price != order.price(),
1189 OrderType::StopMarket | OrderType::TrailingStopMarket => {
1190 report.trigger_price != order.trigger_price()
1191 }
1192 OrderType::StopLimit | OrderType::TrailingStopLimit => {
1193 report.trigger_price != order.trigger_price() || report.price != order.price()
1194 }
1195 _ => false,
1196 }
1197}
1198
1199#[must_use]
1204pub fn reconcile_order_report(
1205 order: &OrderAny,
1206 report: &OrderStatusReport,
1207 instrument: Option<&InstrumentAny>,
1208 ts_now: UnixNanos,
1209) -> Option<OrderEventAny> {
1210 if order.status() == report.order_status && order.filled_qty() == report.filled_qty {
1211 if should_reconciliation_update(order, report) {
1212 log::info!(
1213 "Order {} has been updated at venue: qty={}->{}, price={:?}->{:?}",
1214 order.client_order_id(),
1215 order.quantity(),
1216 report.quantity,
1217 order.price(),
1218 report.price
1219 );
1220 return Some(create_reconciliation_updated(order, report, ts_now));
1221 }
1222 return None; }
1224
1225 match report.order_status {
1226 OrderStatus::Accepted => {
1227 if order.status() == OrderStatus::Accepted
1228 && should_reconciliation_update(order, report)
1229 {
1230 return Some(create_reconciliation_updated(order, report, ts_now));
1231 }
1232 Some(create_reconciliation_accepted(order, report, ts_now))
1233 }
1234 OrderStatus::Rejected => {
1235 create_reconciliation_rejected(order, report.cancel_reason.as_deref(), ts_now)
1236 }
1237 OrderStatus::Triggered => Some(create_reconciliation_triggered(order, report, ts_now)),
1238 OrderStatus::Canceled => Some(create_reconciliation_canceled(order, report, ts_now)),
1239 OrderStatus::Expired => Some(create_reconciliation_expired(order, report, ts_now)),
1240
1241 OrderStatus::PartiallyFilled | OrderStatus::Filled => {
1242 reconcile_fill_quantity_mismatch(order, report, instrument, ts_now)
1243 }
1244
1245 OrderStatus::PendingUpdate | OrderStatus::PendingCancel => {
1247 log::debug!(
1248 "Order {} in pending state: {:?}",
1249 order.client_order_id(),
1250 report.order_status
1251 );
1252 None
1253 }
1254
1255 OrderStatus::Initialized
1257 | OrderStatus::Submitted
1258 | OrderStatus::Denied
1259 | OrderStatus::Emulated
1260 | OrderStatus::Released => {
1261 log::warn!(
1262 "Unexpected order status in venue report for {}: {:?}",
1263 order.client_order_id(),
1264 report.order_status
1265 );
1266 None
1267 }
1268 }
1269}
1270
1271fn reconcile_fill_quantity_mismatch(
1275 order: &OrderAny,
1276 report: &OrderStatusReport,
1277 instrument: Option<&InstrumentAny>,
1278 ts_now: UnixNanos,
1279) -> Option<OrderEventAny> {
1280 let order_filled_qty = order.filled_qty();
1281 let report_filled_qty = report.filled_qty;
1282
1283 if report_filled_qty < order_filled_qty {
1284 log::error!(
1286 "Fill qty mismatch for {}: cached={}, venue={} (venue < cached)",
1287 order.client_order_id(),
1288 order_filled_qty,
1289 report_filled_qty
1290 );
1291 return None;
1292 }
1293
1294 if report_filled_qty > order_filled_qty {
1295 let Some(instrument) = instrument else {
1297 log::warn!(
1298 "Cannot generate inferred fill for {}: instrument not available",
1299 order.client_order_id()
1300 );
1301 return None;
1302 };
1303
1304 let account_id = order.account_id()?;
1305 return create_incremental_inferred_fill(order, report, &account_id, instrument, ts_now);
1306 }
1307
1308 if order.status() != report.order_status {
1310 log::warn!(
1311 "Status mismatch with matching fill qty for {}: local={:?}, venue={:?}, filled_qty={}",
1312 order.client_order_id(),
1313 order.status(),
1314 report.order_status,
1315 report.filled_qty
1316 );
1317 }
1318
1319 None
1320}
1321
1322fn create_incremental_inferred_fill(
1326 order: &OrderAny,
1327 report: &OrderStatusReport,
1328 account_id: &AccountId,
1329 instrument: &InstrumentAny,
1330 ts_now: UnixNanos,
1331) -> Option<OrderEventAny> {
1332 let order_filled_qty = order.filled_qty();
1333 let last_qty = report.filled_qty - order_filled_qty;
1334
1335 if last_qty <= Quantity::zero(instrument.size_precision()) {
1336 return None;
1337 }
1338
1339 let liquidity_side = match order.order_type() {
1340 OrderType::Market | OrderType::StopMarket | OrderType::MarketToLimit => {
1341 LiquiditySide::Taker
1342 }
1343 _ if order.is_post_only() => LiquiditySide::Maker,
1344 _ => LiquiditySide::NoLiquiditySide,
1345 };
1346
1347 let last_px = calculate_incremental_fill_price(order, report, instrument)?;
1348
1349 let trade_id = TradeId::new(format!(
1350 "INFERRED-{}-{}",
1351 order.client_order_id(),
1352 ts_now.as_u64()
1353 ));
1354
1355 let venue_order_id = order.venue_order_id().unwrap_or(report.venue_order_id);
1356
1357 log::info!(
1358 color = LogColor::Blue as u8;
1359 "Generated inferred fill for {}: qty={}, px={}",
1360 order.client_order_id(),
1361 last_qty,
1362 last_px,
1363 );
1364
1365 Some(OrderEventAny::Filled(OrderFilled::new(
1366 order.trader_id(),
1367 order.strategy_id(),
1368 order.instrument_id(),
1369 order.client_order_id(),
1370 venue_order_id,
1371 *account_id,
1372 trade_id,
1373 order.order_side(),
1374 order.order_type(),
1375 last_qty,
1376 last_px,
1377 instrument.quote_currency(),
1378 liquidity_side,
1379 UUID4::new(),
1380 report.ts_last,
1381 ts_now,
1382 true, None, None, )))
1386}
1387
1388fn calculate_incremental_fill_price(
1390 order: &OrderAny,
1391 report: &OrderStatusReport,
1392 instrument: &InstrumentAny,
1393) -> Option<Price> {
1394 let order_filled_qty = order.filled_qty();
1395
1396 if order_filled_qty.is_zero() {
1398 if let Some(avg_px) = report.avg_px {
1399 return Price::from_decimal_dp(avg_px, instrument.price_precision()).ok();
1400 }
1401 if let Some(price) = report.price {
1402 return Some(price);
1403 }
1404 if let Some(price) = order.price() {
1405 return Some(price);
1406 }
1407 log::warn!(
1408 "Cannot determine fill price for {}: no avg_px, report price, or order price",
1409 order.client_order_id()
1410 );
1411 return None;
1412 }
1413
1414 if let Some(report_avg_px) = report.avg_px {
1416 let Some(order_avg_px) = order.avg_px() else {
1417 return Price::from_decimal_dp(report_avg_px, instrument.price_precision()).ok();
1419 };
1420 let report_filled_qty = report.filled_qty;
1421 let last_qty = report_filled_qty - order_filled_qty;
1422
1423 let report_notional = report_avg_px * report_filled_qty.as_decimal();
1424 let order_notional = Decimal::from_f64_retain(order_avg_px).unwrap_or_default()
1425 * order_filled_qty.as_decimal();
1426 let last_notional = report_notional - order_notional;
1427 let last_px_decimal = last_notional / last_qty.as_decimal();
1428
1429 return Price::from_decimal_dp(last_px_decimal, instrument.price_precision()).ok();
1430 }
1431
1432 if let Some(price) = report.price {
1434 return Some(price);
1435 }
1436
1437 order.price()
1438}
1439
1440#[cfg(test)]
1441mod tests {
1442 use nautilus_model::{
1443 instruments::stubs::{audusd_sim, crypto_perpetual_ethusdt},
1444 orders::OrderTestBuilder,
1445 reports::OrderStatusReport,
1446 };
1447 use rstest::{fixture, rstest};
1448 use rust_decimal_macros::dec;
1449
1450 use super::*;
1451
1452 #[fixture]
1453 fn instrument() -> InstrumentAny {
1454 InstrumentAny::CurrencyPair(audusd_sim())
1455 }
1456
1457 fn create_test_venue_order_id(value: &str) -> VenueOrderId {
1458 VenueOrderId::new(value)
1459 }
1460
1461 #[rstest]
1462 fn test_fill_snapshot_direction() {
1463 let venue_order_id = create_test_venue_order_id("ORDER1");
1464 let buy_fill = FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id);
1465 assert_eq!(buy_fill.direction(), 1);
1466
1467 let sell_fill =
1468 FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id);
1469 assert_eq!(sell_fill.direction(), -1);
1470 }
1471
1472 #[rstest]
1473 fn test_simulate_position_accumulate_long() {
1474 let venue_order_id = create_test_venue_order_id("ORDER1");
1475 let fills = vec![
1476 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1477 FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(102), venue_order_id),
1478 ];
1479
1480 let (qty, value) = simulate_position(&fills);
1481 assert_eq!(qty, dec!(15));
1482 assert_eq!(value, dec!(1510)); }
1484
1485 #[rstest]
1486 fn test_simulate_position_close_and_flip() {
1487 let venue_order_id = create_test_venue_order_id("ORDER1");
1488 let fills = vec![
1489 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1490 FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(102), venue_order_id),
1491 ];
1492
1493 let (qty, value) = simulate_position(&fills);
1494 assert_eq!(qty, dec!(-5)); assert_eq!(value, dec!(510)); }
1497
1498 #[rstest]
1499 fn test_simulate_position_partial_close() {
1500 let venue_order_id = create_test_venue_order_id("ORDER1");
1501 let fills = vec![
1502 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1503 FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(102), venue_order_id),
1504 ];
1505
1506 let (qty, value) = simulate_position(&fills);
1507 assert_eq!(qty, dec!(5));
1508 assert_eq!(value, dec!(500)); let avg_px = value / qty;
1512 assert_eq!(avg_px, dec!(100));
1513 }
1514
1515 #[rstest]
1516 fn test_simulate_position_multiple_partial_closes() {
1517 let venue_order_id = create_test_venue_order_id("ORDER1");
1518 let fills = vec![
1519 FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(10.0), venue_order_id),
1520 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), ];
1523
1524 let (qty, value) = simulate_position(&fills);
1525 assert_eq!(qty, dec!(50));
1526 assert!((value - dec!(500)).abs() < dec!(0.01));
1530
1531 let avg_px = value / qty;
1533 assert!((avg_px - dec!(10.0)).abs() < dec!(0.01));
1534 }
1535
1536 #[rstest]
1537 fn test_simulate_position_short_partial_close() {
1538 let venue_order_id = create_test_venue_order_id("ORDER1");
1539 let fills = vec![
1540 FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
1541 FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(98), venue_order_id), ];
1543
1544 let (qty, value) = simulate_position(&fills);
1545 assert_eq!(qty, dec!(-5));
1546 assert_eq!(value, dec!(500)); let avg_px = value / qty.abs();
1550 assert_eq!(avg_px, dec!(100));
1551 }
1552
1553 #[rstest]
1554 fn test_detect_zero_crossings() {
1555 let venue_order_id = create_test_venue_order_id("ORDER1");
1556 let fills = vec![
1557 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1558 FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), FillSnapshot::new(3000, OrderSide::Buy, dec!(5), dec!(103), venue_order_id),
1560 FillSnapshot::new(4000, OrderSide::Sell, dec!(5), dec!(104), venue_order_id), ];
1562
1563 let crossings = detect_zero_crossings(&fills);
1564 assert_eq!(crossings.len(), 2);
1565 assert_eq!(crossings[0], 2000);
1566 assert_eq!(crossings[1], 4000);
1567 }
1568
1569 #[rstest]
1570 fn test_check_position_match_exact() {
1571 let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100), dec!(0.0001));
1572 assert!(result);
1573 }
1574
1575 #[rstest]
1576 fn test_check_position_match_within_tolerance() {
1577 let result =
1580 check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100.005), dec!(0.0001));
1581 assert!(result);
1582 }
1583
1584 #[rstest]
1585 fn test_check_position_match_qty_mismatch() {
1586 let result = check_position_match(dec!(10), dec!(1000), dec!(11), dec!(100), dec!(0.0001));
1587 assert!(!result);
1588 }
1589
1590 #[rstest]
1591 fn test_check_position_match_both_flat() {
1592 let result = check_position_match(dec!(0), dec!(0), dec!(0), dec!(0), dec!(0.0001));
1593 assert!(result);
1594 }
1595
1596 #[rstest]
1597 fn test_reconciliation_price_flat_to_long(_instrument: InstrumentAny) {
1598 let result = calculate_reconciliation_price(dec!(0), None, dec!(10), Some(dec!(100)));
1599 assert!(result.is_some());
1600 assert_eq!(result.unwrap(), dec!(100));
1601 }
1602
1603 #[rstest]
1604 fn test_reconciliation_price_no_target_avg_px(_instrument: InstrumentAny) {
1605 let result = calculate_reconciliation_price(dec!(5), Some(dec!(100)), dec!(10), None);
1606 assert!(result.is_none());
1607 }
1608
1609 #[rstest]
1610 fn test_reconciliation_price_no_quantity_change(_instrument: InstrumentAny) {
1611 let result =
1612 calculate_reconciliation_price(dec!(10), Some(dec!(100)), dec!(10), Some(dec!(105)));
1613 assert!(result.is_none());
1614 }
1615
1616 #[rstest]
1617 fn test_reconciliation_price_long_position_increase(_instrument: InstrumentAny) {
1618 let result =
1619 calculate_reconciliation_price(dec!(10), Some(dec!(100)), dec!(15), Some(dec!(102)));
1620 assert!(result.is_some());
1621 assert_eq!(result.unwrap(), dec!(106));
1623 }
1624
1625 #[rstest]
1626 fn test_reconciliation_price_flat_to_short(_instrument: InstrumentAny) {
1627 let result = calculate_reconciliation_price(dec!(0), None, dec!(-10), Some(dec!(100)));
1628 assert!(result.is_some());
1629 assert_eq!(result.unwrap(), dec!(100));
1630 }
1631
1632 #[rstest]
1633 fn test_reconciliation_price_long_to_flat(_instrument: InstrumentAny) {
1634 let result =
1637 calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(0), Some(dec!(0)));
1638 assert!(result.is_some());
1639 assert_eq!(result.unwrap(), dec!(1.20));
1640 }
1641
1642 #[rstest]
1643 fn test_reconciliation_price_short_to_flat(_instrument: InstrumentAny) {
1644 let result = calculate_reconciliation_price(dec!(-50), Some(dec!(2.50)), dec!(0), None);
1647 assert!(result.is_some());
1648 assert_eq!(result.unwrap(), dec!(2.50));
1649 }
1650
1651 #[rstest]
1652 fn test_reconciliation_price_short_position_increase(_instrument: InstrumentAny) {
1653 let result = calculate_reconciliation_price(
1658 dec!(-100),
1659 Some(dec!(1.30)),
1660 dec!(-200),
1661 Some(dec!(1.28)),
1662 );
1663 assert!(result.is_some());
1664 assert_eq!(result.unwrap(), dec!(1.26));
1665 }
1666
1667 #[rstest]
1668 fn test_reconciliation_price_long_position_decrease(_instrument: InstrumentAny) {
1669 let result = calculate_reconciliation_price(
1671 dec!(200),
1672 Some(dec!(1.20)),
1673 dec!(100),
1674 Some(dec!(1.20)),
1675 );
1676 assert!(result.is_some());
1677 assert_eq!(result.unwrap(), dec!(1.20));
1678 }
1679
1680 #[rstest]
1681 fn test_reconciliation_price_long_to_short_flip(_instrument: InstrumentAny) {
1682 let result = calculate_reconciliation_price(
1685 dec!(100),
1686 Some(dec!(1.20)),
1687 dec!(-100),
1688 Some(dec!(1.25)),
1689 );
1690 assert!(result.is_some());
1691 assert_eq!(result.unwrap(), dec!(1.25));
1692 }
1693
1694 #[rstest]
1695 fn test_reconciliation_price_short_to_long_flip(_instrument: InstrumentAny) {
1696 let result = calculate_reconciliation_price(
1699 dec!(-100),
1700 Some(dec!(1.30)),
1701 dec!(100),
1702 Some(dec!(1.25)),
1703 );
1704 assert!(result.is_some());
1705 assert_eq!(result.unwrap(), dec!(1.25));
1706 }
1707
1708 #[rstest]
1709 fn test_reconciliation_price_complex_scenario(_instrument: InstrumentAny) {
1710 let result = calculate_reconciliation_price(
1715 dec!(150),
1716 Some(dec!(1.23456)),
1717 dec!(250),
1718 Some(dec!(1.24567)),
1719 );
1720 assert!(result.is_some());
1721 assert_eq!(result.unwrap(), dec!(1.262335));
1722 }
1723
1724 #[rstest]
1725 fn test_reconciliation_price_zero_target_avg_px(_instrument: InstrumentAny) {
1726 let result =
1727 calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(200), Some(dec!(0)));
1728 assert!(result.is_none());
1729 }
1730
1731 #[rstest]
1732 fn test_reconciliation_price_negative_price(_instrument: InstrumentAny) {
1733 let result = calculate_reconciliation_price(
1738 dec!(100),
1739 Some(dec!(2.00)),
1740 dec!(200),
1741 Some(dec!(1.00)),
1742 );
1743 assert!(result.is_none());
1744 }
1745
1746 #[rstest]
1747 fn test_reconciliation_price_flip_simulation_compatibility() {
1748 let venue_order_id = create_test_venue_order_id("ORDER1");
1749 let recon_px = calculate_reconciliation_price(
1753 dec!(100),
1754 Some(dec!(1.20)),
1755 dec!(-100),
1756 Some(dec!(1.25)),
1757 )
1758 .expect("reconciliation price");
1759
1760 assert_eq!(recon_px, dec!(1.25));
1761
1762 let fills = vec![
1764 FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
1765 FillSnapshot::new(2000, OrderSide::Sell, dec!(200), recon_px, venue_order_id),
1766 ];
1767
1768 let (final_qty, final_value) = simulate_position(&fills);
1769 assert_eq!(final_qty, dec!(-100));
1770 let final_avg = final_value / final_qty.abs();
1771 assert_eq!(final_avg, dec!(1.25), "Final average should match target");
1772 }
1773
1774 #[rstest]
1775 fn test_reconciliation_price_accumulation_simulation_compatibility() {
1776 let venue_order_id = create_test_venue_order_id("ORDER1");
1777 let recon_px = calculate_reconciliation_price(
1780 dec!(100),
1781 Some(dec!(1.20)),
1782 dec!(200),
1783 Some(dec!(1.22)),
1784 )
1785 .expect("reconciliation price");
1786
1787 let fills = vec![
1789 FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
1790 FillSnapshot::new(2000, OrderSide::Buy, dec!(100), recon_px, venue_order_id),
1791 ];
1792
1793 let (final_qty, final_value) = simulate_position(&fills);
1794 assert_eq!(final_qty, dec!(200));
1795 let final_avg = final_value / final_qty.abs();
1796 assert_eq!(final_avg, dec!(1.22), "Final average should match target");
1797 }
1798
1799 #[rstest]
1800 fn test_simulate_position_accumulate_short() {
1801 let venue_order_id = create_test_venue_order_id("ORDER1");
1802 let fills = vec![
1803 FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
1804 FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(98), venue_order_id),
1805 ];
1806
1807 let (qty, value) = simulate_position(&fills);
1808 assert_eq!(qty, dec!(-15));
1809 assert_eq!(value, dec!(1490)); }
1811
1812 #[rstest]
1813 fn test_simulate_position_short_to_long_flip() {
1814 let venue_order_id = create_test_venue_order_id("ORDER1");
1815 let fills = vec![
1816 FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
1817 FillSnapshot::new(2000, OrderSide::Buy, dec!(15), dec!(102), venue_order_id),
1818 ];
1819
1820 let (qty, value) = simulate_position(&fills);
1821 assert_eq!(qty, dec!(5)); assert_eq!(value, dec!(510)); }
1824
1825 #[rstest]
1826 fn test_simulate_position_multiple_flips() {
1827 let venue_order_id = create_test_venue_order_id("ORDER1");
1828 let fills = vec![
1829 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1830 FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(105), venue_order_id), FillSnapshot::new(3000, OrderSide::Buy, dec!(10), dec!(110), venue_order_id), ];
1833
1834 let (qty, value) = simulate_position(&fills);
1835 assert_eq!(qty, dec!(5)); assert_eq!(value, dec!(550)); }
1838
1839 #[rstest]
1840 fn test_simulate_position_empty_fills() {
1841 let fills: Vec<FillSnapshot> = vec![];
1842 let (qty, value) = simulate_position(&fills);
1843 assert_eq!(qty, dec!(0));
1844 assert_eq!(value, dec!(0));
1845 }
1846
1847 #[rstest]
1848 fn test_detect_zero_crossings_no_crossings() {
1849 let venue_order_id = create_test_venue_order_id("ORDER1");
1850 let fills = vec![
1851 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1852 FillSnapshot::new(2000, OrderSide::Buy, dec!(5), dec!(102), venue_order_id),
1853 ];
1854
1855 let crossings = detect_zero_crossings(&fills);
1856 assert_eq!(crossings.len(), 0);
1857 }
1858
1859 #[rstest]
1860 fn test_detect_zero_crossings_single_crossing() {
1861 let venue_order_id = create_test_venue_order_id("ORDER1");
1862 let fills = vec![
1863 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1864 FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), venue_order_id), ];
1866
1867 let crossings = detect_zero_crossings(&fills);
1868 assert_eq!(crossings.len(), 1);
1869 assert_eq!(crossings[0], 2000);
1870 }
1871
1872 #[rstest]
1873 fn test_detect_zero_crossings_empty_fills() {
1874 let fills: Vec<FillSnapshot> = vec![];
1875 let crossings = detect_zero_crossings(&fills);
1876 assert_eq!(crossings.len(), 0);
1877 }
1878
1879 #[rstest]
1880 fn test_detect_zero_crossings_long_to_short_flip() {
1881 let venue_order_id = create_test_venue_order_id("ORDER1");
1882 let fills = vec![
1884 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1885 FillSnapshot::new(2000, OrderSide::Sell, dec!(15), dec!(102), venue_order_id), ];
1887
1888 let crossings = detect_zero_crossings(&fills);
1889 assert_eq!(crossings.len(), 1);
1890 assert_eq!(crossings[0], 2000); }
1892
1893 #[rstest]
1894 fn test_detect_zero_crossings_short_to_long_flip() {
1895 let venue_order_id = create_test_venue_order_id("ORDER1");
1896 let fills = vec![
1898 FillSnapshot::new(1000, OrderSide::Sell, dec!(10), dec!(100), venue_order_id),
1899 FillSnapshot::new(2000, OrderSide::Buy, dec!(20), dec!(102), venue_order_id), ];
1901
1902 let crossings = detect_zero_crossings(&fills);
1903 assert_eq!(crossings.len(), 1);
1904 assert_eq!(crossings[0], 2000);
1905 }
1906
1907 #[rstest]
1908 fn test_detect_zero_crossings_multiple_flips() {
1909 let venue_order_id = create_test_venue_order_id("ORDER1");
1910 let fills = vec![
1911 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
1912 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), ];
1916
1917 let crossings = detect_zero_crossings(&fills);
1918 assert_eq!(crossings.len(), 2);
1919 assert_eq!(crossings[0], 2000); assert_eq!(crossings[1], 4000); }
1922
1923 #[rstest]
1924 fn test_check_position_match_outside_tolerance() {
1925 let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(101), dec!(0.0001));
1928 assert!(!result);
1929 }
1930
1931 #[rstest]
1932 fn test_check_position_match_edge_of_tolerance() {
1933 let result =
1936 check_position_match(dec!(10), dec!(1000), dec!(10), dec!(100.01), dec!(0.0001));
1937 assert!(result);
1938 }
1939
1940 #[rstest]
1941 fn test_check_position_match_zero_venue_avg_px() {
1942 let result = check_position_match(dec!(10), dec!(1000), dec!(10), dec!(0), dec!(0.0001));
1943 assert!(!result); }
1945
1946 #[rstest]
1947 fn test_adjust_fills_no_fills() {
1948 let venue_position = VenuePositionSnapshot {
1949 side: OrderSide::Buy,
1950 qty: dec!(0.02),
1951 avg_px: dec!(4100.00),
1952 };
1953 let instrument = instrument();
1954 let result =
1955 adjust_fills_for_partial_window(&[], &venue_position, &instrument, dec!(0.0001));
1956 assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
1957 }
1958
1959 #[rstest]
1960 fn test_adjust_fills_flat_position() {
1961 let venue_order_id = create_test_venue_order_id("ORDER1");
1962 let fills = vec![FillSnapshot::new(
1963 1000,
1964 OrderSide::Buy,
1965 dec!(0.01),
1966 dec!(4100.00),
1967 venue_order_id,
1968 )];
1969 let venue_position = VenuePositionSnapshot {
1970 side: OrderSide::Buy,
1971 qty: dec!(0),
1972 avg_px: dec!(0),
1973 };
1974 let instrument = instrument();
1975 let result =
1976 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
1977 assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
1978 }
1979
1980 #[rstest]
1981 fn test_adjust_fills_complete_lifecycle_no_adjustment() {
1982 let venue_order_id = create_test_venue_order_id("ORDER1");
1983 let venue_order_id2 = create_test_venue_order_id("ORDER2");
1984 let fills = vec![
1985 FillSnapshot::new(
1986 1000,
1987 OrderSide::Buy,
1988 dec!(0.01),
1989 dec!(4100.00),
1990 venue_order_id,
1991 ),
1992 FillSnapshot::new(
1993 2000,
1994 OrderSide::Buy,
1995 dec!(0.01),
1996 dec!(4100.00),
1997 venue_order_id2,
1998 ),
1999 ];
2000 let venue_position = VenuePositionSnapshot {
2001 side: OrderSide::Buy,
2002 qty: dec!(0.02),
2003 avg_px: dec!(4100.00),
2004 };
2005 let instrument = instrument();
2006 let result =
2007 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2008 assert!(matches!(result, FillAdjustmentResult::NoAdjustment));
2009 }
2010
2011 #[rstest]
2012 fn test_adjust_fills_incomplete_lifecycle_adds_synthetic() {
2013 let venue_order_id = create_test_venue_order_id("ORDER1");
2014 let fills = vec![FillSnapshot::new(
2016 2000,
2017 OrderSide::Buy,
2018 dec!(0.02),
2019 dec!(4200.00),
2020 venue_order_id,
2021 )];
2022 let venue_position = VenuePositionSnapshot {
2023 side: OrderSide::Buy,
2024 qty: dec!(0.04),
2025 avg_px: dec!(4100.00),
2026 };
2027 let instrument = instrument();
2028 let result =
2029 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2030
2031 match result {
2032 FillAdjustmentResult::AddSyntheticOpening {
2033 synthetic_fill,
2034 existing_fills,
2035 } => {
2036 assert_eq!(synthetic_fill.side, OrderSide::Buy);
2037 assert_eq!(synthetic_fill.qty, dec!(0.02)); assert_eq!(existing_fills.len(), 1);
2039 }
2040 _ => panic!("Expected AddSyntheticOpening"),
2041 }
2042 }
2043
2044 #[rstest]
2045 fn test_adjust_fills_with_zero_crossings() {
2046 let venue_order_id1 = create_test_venue_order_id("ORDER1");
2047 let venue_order_id2 = create_test_venue_order_id("ORDER2");
2048 let venue_order_id3 = create_test_venue_order_id("ORDER3");
2049
2050 let fills = vec![
2053 FillSnapshot::new(
2054 1000,
2055 OrderSide::Buy,
2056 dec!(0.02),
2057 dec!(4100.00),
2058 venue_order_id1,
2059 ),
2060 FillSnapshot::new(
2061 2000,
2062 OrderSide::Sell,
2063 dec!(0.02),
2064 dec!(4150.00),
2065 venue_order_id2,
2066 ), FillSnapshot::new(
2068 3000,
2069 OrderSide::Buy,
2070 dec!(0.03),
2071 dec!(4200.00),
2072 venue_order_id3,
2073 ), ];
2075
2076 let venue_position = VenuePositionSnapshot {
2077 side: OrderSide::Buy,
2078 qty: dec!(0.03),
2079 avg_px: dec!(4200.00),
2080 };
2081
2082 let instrument = instrument();
2083 let result =
2084 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2085
2086 match result {
2088 FillAdjustmentResult::FilterToCurrentLifecycle {
2089 last_zero_crossing_ts,
2090 current_lifecycle_fills,
2091 } => {
2092 assert_eq!(last_zero_crossing_ts, 2000);
2093 assert_eq!(current_lifecycle_fills.len(), 1);
2094 assert_eq!(current_lifecycle_fills[0].venue_order_id, venue_order_id3);
2095 }
2096 _ => panic!("Expected FilterToCurrentLifecycle, was {result:?}"),
2097 }
2098 }
2099
2100 #[rstest]
2101 fn test_adjust_fills_multiple_zero_crossings_mismatch() {
2102 let venue_order_id1 = create_test_venue_order_id("ORDER1");
2103 let venue_order_id2 = create_test_venue_order_id("ORDER2");
2104 let _venue_order_id3 = create_test_venue_order_id("ORDER3");
2105 let venue_order_id4 = create_test_venue_order_id("ORDER4");
2106 let venue_order_id5 = create_test_venue_order_id("ORDER5");
2107
2108 let fills = vec![
2111 FillSnapshot::new(
2112 1000,
2113 OrderSide::Buy,
2114 dec!(0.05),
2115 dec!(4000.00),
2116 venue_order_id1,
2117 ),
2118 FillSnapshot::new(
2119 2000,
2120 OrderSide::Sell,
2121 dec!(0.05),
2122 dec!(4050.00),
2123 venue_order_id2,
2124 ), FillSnapshot::new(
2126 3000,
2127 OrderSide::Buy,
2128 dec!(0.05),
2129 dec!(4000.00),
2130 venue_order_id4,
2131 ), FillSnapshot::new(
2133 4000,
2134 OrderSide::Buy,
2135 dec!(0.05),
2136 dec!(4100.00),
2137 venue_order_id5,
2138 ), ];
2140
2141 let venue_position = VenuePositionSnapshot {
2142 side: OrderSide::Buy,
2143 qty: dec!(0.05),
2144 avg_px: dec!(4142.04),
2145 };
2146
2147 let instrument = instrument();
2148 let result =
2149 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2150
2151 match result {
2153 FillAdjustmentResult::ReplaceCurrentLifecycle {
2154 synthetic_fill,
2155 first_venue_order_id,
2156 } => {
2157 assert_eq!(synthetic_fill.qty, dec!(0.05));
2158 assert_eq!(synthetic_fill.px, dec!(4142.04));
2159 assert_eq!(synthetic_fill.side, OrderSide::Buy);
2160 assert_eq!(first_venue_order_id, venue_order_id4);
2161 }
2162 _ => panic!("Expected ReplaceCurrentLifecycle, was {result:?}"),
2163 }
2164 }
2165
2166 #[rstest]
2167 fn test_adjust_fills_short_position() {
2168 let venue_order_id = create_test_venue_order_id("ORDER1");
2169
2170 let fills = vec![FillSnapshot::new(
2172 1000,
2173 OrderSide::Sell,
2174 dec!(0.02),
2175 dec!(4120.00),
2176 venue_order_id,
2177 )];
2178
2179 let venue_position = VenuePositionSnapshot {
2180 side: OrderSide::Sell,
2181 qty: dec!(0.05),
2182 avg_px: dec!(4100.00),
2183 };
2184
2185 let instrument = instrument();
2186 let result =
2187 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2188
2189 match result {
2191 FillAdjustmentResult::AddSyntheticOpening {
2192 synthetic_fill,
2193 existing_fills,
2194 } => {
2195 assert_eq!(synthetic_fill.side, OrderSide::Sell);
2196 assert_eq!(synthetic_fill.qty, dec!(0.03)); assert_eq!(existing_fills.len(), 1);
2198 }
2199 _ => panic!("Expected AddSyntheticOpening, was {result:?}"),
2200 }
2201 }
2202
2203 #[rstest]
2204 fn test_adjust_fills_timestamp_underflow_protection() {
2205 let venue_order_id = create_test_venue_order_id("ORDER1");
2206
2207 let fills = vec![FillSnapshot::new(
2209 0,
2210 OrderSide::Buy,
2211 dec!(0.01),
2212 dec!(4100.00),
2213 venue_order_id,
2214 )];
2215
2216 let venue_position = VenuePositionSnapshot {
2217 side: OrderSide::Buy,
2218 qty: dec!(0.02),
2219 avg_px: dec!(4100.00),
2220 };
2221
2222 let instrument = instrument();
2223 let result =
2224 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2225
2226 match result {
2228 FillAdjustmentResult::AddSyntheticOpening { synthetic_fill, .. } => {
2229 assert_eq!(synthetic_fill.ts_event, 0); }
2231 _ => panic!("Expected AddSyntheticOpening, was {result:?}"),
2232 }
2233 }
2234
2235 #[rstest]
2236 fn test_adjust_fills_with_flip_scenario() {
2237 let venue_order_id1 = create_test_venue_order_id("ORDER1");
2238 let venue_order_id2 = create_test_venue_order_id("ORDER2");
2239
2240 let fills = vec![
2242 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id1),
2243 FillSnapshot::new(2000, OrderSide::Sell, dec!(20), dec!(105), venue_order_id2), ];
2245
2246 let venue_position = VenuePositionSnapshot {
2247 side: OrderSide::Sell,
2248 qty: dec!(10),
2249 avg_px: dec!(105),
2250 };
2251
2252 let instrument = instrument();
2253 let result =
2254 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2255
2256 match result {
2258 FillAdjustmentResult::NoAdjustment => {
2259 let (qty, value) = simulate_position(&fills);
2261 assert_eq!(qty, dec!(-10));
2262 let avg = value / qty.abs();
2263 assert_eq!(avg, dec!(105));
2264 }
2265 _ => panic!("Expected NoAdjustment for matching flip, was {result:?}"),
2266 }
2267 }
2268
2269 #[rstest]
2270 fn test_detect_zero_crossings_complex_lifecycle() {
2271 let venue_order_id = create_test_venue_order_id("ORDER1");
2272 let fills = vec![
2274 FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
2275 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), ];
2281
2282 let crossings = detect_zero_crossings(&fills);
2283 assert_eq!(crossings.len(), 3);
2284 assert_eq!(crossings[0], 3000); assert_eq!(crossings[1], 4000); assert_eq!(crossings[2], 6000); }
2288
2289 #[rstest]
2290 fn test_reconciliation_price_partial_close() {
2291 let venue_order_id = create_test_venue_order_id("ORDER1");
2292 let recon_px =
2294 calculate_reconciliation_price(dec!(100), Some(dec!(1.20)), dec!(50), Some(dec!(1.20)))
2295 .expect("reconciliation price");
2296
2297 let fills = vec![
2299 FillSnapshot::new(1000, OrderSide::Buy, dec!(100), dec!(1.20), venue_order_id),
2300 FillSnapshot::new(2000, OrderSide::Sell, dec!(50), recon_px, venue_order_id),
2301 ];
2302
2303 let (final_qty, final_value) = simulate_position(&fills);
2304 assert_eq!(final_qty, dec!(50));
2305 let final_avg = final_value / final_qty.abs();
2306 assert_eq!(final_avg, dec!(1.20), "Average should be maintained");
2307 }
2308
2309 #[rstest]
2310 fn test_detect_zero_crossings_identical_timestamps() {
2311 let venue_order_id1 = create_test_venue_order_id("ORDER1");
2312 let venue_order_id2 = create_test_venue_order_id("ORDER2");
2313
2314 let fills = vec![
2316 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id1),
2317 FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(102), venue_order_id1),
2318 FillSnapshot::new(2000, OrderSide::Sell, dec!(5), dec!(103), venue_order_id2), ];
2320
2321 let crossings = detect_zero_crossings(&fills);
2322
2323 assert_eq!(crossings.len(), 1);
2325 assert_eq!(crossings[0], 2000);
2326
2327 let (qty, _) = simulate_position(&fills);
2329 assert_eq!(qty, dec!(0));
2330 }
2331
2332 #[rstest]
2333 fn test_detect_zero_crossings_five_lifecycles() {
2334 let venue_order_id = create_test_venue_order_id("ORDER1");
2335
2336 let fills = vec![
2338 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2340 FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(101), venue_order_id),
2341 FillSnapshot::new(3000, OrderSide::Sell, dec!(20), dec!(102), venue_order_id),
2343 FillSnapshot::new(4000, OrderSide::Buy, dec!(20), dec!(101), venue_order_id),
2344 FillSnapshot::new(5000, OrderSide::Buy, dec!(15), dec!(103), venue_order_id),
2346 FillSnapshot::new(6000, OrderSide::Sell, dec!(15), dec!(104), venue_order_id),
2347 FillSnapshot::new(7000, OrderSide::Sell, dec!(25), dec!(105), venue_order_id),
2349 FillSnapshot::new(8000, OrderSide::Buy, dec!(25), dec!(104), venue_order_id),
2350 FillSnapshot::new(9000, OrderSide::Buy, dec!(30), dec!(106), venue_order_id),
2352 ];
2353
2354 let crossings = detect_zero_crossings(&fills);
2355
2356 assert_eq!(crossings.len(), 4);
2358 assert_eq!(crossings[0], 2000);
2359 assert_eq!(crossings[1], 4000);
2360 assert_eq!(crossings[2], 6000);
2361 assert_eq!(crossings[3], 8000);
2362
2363 let (qty, _) = simulate_position(&fills);
2365 assert_eq!(qty, dec!(30));
2366 }
2367
2368 #[rstest]
2369 fn test_adjust_fills_five_zero_crossings(instrument: InstrumentAny) {
2370 let venue_order_id = create_test_venue_order_id("ORDER1");
2371
2372 let fills = vec![
2374 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2376 FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(101), venue_order_id),
2377 FillSnapshot::new(3000, OrderSide::Sell, dec!(20), dec!(102), venue_order_id),
2378 FillSnapshot::new(4000, OrderSide::Buy, dec!(20), dec!(101), venue_order_id),
2379 FillSnapshot::new(5000, OrderSide::Buy, dec!(15), dec!(103), venue_order_id),
2380 FillSnapshot::new(6000, OrderSide::Sell, dec!(15), dec!(104), venue_order_id),
2381 FillSnapshot::new(7000, OrderSide::Sell, dec!(25), dec!(105), venue_order_id),
2382 FillSnapshot::new(8000, OrderSide::Buy, dec!(25), dec!(104), venue_order_id),
2383 FillSnapshot::new(9000, OrderSide::Buy, dec!(30), dec!(106), venue_order_id),
2385 ];
2386
2387 let venue_position = VenuePositionSnapshot {
2388 side: OrderSide::Buy,
2389 qty: dec!(30),
2390 avg_px: dec!(106),
2391 };
2392
2393 let result =
2394 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2395
2396 match result {
2398 FillAdjustmentResult::FilterToCurrentLifecycle {
2399 last_zero_crossing_ts,
2400 current_lifecycle_fills,
2401 } => {
2402 assert_eq!(last_zero_crossing_ts, 8000);
2403 assert_eq!(current_lifecycle_fills.len(), 1);
2404 assert_eq!(current_lifecycle_fills[0].ts_event, 9000);
2405 assert_eq!(current_lifecycle_fills[0].qty, dec!(30));
2406 }
2407 _ => panic!("Expected FilterToCurrentLifecycle, was {result:?}"),
2408 }
2409 }
2410
2411 #[rstest]
2412 fn test_adjust_fills_alternating_long_short_positions(instrument: InstrumentAny) {
2413 let venue_order_id = create_test_venue_order_id("ORDER1");
2414
2415 let fills = vec![
2418 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2419 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), ];
2424
2425 let venue_position = VenuePositionSnapshot {
2427 side: OrderSide::Buy,
2428 qty: dec!(10),
2429 avg_px: dec!(102),
2430 };
2431
2432 let result =
2433 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2434
2435 assert!(
2439 matches!(result, FillAdjustmentResult::NoAdjustment),
2440 "Expected NoAdjustment (continuous lifecycle with matching position), was {result:?}"
2441 );
2442 }
2443
2444 #[rstest]
2445 fn test_adjust_fills_with_flat_crossings(instrument: InstrumentAny) {
2446 let venue_order_id = create_test_venue_order_id("ORDER1");
2447
2448 let fills = vec![
2450 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), venue_order_id),
2451 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), ];
2456
2457 let venue_position = VenuePositionSnapshot {
2459 side: OrderSide::Buy,
2460 qty: dec!(10),
2461 avg_px: dec!(98),
2462 };
2463
2464 let result =
2465 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2466
2467 match result {
2470 FillAdjustmentResult::FilterToCurrentLifecycle {
2471 last_zero_crossing_ts,
2472 current_lifecycle_fills,
2473 } => {
2474 assert_eq!(last_zero_crossing_ts, 4000);
2475 assert_eq!(current_lifecycle_fills.len(), 1);
2476 assert_eq!(current_lifecycle_fills[0].ts_event, 5000);
2477 assert_eq!(current_lifecycle_fills[0].qty, dec!(10));
2478 }
2479 _ => panic!("Expected FilterToCurrentLifecycle, was {result:?}"),
2480 }
2481 }
2482
2483 #[rstest]
2484 fn test_replace_current_lifecycle_uses_first_venue_order_id(instrument: InstrumentAny) {
2485 let order_id_1 = create_test_venue_order_id("ORDER1");
2486 let order_id_2 = create_test_venue_order_id("ORDER2");
2487 let order_id_3 = create_test_venue_order_id("ORDER3");
2488
2489 let fills = vec![
2491 FillSnapshot::new(1000, OrderSide::Buy, dec!(10), dec!(100), order_id_1),
2492 FillSnapshot::new(2000, OrderSide::Sell, dec!(10), dec!(102), order_id_1), FillSnapshot::new(3000, OrderSide::Buy, dec!(5), dec!(103), order_id_2),
2495 FillSnapshot::new(4000, OrderSide::Buy, dec!(5), dec!(104), order_id_3),
2496 ];
2497
2498 let venue_position = VenuePositionSnapshot {
2500 side: OrderSide::Buy,
2501 qty: dec!(15),
2502 avg_px: dec!(105),
2503 };
2504
2505 let result =
2506 adjust_fills_for_partial_window(&fills, &venue_position, &instrument, dec!(0.0001));
2507
2508 match result {
2510 FillAdjustmentResult::ReplaceCurrentLifecycle {
2511 synthetic_fill,
2512 first_venue_order_id,
2513 } => {
2514 assert_eq!(first_venue_order_id, order_id_2);
2515 assert_eq!(synthetic_fill.venue_order_id, order_id_2);
2516 assert_eq!(synthetic_fill.qty, dec!(15));
2517 assert_eq!(synthetic_fill.px, dec!(105));
2518 }
2519 _ => panic!("Expected ReplaceCurrentLifecycle, was {result:?}"),
2520 }
2521 }
2522
2523 fn make_test_report(
2524 instrument_id: InstrumentId,
2525 order_type: OrderType,
2526 status: OrderStatus,
2527 filled_qty: &str,
2528 post_only: bool,
2529 ) -> OrderStatusReport {
2530 let account_id = AccountId::from("TEST-001");
2531 let mut report = OrderStatusReport::new(
2532 account_id,
2533 instrument_id,
2534 None,
2535 VenueOrderId::from("V-001"),
2536 OrderSide::Buy,
2537 order_type,
2538 TimeInForce::Gtc,
2539 status,
2540 Quantity::from("1.0"),
2541 Quantity::from(filled_qty),
2542 UnixNanos::from(1_000_000),
2543 UnixNanos::from(1_000_000),
2544 UnixNanos::from(1_000_000),
2545 None,
2546 )
2547 .with_price(Price::from("100.00"))
2548 .with_avg_px(100.0)
2549 .unwrap();
2550 report.post_only = post_only;
2551 report
2552 }
2553
2554 #[rstest]
2555 #[case::accepted(OrderStatus::Accepted, "0", 1, "Accepted")]
2556 #[case::triggered(OrderStatus::Triggered, "0", 1, "Accepted")]
2557 #[case::canceled(OrderStatus::Canceled, "0", 2, "Canceled")]
2558 #[case::expired(OrderStatus::Expired, "0", 2, "Expired")]
2559 #[case::filled(OrderStatus::Filled, "1.0", 2, "Filled")]
2560 #[case::partially_filled(OrderStatus::PartiallyFilled, "0.5", 2, "Filled")]
2561 #[case::rejected(OrderStatus::Rejected, "0", 1, "Rejected")]
2562 fn test_external_order_status_event_generation(
2563 #[case] status: OrderStatus,
2564 #[case] filled_qty: &str,
2565 #[case] expected_events: usize,
2566 #[case] last_event_type: &str,
2567 ) {
2568 let instrument = crypto_perpetual_ethusdt();
2569 let order = OrderTestBuilder::new(OrderType::Limit)
2570 .instrument_id(instrument.id())
2571 .side(OrderSide::Buy)
2572 .quantity(Quantity::from("1.0"))
2573 .price(Price::from("100.00"))
2574 .build();
2575 let report = make_test_report(instrument.id(), OrderType::Limit, status, filled_qty, false);
2576
2577 let events = generate_external_order_status_events(
2578 &order,
2579 &report,
2580 &AccountId::from("TEST-001"),
2581 &InstrumentAny::CryptoPerpetual(instrument),
2582 UnixNanos::from(2_000_000),
2583 );
2584
2585 assert_eq!(events.len(), expected_events, "status={status}");
2586 let last = events.last().unwrap();
2587 let actual_type = match last {
2588 OrderEventAny::Accepted(_) => "Accepted",
2589 OrderEventAny::Canceled(_) => "Canceled",
2590 OrderEventAny::Expired(_) => "Expired",
2591 OrderEventAny::Filled(_) => "Filled",
2592 OrderEventAny::Rejected(_) => "Rejected",
2593 _ => "Other",
2594 };
2595 assert_eq!(actual_type, last_event_type, "status={status}");
2596 }
2597
2598 #[rstest]
2599 #[case::market(OrderType::Market, false, LiquiditySide::Taker)]
2600 #[case::stop_market(OrderType::StopMarket, false, LiquiditySide::Taker)]
2601 #[case::trailing_stop_market(OrderType::TrailingStopMarket, false, LiquiditySide::Taker)]
2602 #[case::limit_post_only(OrderType::Limit, true, LiquiditySide::Maker)]
2603 #[case::limit_default(OrderType::Limit, false, LiquiditySide::NoLiquiditySide)]
2604 fn test_inferred_fill_liquidity_side(
2605 #[case] order_type: OrderType,
2606 #[case] post_only: bool,
2607 #[case] expected: LiquiditySide,
2608 ) {
2609 let instrument = crypto_perpetual_ethusdt();
2610 let order = match order_type {
2611 OrderType::Limit => OrderTestBuilder::new(order_type)
2612 .instrument_id(instrument.id())
2613 .side(OrderSide::Buy)
2614 .quantity(Quantity::from("1.0"))
2615 .price(Price::from("100.00"))
2616 .build(),
2617 OrderType::StopMarket => OrderTestBuilder::new(order_type)
2618 .instrument_id(instrument.id())
2619 .side(OrderSide::Buy)
2620 .quantity(Quantity::from("1.0"))
2621 .trigger_price(Price::from("100.00"))
2622 .build(),
2623 OrderType::TrailingStopMarket => OrderTestBuilder::new(order_type)
2624 .instrument_id(instrument.id())
2625 .side(OrderSide::Buy)
2626 .quantity(Quantity::from("1.0"))
2627 .trigger_price(Price::from("100.00"))
2628 .trailing_offset(dec!(1.0))
2629 .build(),
2630 _ => OrderTestBuilder::new(order_type)
2631 .instrument_id(instrument.id())
2632 .side(OrderSide::Buy)
2633 .quantity(Quantity::from("1.0"))
2634 .build(),
2635 };
2636 let report = make_test_report(
2637 instrument.id(),
2638 order_type,
2639 OrderStatus::Filled,
2640 "1.0",
2641 post_only,
2642 );
2643
2644 let fill = create_inferred_fill(
2645 &order,
2646 &report,
2647 &AccountId::from("TEST-001"),
2648 &InstrumentAny::CryptoPerpetual(instrument),
2649 UnixNanos::from(2_000_000),
2650 );
2651
2652 let filled = match fill.unwrap() {
2653 OrderEventAny::Filled(f) => f,
2654 _ => panic!("Expected Filled event"),
2655 };
2656 assert_eq!(
2657 filled.liquidity_side, expected,
2658 "order_type={order_type}, post_only={post_only}"
2659 );
2660 }
2661
2662 #[rstest]
2663 fn test_inferred_fill_no_price_returns_none() {
2664 let instrument = crypto_perpetual_ethusdt();
2665 let order = OrderTestBuilder::new(OrderType::Market)
2666 .instrument_id(instrument.id())
2667 .side(OrderSide::Buy)
2668 .quantity(Quantity::from("1.0"))
2669 .build();
2670
2671 let report = OrderStatusReport::new(
2672 AccountId::from("TEST-001"),
2673 instrument.id(),
2674 None,
2675 VenueOrderId::from("V-001"),
2676 OrderSide::Buy,
2677 OrderType::Market,
2678 TimeInForce::Ioc,
2679 OrderStatus::Filled,
2680 Quantity::from("1.0"),
2681 Quantity::from("1.0"),
2682 UnixNanos::from(1_000_000),
2683 UnixNanos::from(1_000_000),
2684 UnixNanos::from(1_000_000),
2685 None,
2686 );
2687
2688 let fill = create_inferred_fill(
2689 &order,
2690 &report,
2691 &AccountId::from("TEST-001"),
2692 &InstrumentAny::CryptoPerpetual(instrument),
2693 UnixNanos::from(2_000_000),
2694 );
2695
2696 assert!(fill.is_none());
2697 }
2698}