1use indexmap::{IndexMap, IndexSet};
24use nautilus_common::cache::Cache;
25use nautilus_core::UnixNanos;
26use nautilus_model::{
27 enums::{
28 AvgPxReconciliation, LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSide,
29 TimeInForce,
30 },
31 identifiers::{AccountId, InstrumentId, VenueOrderId},
32 instruments::{Instrument, InstrumentAny},
33 orders::Order,
34 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
35 types::{Money, Quantity},
36};
37use rust_decimal::{Decimal, RoundingStrategy};
38
39use super::{
40 RECONCILIATION_ORDER_TAG,
41 ids::{create_synthetic_trade_id, create_synthetic_venue_order_id},
42 reconciliation_price,
43 types::{FillAdjustmentResult, FillSnapshot, ReconciliationResult, VenuePositionSnapshot},
44};
45
46const DEFAULT_TOLERANCE: Decimal = Decimal::from_parts(1, 0, 0, false, 4); pub fn process_mass_status_for_reconciliation(
59 mass_status: &ExecutionMassStatus,
60 instrument: &InstrumentAny,
61 tolerance: Option<Decimal>,
62) -> anyhow::Result<ReconciliationResult> {
63 process_mass_status_for_reconciliation_inner(mass_status, instrument, tolerance, true)
64}
65
66pub fn process_mass_status_for_reconciliation_without_synthetic_reports(
74 mass_status: &ExecutionMassStatus,
75 instrument: &InstrumentAny,
76 tolerance: Option<Decimal>,
77) -> anyhow::Result<ReconciliationResult> {
78 process_mass_status_for_reconciliation_inner(mass_status, instrument, tolerance, false)
79}
80
81fn process_mass_status_for_reconciliation_inner(
82 mass_status: &ExecutionMassStatus,
83 instrument: &InstrumentAny,
84 tolerance: Option<Decimal>,
85 generate_synthetic_reports: bool,
86) -> anyhow::Result<ReconciliationResult> {
87 let instrument_id = instrument.id();
88 let account_id = mass_status.account_id;
89 let tol = tolerance.unwrap_or(DEFAULT_TOLERANCE);
90
91 let position_reports = mass_status.position_reports();
92
93 let venue_position = match position_reports.get(&instrument_id).and_then(|r| r.first()) {
94 Some(report) if report.avg_px_open.is_some() => position_report_to_snapshot(report),
95 _ => {
96 return Ok(extract_instrument_reports(mass_status, instrument_id));
98 }
99 };
100
101 let extracted = extract_fills_for_instrument(mass_status, instrument_id);
102 let fill_snapshots = extracted.snapshots;
103 let mut order_map = extracted.orders;
104 let mut fill_map = extracted.fills;
105 let mut order_only_ids = IndexSet::new();
106 let mut order_only_fill_keys = IndexSet::new();
107
108 if fill_snapshots.is_empty() {
109 return Ok(ReconciliationResult {
110 orders: order_map,
111 fills: fill_map,
112 order_only_ids,
113 order_only_fill_keys,
114 });
115 }
116
117 let result = adjust_fills_for_partial_window(&fill_snapshots, &venue_position, tol);
118
119 match result {
120 FillAdjustmentResult::NoAdjustment => {}
121
122 FillAdjustmentResult::AddSyntheticOpening {
123 synthetic_fill,
124 existing_fills: _,
125 } if generate_synthetic_reports => {
126 let venue_order_id = create_synthetic_venue_order_id(&synthetic_fill, instrument_id);
127
128 let order = create_synthetic_order_report(
129 &synthetic_fill,
130 account_id,
131 instrument_id,
132 instrument,
133 venue_order_id,
134 )?;
135 let fill = create_synthetic_fill_report(
136 &synthetic_fill,
137 account_id,
138 instrument_id,
139 instrument,
140 venue_order_id,
141 )?;
142
143 order_map.insert(venue_order_id, order);
144 fill_map.entry(venue_order_id).or_default().insert(0, fill);
145 }
146
147 FillAdjustmentResult::ReplaceCurrentLifecycle { synthetic_fill }
148 if generate_synthetic_reports =>
149 {
150 let venue_order_id = create_synthetic_venue_order_id(&synthetic_fill, instrument_id);
151
152 let order = create_synthetic_order_report(
153 &synthetic_fill,
154 account_id,
155 instrument_id,
156 instrument,
157 venue_order_id,
158 )?;
159 let fill = create_synthetic_fill_report(
160 &synthetic_fill,
161 account_id,
162 instrument_id,
163 instrument,
164 venue_order_id,
165 )?;
166
167 order_only_ids.extend(order_map.keys().copied());
170 fill_map.retain(|id, _| order_only_ids.contains(id));
171 order_map.insert(venue_order_id, order);
172 fill_map.insert(venue_order_id, vec![fill]);
173 }
174
175 FillAdjustmentResult::FilterToCurrentLifecycle {
176 last_zero_crossing_ts,
177 current_lifecycle_fills: _,
178 } => {
179 fill_map.retain(|_, fills| {
182 if !fills
183 .iter()
184 .any(|f| f.ts_event.as_u64() > last_zero_crossing_ts)
185 {
186 return false;
187 }
188 order_only_fill_keys.extend(
189 fills
190 .iter()
191 .filter(|f| f.ts_event.as_u64() <= last_zero_crossing_ts)
192 .map(|f| (f.account_id, f.instrument_id, f.trade_id)),
193 );
194 true
195 });
196
197 let orders_with_fills: ahash::AHashSet<VenueOrderId> =
199 fill_map.keys().copied().collect();
200 order_map.retain(|id, order| {
201 orders_with_fills.contains(id)
202 || !matches!(
203 order.order_status,
204 OrderStatus::Denied
205 | OrderStatus::Rejected
206 | OrderStatus::Canceled
207 | OrderStatus::Expired
208 | OrderStatus::Filled
209 )
210 });
211 }
212
213 FillAdjustmentResult::AddSyntheticOpening { .. }
214 | FillAdjustmentResult::ReplaceCurrentLifecycle { .. } => {}
215 }
216
217 Ok(ReconciliationResult {
218 orders: order_map,
219 fills: fill_map,
220 order_only_ids,
221 order_only_fill_keys,
222 })
223}
224
225#[must_use]
234pub(super) fn adjust_fills_for_partial_window(
235 fills: &[FillSnapshot],
236 venue_position: &VenuePositionSnapshot,
237 tolerance: Decimal,
238) -> FillAdjustmentResult {
239 if fills.is_empty() {
240 return FillAdjustmentResult::NoAdjustment;
241 }
242
243 if venue_position.qty == Decimal::ZERO {
244 return FillAdjustmentResult::NoAdjustment;
245 }
246
247 let zero_crossings = detect_zero_crossings(fills);
248 let venue_qty_signed = venue_position.signed_qty();
249
250 if !zero_crossings.is_empty() {
252 let mut last_flat_crossing_ts = None;
255 let mut running_qty = Decimal::ZERO;
256
257 for fill in fills {
258 let prev_qty = running_qty;
259 running_qty += Decimal::from(fill.direction()) * fill.qty;
260
261 if prev_qty != Decimal::ZERO && running_qty == Decimal::ZERO {
262 last_flat_crossing_ts = Some(fill.ts_event);
263 }
264 }
265
266 let lifecycle_boundary_ts =
267 last_flat_crossing_ts.unwrap_or(*zero_crossings.last().unwrap());
268
269 let current_lifecycle_fills: Vec<FillSnapshot> = fills
271 .iter()
272 .filter(|f| f.ts_event > lifecycle_boundary_ts)
273 .cloned()
274 .collect();
275
276 if current_lifecycle_fills.is_empty() {
277 return FillAdjustmentResult::NoAdjustment;
278 }
279
280 let (current_qty, current_value) = simulate_position(¤t_lifecycle_fills);
281
282 if check_position_match(current_qty, current_value, venue_position, tolerance) {
283 return FillAdjustmentResult::FilterToCurrentLifecycle {
285 last_zero_crossing_ts: lifecycle_boundary_ts,
286 current_lifecycle_fills,
287 };
288 }
289
290 if let Some(first_fill) = current_lifecycle_fills.first() {
292 let synthetic_fill = FillSnapshot::new(
293 first_fill.venue_order_id,
294 position_to_order_side(venue_position.side),
295 venue_position.qty,
296 venue_position.avg_px,
297 first_fill.ts_event.saturating_sub(1), );
299
300 return FillAdjustmentResult::ReplaceCurrentLifecycle { synthetic_fill };
301 }
302
303 return FillAdjustmentResult::NoAdjustment;
304 }
305
306 let oldest_lifecycle_fills: Vec<FillSnapshot> =
309 if let Some(&first_zero_crossing_ts) = zero_crossings.first() {
310 fills
312 .iter()
313 .filter(|f| f.ts_event <= first_zero_crossing_ts)
314 .cloned()
315 .collect()
316 } else {
317 fills.to_vec()
319 };
320
321 if oldest_lifecycle_fills.is_empty() {
322 return FillAdjustmentResult::NoAdjustment;
323 }
324
325 let (oldest_qty, oldest_value) = simulate_position(&oldest_lifecycle_fills);
326
327 if zero_crossings.is_empty() {
329 if check_position_match(oldest_qty, oldest_value, venue_position, tolerance) {
330 return FillAdjustmentResult::NoAdjustment;
331 }
332
333 if let Some(first_fill) = oldest_lifecycle_fills.first() {
334 let oldest_avg_px = if oldest_qty == Decimal::ZERO {
337 None
338 } else {
339 Some(oldest_value / oldest_qty.abs())
340 };
341
342 let reconciliation_price =
343 synthetic_opening_price(oldest_qty, oldest_avg_px, venue_position);
344
345 if let Some(opening_px) = reconciliation_price {
346 let opening_qty = if oldest_qty == Decimal::ZERO {
348 venue_qty_signed
349 } else {
350 venue_qty_signed - oldest_qty
352 };
353
354 if opening_qty.abs() > Decimal::ZERO {
355 let synthetic_side = if opening_qty > Decimal::ZERO {
356 OrderSide::Buy
357 } else {
358 OrderSide::Sell
359 };
360
361 let synthetic_fill = FillSnapshot::new(
362 first_fill.venue_order_id,
363 synthetic_side,
364 opening_qty.abs(),
365 opening_px,
366 first_fill.ts_event.saturating_sub(1),
367 );
368
369 return FillAdjustmentResult::AddSyntheticOpening {
370 synthetic_fill,
371 existing_fills: oldest_lifecycle_fills,
372 };
373 }
374 }
375 }
376
377 return FillAdjustmentResult::NoAdjustment;
378 }
379
380 if oldest_qty == Decimal::ZERO {
382 return FillAdjustmentResult::NoAdjustment;
384 }
385
386 if !oldest_lifecycle_fills.is_empty()
388 && let Some(&first_zero_crossing_ts) = zero_crossings.first()
389 {
390 let current_lifecycle_fills: Vec<FillSnapshot> = fills
392 .iter()
393 .filter(|f| f.ts_event > first_zero_crossing_ts)
394 .cloned()
395 .collect();
396
397 if !current_lifecycle_fills.is_empty()
398 && let Some(first_current_fill) = current_lifecycle_fills.first()
399 {
400 let synthetic_fill = FillSnapshot::new(
401 first_current_fill.venue_order_id,
402 position_to_order_side(venue_position.side),
403 venue_position.qty,
404 venue_position.avg_px,
405 first_current_fill.ts_event.saturating_sub(1),
406 );
407
408 return FillAdjustmentResult::AddSyntheticOpening {
409 synthetic_fill,
410 existing_fills: oldest_lifecycle_fills,
411 };
412 }
413 }
414
415 FillAdjustmentResult::NoAdjustment
416}
417
418fn synthetic_opening_price(
419 window_qty: Decimal,
420 window_avg_px: Option<Decimal>,
421 venue_position: &VenuePositionSnapshot,
422) -> Option<Decimal> {
423 match venue_position.avg_px_reconciliation {
424 AvgPxReconciliation::OpeningOnly => Some(venue_position.avg_px),
426 AvgPxReconciliation::Match => calculate_reconciliation_price(
427 window_qty,
428 window_avg_px,
429 venue_position.signed_qty(),
430 Some(venue_position.avg_px),
431 AvgPxReconciliation::Match,
432 venue_position.avg_px_precision,
433 ),
434 }
435}
436
437pub fn check_position_reconciliation(
448 report: &PositionStatusReport,
449 cached_signed_qty: Decimal,
450 size_precision: Option<u8>,
451) -> bool {
452 let venue_signed_qty = report.signed_decimal_qty;
453
454 if venue_signed_qty == Decimal::ZERO && cached_signed_qty == Decimal::ZERO {
455 return true;
456 }
457
458 if let Some(precision) = size_precision
459 && is_within_single_unit_tolerance(cached_signed_qty, venue_signed_qty, precision)
460 {
461 log::debug!(
462 "Position for {} within tolerance: cached={}, venue={}",
463 report.instrument_id,
464 cached_signed_qty,
465 venue_signed_qty
466 );
467 return true;
468 }
469
470 if cached_signed_qty == venue_signed_qty {
471 return true;
472 }
473
474 log::warn!(
475 "Position discrepancy for {}: cached={}, venue={}",
476 report.instrument_id,
477 cached_signed_qty,
478 venue_signed_qty
479 );
480
481 false
482}
483
484#[must_use]
490pub fn fill_precedes_snapshot_reconciled_position(cache: &Cache, report: &FillReport) -> bool {
491 cache
492 .positions_open(
493 None,
494 Some(&report.instrument_id),
495 None,
496 Some(&report.account_id),
497 None,
498 )
499 .into_iter()
500 .any(|position| {
501 report.ts_event < position.ts_opened
502 && !position.trade_ids.contains(&report.trade_id)
503 && cache
504 .order(&position.opening_order_id)
505 .is_some_and(|order| {
506 order.tags().is_some_and(|tags| {
507 tags.iter()
508 .any(|tag| tag.as_str() == RECONCILIATION_ORDER_TAG)
509 })
510 })
511 })
512}
513
514pub(super) fn cap_price_at_instrument_max(px: Decimal, instrument: &InstrumentAny) -> Decimal {
532 let Some(max) = instrument.max_price() else {
533 return px;
534 };
535 let cap = max.as_decimal().round_dp_with_strategy(
536 u32::from(instrument.price_precision()),
537 RoundingStrategy::ToZero,
538 );
539 px.min(cap)
540}
541
542pub(super) fn create_synthetic_order_report(
552 fill: &FillSnapshot,
553 account_id: AccountId,
554 instrument_id: InstrumentId,
555 instrument: &InstrumentAny,
556 venue_order_id: VenueOrderId,
557) -> anyhow::Result<OrderStatusReport> {
558 let order_qty = Quantity::from_decimal_dp(fill.qty, instrument.size_precision())?;
559
560 let mut report = OrderStatusReport::new(
561 account_id,
562 instrument_id,
563 None, venue_order_id,
565 fill.side.into(),
566 OrderType::Market,
567 TimeInForce::Gtc,
568 OrderStatus::Filled,
569 order_qty,
570 order_qty, UnixNanos::from(fill.ts_event),
572 UnixNanos::from(fill.ts_event),
573 UnixNanos::from(fill.ts_event),
574 None, );
576 report.avg_px = Some(cap_price_at_instrument_max(fill.px, instrument));
577 Ok(report)
578}
579
580pub(super) fn create_synthetic_fill_report(
586 fill: &FillSnapshot,
587 account_id: AccountId,
588 instrument_id: InstrumentId,
589 instrument: &InstrumentAny,
590 venue_order_id: VenueOrderId,
591) -> anyhow::Result<FillReport> {
592 let trade_id = create_synthetic_trade_id(fill);
593 let qty = Quantity::from_decimal_dp(fill.qty, instrument.size_precision())?;
594 let px = reconciliation_price(cap_price_at_instrument_max(fill.px, instrument), instrument)?;
595
596 Ok(FillReport::new(
597 account_id,
598 instrument_id,
599 venue_order_id,
600 trade_id,
601 fill.side,
602 qty,
603 px,
604 Money::zero(instrument.quote_currency()),
605 LiquiditySide::NoLiquiditySide,
606 None, None, fill.ts_event.into(),
609 fill.ts_event.into(),
610 None, ))
612}
613
614fn position_report_to_snapshot(report: &PositionStatusReport) -> VenuePositionSnapshot {
616 VenuePositionSnapshot {
617 side: report.position_side,
618 qty: report.quantity.into(),
619 avg_px: report.avg_px_open.unwrap_or(Decimal::ZERO),
620 avg_px_reconciliation: report.avg_px_open_reconciliation,
621 avg_px_precision: report.avg_px_open_precision,
622 }
623}
624
625fn position_to_order_side(side: PositionSide) -> OrderSide {
631 match side {
632 PositionSide::Long => OrderSide::Buy,
633 PositionSide::Short => OrderSide::Sell,
634 PositionSide::Flat => {
635 unreachable!("flat venue position must be guarded by an earlier check")
636 }
637 }
638}
639
640fn extract_instrument_reports(
642 mass_status: &ExecutionMassStatus,
643 instrument_id: InstrumentId,
644) -> ReconciliationResult {
645 let mut orders = IndexMap::new();
646 let mut fills = IndexMap::new();
647
648 for (id, order) in mass_status.order_reports() {
649 if order.instrument_id == instrument_id {
650 orders.insert(id, order.clone());
651 }
652 }
653
654 for (id, fill_list) in mass_status.fill_reports() {
655 let filtered: Vec<_> = fill_list
656 .iter()
657 .filter(|f| {
658 if f.instrument_id != instrument_id {
659 return false;
660 }
661
662 if f.last_qty.is_zero() {
663 log::warn!("Skipping zero-quantity fill report: {f}");
664 return false;
665 }
666
667 true
668 })
669 .cloned()
670 .collect();
671
672 if !filtered.is_empty() {
673 fills.insert(id, filtered);
674 }
675 }
676
677 ReconciliationResult {
678 orders,
679 fills,
680 order_only_ids: IndexSet::new(),
681 order_only_fill_keys: IndexSet::new(),
682 }
683}
684
685struct ExtractedFills {
687 snapshots: Vec<FillSnapshot>,
688 orders: IndexMap<VenueOrderId, OrderStatusReport>,
689 fills: IndexMap<VenueOrderId, Vec<FillReport>>,
690}
691
692fn extract_fills_for_instrument(
694 mass_status: &ExecutionMassStatus,
695 instrument_id: InstrumentId,
696) -> ExtractedFills {
697 let mut snapshots = Vec::new();
698 let mut order_map = IndexMap::new();
699 let mut fill_map = IndexMap::new();
700
701 for (id, order) in mass_status.order_reports() {
703 if order.instrument_id == instrument_id {
704 order_map.insert(id, order.clone());
705 }
706 }
707
708 for (venue_order_id, fill_reports) in mass_status.fill_reports() {
710 for fill in fill_reports {
711 if fill.instrument_id == instrument_id {
712 if fill.last_qty.is_zero() {
713 log::warn!("Skipping zero-quantity fill report: {fill}");
714 continue;
715 }
716
717 let side = mass_status
718 .order_reports()
719 .get(&venue_order_id)
720 .and_then(|order| order.order_side)
721 .unwrap_or(fill.order_side);
722
723 snapshots.push(FillSnapshot::new(
724 venue_order_id,
725 side,
726 fill.last_qty.into(),
727 fill.last_px.into(),
728 fill.ts_event.as_u64(),
729 ));
730
731 fill_map
732 .entry(venue_order_id)
733 .or_insert_with(Vec::new)
734 .push(fill.clone());
735 }
736 }
737 }
738
739 snapshots.sort_by_key(|f| f.ts_event);
741
742 ExtractedFills {
743 snapshots,
744 orders: order_map,
745 fills: fill_map,
746 }
747}
748
749#[must_use]
755pub(super) fn simulate_position(fills: &[FillSnapshot]) -> (Decimal, Decimal) {
756 let mut qty = Decimal::ZERO;
757 let mut value = Decimal::ZERO;
758
759 for fill in fills {
760 debug_assert!(
761 fill.qty > Decimal::ZERO,
762 "fill snapshot qty must be positive, received {}",
763 fill.qty,
764 );
765 let direction = Decimal::from(fill.direction());
766 let new_qty = qty + (direction * fill.qty);
767
768 if (qty >= Decimal::ZERO && direction > Decimal::ZERO)
770 || (qty <= Decimal::ZERO && direction < Decimal::ZERO)
771 {
772 value += fill.qty * fill.px;
774 qty = new_qty;
775 } else {
776 if qty.abs() >= fill.qty {
778 let close_ratio = fill.qty / qty.abs();
780 value *= Decimal::ONE - close_ratio;
781 qty = new_qty;
782 } else {
783 let remaining = fill.qty - qty.abs();
785 qty = direction * remaining;
786 value = remaining * fill.px;
787 }
788 }
789 }
790
791 debug_assert!(
792 value >= Decimal::ZERO,
793 "simulated position value must be non-negative, was {value}",
794 );
795 debug_assert!(
796 !(qty != Decimal::ZERO && value.is_sign_negative()),
797 "simulated avg price invariant: qty={qty}, value={value}",
798 );
799
800 (qty, value)
801}
802
803#[must_use]
812pub(super) fn detect_zero_crossings(fills: &[FillSnapshot]) -> Vec<u64> {
813 let mut running_qty = Decimal::ZERO;
814 let mut zero_crossings = Vec::new();
815
816 for fill in fills {
817 let prev_qty = running_qty;
818 running_qty += Decimal::from(fill.direction()) * fill.qty;
819
820 if prev_qty != Decimal::ZERO {
822 if running_qty == Decimal::ZERO {
823 zero_crossings.push(fill.ts_event);
825 } else if (prev_qty > Decimal::ZERO) != (running_qty > Decimal::ZERO) {
826 zero_crossings.push(fill.ts_event);
828 }
829 }
830 }
831
832 zero_crossings
833}
834
835#[must_use]
844pub(super) fn check_position_match(
845 simulated_qty: Decimal,
846 simulated_value: Decimal,
847 venue_position: &VenuePositionSnapshot,
848 tolerance: Decimal,
849) -> bool {
850 if simulated_qty != venue_position.signed_qty() {
851 return false;
852 }
853
854 if simulated_qty == Decimal::ZERO {
855 return true; }
857
858 if venue_position.avg_px_reconciliation == AvgPxReconciliation::OpeningOnly {
859 return true;
860 }
861
862 let abs_qty = simulated_qty.abs();
864 if abs_qty == Decimal::ZERO {
865 return false;
866 }
867
868 let simulated_avg_px = simulated_value / abs_qty;
869
870 position_prices_match(
871 simulated_avg_px,
872 venue_position.avg_px,
873 Some(tolerance),
874 venue_position.avg_px_precision,
875 )
876}
877
878#[must_use]
886pub fn position_prices_match(
887 cached_avg_px: Decimal,
888 venue_avg_px: Decimal,
889 tolerance: Option<Decimal>,
890 venue_precision: Option<u8>,
891) -> bool {
892 if venue_avg_px.is_zero() {
893 return false;
894 }
895
896 let tolerance = tolerance.unwrap_or(DEFAULT_TOLERANCE);
897 let diff = (cached_avg_px - venue_avg_px).abs();
898
899 if diff / venue_avg_px.abs() <= tolerance {
900 return true;
901 }
902
903 venue_precision.is_some_and(|precision| {
904 let unit = Decimal::try_new(1, u32::from(precision)).unwrap_or(Decimal::ZERO);
905 diff <= tolerance * venue_avg_px.abs() + unit
906 })
907}
908
909pub fn calculate_reconciliation_price(
935 current_position_qty: Decimal,
936 current_position_avg_px: Option<Decimal>,
937 target_position_qty: Decimal,
938 target_position_avg_px: Option<Decimal>,
939 target_avg_px_reconciliation: AvgPxReconciliation,
940 target_avg_px_precision: Option<u8>,
941) -> Option<Decimal> {
942 let qty_diff = target_position_qty - current_position_qty;
943
944 if qty_diff == Decimal::ZERO {
945 return None; }
947
948 let is_same_side = current_position_qty != Decimal::ZERO
949 && target_position_qty != Decimal::ZERO
950 && (current_position_qty > Decimal::ZERO) == (target_position_qty > Decimal::ZERO);
951
952 if is_same_side
953 && let Some(current_avg_px) = current_position_avg_px
954 && keeps_current_avg_px(
955 current_avg_px,
956 target_position_avg_px,
957 target_avg_px_reconciliation,
958 target_avg_px_precision,
959 )
960 {
961 return Some(current_avg_px);
962 }
963
964 if target_position_qty == Decimal::ZERO {
967 return current_position_avg_px;
968 }
969
970 let target_avg_px = target_position_avg_px?;
972 if target_avg_px == Decimal::ZERO {
973 return None;
974 }
975
976 if current_position_qty == Decimal::ZERO || current_position_avg_px.is_none() {
978 return Some(target_avg_px);
979 }
980
981 let current_avg_px = current_position_avg_px?;
982
983 let is_flip = (current_position_qty > Decimal::ZERO) != (target_position_qty > Decimal::ZERO)
986 && target_position_qty != Decimal::ZERO;
987
988 if is_flip {
989 return Some(target_avg_px);
990 }
991
992 if target_position_qty.abs() < current_position_qty.abs() {
993 return Some(target_avg_px);
994 }
995
996 let target_value = target_position_qty * target_avg_px;
999 let current_value = current_position_qty * current_avg_px;
1000 let diff_value = target_value - current_value;
1001
1002 let reconciliation_px = diff_value / qty_diff;
1004
1005 if reconciliation_px > Decimal::ZERO {
1007 return Some(reconciliation_px);
1008 }
1009
1010 None
1011}
1012
1013fn keeps_current_avg_px(
1014 current_avg_px: Decimal,
1015 target_avg_px: Option<Decimal>,
1016 target_avg_px_reconciliation: AvgPxReconciliation,
1017 target_avg_px_precision: Option<u8>,
1018) -> bool {
1019 match target_avg_px_reconciliation {
1020 AvgPxReconciliation::OpeningOnly => true,
1021 AvgPxReconciliation::Match => {
1022 target_avg_px_precision.is_some()
1023 && target_avg_px.is_some_and(|target_avg_px| {
1024 position_prices_match(
1025 current_avg_px,
1026 target_avg_px,
1027 None,
1028 target_avg_px_precision,
1029 )
1030 })
1031 }
1032 }
1033}
1034
1035#[must_use]
1040pub(super) fn is_within_single_unit_tolerance(
1041 value1: Decimal,
1042 value2: Decimal,
1043 precision: u8,
1044) -> bool {
1045 if precision == 0 {
1046 return value1 == value2;
1047 }
1048
1049 let tolerance = Decimal::new(1, u32::from(precision));
1050 let difference = (value1 - value2).abs();
1051 difference <= tolerance
1052}