1use std::str::FromStr;
19
20use ahash::{AHashMap, AHashSet};
21use anyhow::Context;
22use ibapi::orders::{Execution, Liquidity, OrderData, OrderStatus};
23use jiff::{
24 Timestamp,
25 civil::DateTime,
26 tz::{AmbiguousOffset, Offset},
27};
28use nautilus_core::{UnixNanos, datetime::get_timezone};
29use nautilus_model::{
30 enums::{
31 ContingencyType, LiquiditySide, OrderSide, OrderStatus as NautilusOrderStatus, OrderType,
32 TimeInForce, TrailingOffsetType,
33 },
34 identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
35 instruments::Instrument,
36 reports::{FillReport, OrderStatusReport, PositionStatusReport},
37 types::{Currency, Money, Price, Quantity},
38};
39use rust_decimal::Decimal;
40
41use crate::{
42 common::{
43 enums::{IbAction, IbOrderStatus, IbOrderType, IbTimeInForce},
44 spreads::is_spread_instrument_id,
45 },
46 execution::transform::{from_ib_price, ib_trigger_method_to_trigger_type},
47 providers::instruments::InteractiveBrokersInstrumentProvider,
48};
49
50pub(super) fn parse_order_data_to_report(
51 data: &OrderData,
52 instrument_id: InstrumentId,
53 account_id: AccountId,
54 instrument_provider: &InteractiveBrokersInstrumentProvider,
55 ts_init: UnixNanos,
56) -> anyhow::Result<OrderStatusReport> {
57 anyhow::ensure!(
58 data.order.total_quantity.is_finite()
59 && data.order.total_quantity >= 0.0
60 && data.order.filled_quantity.is_finite()
61 && data.order.filled_quantity >= 0.0,
62 "IB order {} has invalid quantities",
63 data.order_id
64 );
65
66 let mut report = parse_order_status_to_report(
67 &OrderStatus {
68 order_id: data.order_id,
69 status: data.order_state.status.clone(),
70 filled: data.order.filled_quantity,
71 remaining: (data.order.total_quantity - data.order.filled_quantity).max(0.0),
72 average_fill_price: None,
73 perm_id: data.order.perm_id,
74 parent_id: 0,
75 last_fill_price: None,
76 client_id: data.order.client_id,
77 why_held: String::new(),
78 market_cap_price: None,
79 },
80 Some(&data.order),
81 instrument_id,
82 account_id,
83 instrument_provider,
84 ts_init,
85 )?;
86
87 if !data.order_state.completed_time.is_empty() {
89 match parse_execution_time(&data.order_state.completed_time) {
90 Ok(completed) => report.ts_last = completed,
91 Err(e) => tracing::debug!(
92 "Cannot parse completed time of IB order {}: {e}",
93 data.order_id
94 ),
95 }
96 }
97
98 Ok(report)
99}
100
101pub(crate) fn fill_missing_avg_px(
112 order_reports: &mut [OrderStatusReport],
113 fill_reports: &[FillReport],
114) {
115 for report in order_reports
116 .iter_mut()
117 .filter(|report| report.avg_px.is_none() && !report.filled_qty.is_zero())
118 {
119 let (notional, quantity) = fill_reports
120 .iter()
121 .filter(|fill| fill.venue_order_id == report.venue_order_id)
122 .fold(
123 (Decimal::ZERO, Decimal::ZERO),
124 |(notional, quantity), fill| {
125 (
126 notional + fill.last_px.as_decimal() * fill.last_qty.as_decimal(),
127 quantity + fill.last_qty.as_decimal(),
128 )
129 },
130 );
131
132 if !quantity.is_zero() {
133 report.avg_px = Some(notional / quantity);
134 }
135 }
136}
137
138#[derive(Debug, Default)]
140pub(crate) struct FillEvidence {
141 pub(crate) reports: Vec<FillReport>,
142 pub(crate) combo_leg_reports: Vec<FillReport>,
145 pub(crate) missing_all: bool,
147 pub(crate) missing_instruments: AHashSet<InstrumentId>,
149}
150
151impl FillEvidence {
152 fn is_complete_for(&self, instrument_id: &InstrumentId) -> bool {
153 !self.missing_all && !self.missing_instruments.contains(instrument_id)
154 }
155}
156
157pub(crate) fn fill_position_avg_px_from_fills(
174 position_reports: &mut [PositionStatusReport],
175 evidence: &FillEvidence,
176 instrument_provider: &InteractiveBrokersInstrumentProvider,
177) {
178 for report in position_reports
179 .iter_mut()
180 .filter(|report| !report.signed_decimal_qty.is_zero())
181 {
182 let instrument_id = report.instrument_id;
183 let Some(venue_avg_px) = report.avg_px_open else {
184 continue;
185 };
186 let Some(instrument) = instrument_provider.find(&instrument_id) else {
187 continue;
188 };
189
190 if !evidence.is_complete_for(&instrument_id) {
191 continue;
192 }
193
194 let mut fills: Vec<_> = evidence
195 .reports
196 .iter()
197 .chain(&evidence.combo_leg_reports)
198 .filter(|fill| {
199 fill.account_id == report.account_id && fill.instrument_id == instrument_id
200 })
201 .collect();
202
203 if fills
205 .iter()
206 .any(|fill| fill.commission.currency != instrument.quote_currency())
207 {
208 continue;
209 }
210
211 fills.sort_by_key(|fill| fill.ts_event);
212
213 let window_qty: Decimal = fills.iter().map(|fill| signed_fill_qty(fill)).sum();
214 let pre_window_qty = report.signed_decimal_qty - window_qty;
215
216 let Some(lot) = entry_after_window(pre_window_qty, &fills) else {
217 continue;
218 };
219
220 let units = report.signed_decimal_qty.abs() * instrument.multiplier().as_decimal();
221 let commission_px = lot.commission / units;
222 let venue_avg_px_expected = if report.signed_decimal_qty.is_sign_positive() {
223 lot.avg_px + commission_px
224 } else {
225 lot.avg_px - commission_px
226 };
227
228 let tick = Decimal::new(1, u32::from(instrument.price_precision()));
232 let half_increment = Decimal::new(5, u32::from(instrument.quote_currency().precision) + 1);
233 let tolerance = tick + half_increment * lot.commissions / units;
234
235 if (venue_avg_px - venue_avg_px_expected).abs() <= tolerance {
236 report.avg_px_open = Some(lot.avg_px);
237 } else {
238 tracing::debug!(
239 "Keeping IB average cost {venue_avg_px} for {instrument_id}: fills give entry \
240 {} with average cost {venue_avg_px_expected}",
241 lot.avg_px,
242 );
243 }
244 }
245}
246
247fn signed_fill_qty(fill: &FillReport) -> Decimal {
248 let last_qty = fill.last_qty.as_decimal();
249
250 match fill.order_side {
251 OrderSide::Buy => last_qty,
252 OrderSide::Sell => -last_qty,
253 }
254}
255
256struct EntryLot {
260 avg_px: Decimal,
261 commission: Decimal,
262 commissions: Decimal,
263}
264
265fn entry_after_window(pre_window_qty: Decimal, fills: &[&FillReport]) -> Option<EntryLot> {
273 let mut quantity = pre_window_qty;
274 let mut lot: Option<EntryLot> = None;
275
276 for group in fills.chunk_by(|a, b| a.ts_event == b.ts_event) {
277 if group.len() > 1 {
278 let group_qty: Decimal = group.iter().map(|fill| signed_fill_qty(fill)).sum();
279 let end_qty = quantity + group_qty;
280 let mixes_sides = group
281 .iter()
282 .any(|fill| fill.order_side != group[0].order_side);
283 let flips = !quantity.is_zero()
284 && !end_qty.is_zero()
285 && quantity.is_sign_negative() != end_qty.is_sign_negative();
286
287 if mixes_sides || flips {
288 quantity = end_qty;
289 lot = None;
290 continue;
291 }
292 }
293
294 for fill in group {
295 let last_qty = fill.last_qty.as_decimal();
296 let last_px = fill.last_px.as_decimal();
297 let commission = fill.commission.as_decimal();
298 let signed_qty = signed_fill_qty(fill);
299 let open_qty = quantity.abs();
300
301 if quantity.is_zero() {
302 lot = Some(EntryLot {
303 avg_px: last_px,
304 commission,
305 commissions: Decimal::ONE,
306 });
307 } else if quantity.is_sign_negative() == signed_qty.is_sign_negative() {
308 lot = lot.map(|lot| EntryLot {
309 avg_px: (lot.avg_px * open_qty + last_px * last_qty) / (open_qty + last_qty),
310 commission: lot.commission + commission,
311 commissions: lot.commissions + Decimal::ONE,
312 });
313 } else if last_qty > open_qty {
314 lot = Some(EntryLot {
315 avg_px: last_px,
316 commission: Decimal::ZERO,
317 commissions: Decimal::ZERO,
318 });
319 } else if last_qty < open_qty {
320 let retained = (open_qty - last_qty) / open_qty;
321 lot = lot.map(|lot| EntryLot {
322 avg_px: lot.avg_px,
323 commission: lot.commission * retained,
324 commissions: lot.commissions * retained,
325 });
326 }
327
328 quantity += signed_qty;
329 }
330 }
331
332 if quantity.is_zero() {
333 return None;
334 }
335
336 lot
337}
338
339pub(crate) fn is_combo_execution(exec_data: &ibapi::orders::ExecutionData) -> bool {
344 matches!(
345 exec_data.contract.security_type,
346 ibapi::contracts::SecurityType::Spread
347 )
348}
349
350pub(crate) fn link_order_contingencies(
351 reports: &mut [OrderStatusReport],
352 orders: &[ibapi::orders::Order],
353) {
354 debug_assert_eq!(reports.len(), orders.len());
355
356 let order_ids: Vec<ClientOrderId> = reports
357 .iter()
358 .map(|report| {
359 report
360 .client_order_id
361 .unwrap_or_else(|| ClientOrderId::from(report.venue_order_id.as_str()))
362 })
363 .collect();
364
365 let mut oca_groups: AHashMap<(&str, &str), Vec<usize>> = AHashMap::new();
366
367 for (index, order) in orders.iter().enumerate() {
368 if !order.oca_group.is_empty() {
369 oca_groups
370 .entry((order.account.as_str(), order.oca_group.as_str()))
371 .or_default()
372 .push(index);
373 }
374 }
375
376 let mut children: AHashMap<usize, Vec<usize>> = AHashMap::new();
377
378 for (index, order) in orders.iter().enumerate() {
379 let parent = orders.iter().position(|candidate| {
380 order
381 .parent_perm_id
382 .is_some_and(|perm_id| perm_id != 0 && candidate.perm_id == perm_id)
383 || (order.parent_id != 0
384 && candidate.order_id == order.parent_id
385 && candidate.client_id == order.client_id)
386 });
387
388 if let Some(parent) = parent.filter(|parent| *parent != index) {
389 reports[index].parent_order_id = Some(order_ids[parent]);
390 children.entry(parent).or_default().push(index);
391 }
392 }
393
394 for (index, order) in orders.iter().enumerate() {
395 if let Some(members) = oca_groups.get(&(order.account.as_str(), order.oca_group.as_str())) {
396 let contingency_type = match order.oca_type {
397 ibapi::orders::OcaType::CancelWithBlock => Some(ContingencyType::Oco),
398 ibapi::orders::OcaType::ReduceWithBlock
399 | ibapi::orders::OcaType::ReduceWithoutBlock => Some(ContingencyType::Ouo),
400 _ => None,
401 };
402
403 let linked: Vec<ClientOrderId> = members
404 .iter()
405 .filter(|member| **member != index)
406 .map(|member| order_ids[*member])
407 .collect();
408
409 if let Some(contingency_type) = contingency_type
410 && !linked.is_empty()
411 {
412 reports[index].contingency_type = Some(contingency_type);
413 reports[index].linked_order_ids = Some(linked);
414 continue;
415 }
416 }
417
418 if let Some(child_indices) = children.get(&index) {
419 reports[index].contingency_type = Some(ContingencyType::Oto);
420 reports[index].linked_order_ids = Some(
421 child_indices
422 .iter()
423 .map(|child| order_ids[*child])
424 .collect(),
425 );
426 }
427 }
428}
429
430pub(crate) fn should_use_avg_fill_price(avg_fill_price: f64, instrument_id: &InstrumentId) -> bool {
431 avg_fill_price.is_finite()
432 && avg_fill_price != f64::MAX
433 && avg_fill_price != 0.0
434 && (avg_fill_price > 0.0 || is_spread_instrument_id(instrument_id))
435}
436
437pub(crate) fn ib_venue_order_id(order_id: i32, perm_id: i64) -> VenueOrderId {
438 if perm_id != 0 {
439 VenueOrderId::new(format!("PERM-{perm_id}"))
440 } else {
441 VenueOrderId::new(order_id.to_string())
442 }
443}
444
445pub(crate) fn normalized_order_ref(order_ref: &str) -> Option<&str> {
446 if order_ref.is_empty() {
447 return None;
448 }
449
450 Some(
451 order_ref
452 .rsplit_once(':')
453 .map_or(order_ref, |(base, _)| base),
454 )
455}
456
457#[allow(clippy::too_many_arguments)]
468pub fn parse_execution_to_fill_report(
469 execution: &Execution,
470 _contract: &ibapi::contracts::Contract,
471 commission: f64,
472 commission_currency: &str,
473 instrument_id: InstrumentId,
474 account_id: AccountId,
475 instrument_provider: &InteractiveBrokersInstrumentProvider,
476 ts_init: UnixNanos,
477 avg_px: Option<Price>,
478) -> anyhow::Result<FillReport> {
479 let price_magnifier = instrument_provider.get_price_magnifier(&instrument_id);
481
482 let execution_price = from_ib_price(execution.price, price_magnifier);
484
485 let order_side = IbAction::from_str(execution.side.as_str())?.order_side();
487
488 let instrument = instrument_provider
490 .find(&instrument_id)
491 .context("Instrument not found")?;
492
493 let last_qty = Quantity::new(execution.shares, instrument.size_precision());
495 let last_px = Price::new(execution_price, instrument.price_precision());
496
497 let commission_clamped = if commission == -1.0 { 0.0 } else { commission };
499 let commission_money = Money::new(commission_clamped, Currency::from_str(commission_currency)?);
500
501 let ts_event = parse_execution_time(&execution.time)?;
503
504 let trade_id = TradeId::new(&execution.execution_id);
506
507 let venue_order_id = ib_venue_order_id(execution.order_id, execution.perm_id);
508
509 let client_order_id = normalized_order_ref(&execution.order_reference).map(ClientOrderId::new);
510 let liquidity_side = execution_liquidity_side(&execution.last_liquidity);
511
512 let mut report = FillReport::new(
513 account_id,
514 instrument_id,
515 venue_order_id,
516 trade_id,
517 order_side,
518 last_qty,
519 last_px,
520 commission_money,
521 liquidity_side,
522 client_order_id,
523 None, ts_event,
525 ts_init,
526 Some(nautilus_core::UUID4::new()),
527 );
528 report.avg_px = avg_px.map(|price: Price| price.as_decimal());
529
530 Ok(report)
531}
532
533fn execution_liquidity_side(liquidity: &Liquidity) -> LiquiditySide {
534 match liquidity {
535 Liquidity::AddedLiquidity => LiquiditySide::Maker,
536 Liquidity::RemovedLiquidity => LiquiditySide::Taker,
537 Liquidity::None => LiquiditySide::NoLiquiditySide,
538 Liquidity::LiquidityRoutedOut => {
539 tracing::warn!(
540 "IB execution liquidity was routed out and has no maker/taker representation"
541 );
542 LiquiditySide::NoLiquiditySide
543 }
544 Liquidity::Unknown(code) => {
545 tracing::warn!("IB execution used unknown liquidity code {code}");
546 LiquiditySide::NoLiquiditySide
547 }
548 }
549}
550
551pub fn parse_order_status_to_report(
557 order_status: &OrderStatus,
558 order: Option<&ibapi::orders::Order>,
559 instrument_id: InstrumentId,
560 account_id: AccountId,
561 instrument_provider: &InteractiveBrokersInstrumentProvider,
562 ts_init: UnixNanos,
563) -> anyhow::Result<OrderStatusReport> {
564 let price_magnifier = instrument_provider.get_price_magnifier(&instrument_id);
566
567 let mut nautilus_status = match IbOrderStatus::from_str(order_status.status.as_str()) {
568 Ok(status) => status.nautilus_status(),
569 _ => {
570 tracing::warn!(
571 "Unknown order status: {}, defaulting to SUBMITTED",
572 order_status.status.as_str()
573 );
574 NautilusOrderStatus::Submitted
575 }
576 };
577
578 let order_side = if let Some(order) = order {
580 IbAction::from(order.action).order_side()
581 } else {
582 OrderSide::Buy
584 };
585
586 let instrument = instrument_provider.find(&instrument_id);
587
588 let size_precision = instrument
590 .as_ref()
591 .map_or(0, |instr| instr.size_precision());
592 let price_precision = instrument
593 .as_ref()
594 .map_or(0, |instr| instr.price_precision());
595
596 let total_quantity = order.map_or(0.0, |order| order.total_quantity);
598
599 let quantity = Quantity::new(
600 total_quantity.max(order_status.filled + order_status.remaining),
601 size_precision,
602 );
603
604 let filled_qty = Quantity::new(order_status.filled, size_precision);
606
607 let average_fill_price = order_status.average_fill_price.unwrap_or(0.0);
609 let include_avg_px = should_use_avg_fill_price(average_fill_price, &instrument_id);
610 let avg_px = if include_avg_px {
611 let avg_px = decimal_from_f64(average_fill_price)?
612 .checked_div(Decimal::from(price_magnifier.max(1)))
613 .context("IB average fill price out of range")?;
614 Some(avg_px)
615 } else {
616 None
617 };
618
619 if order_status.filled > 0.0
620 && (order_status.remaining > 0.0
621 || order.is_some_and(|order| order.total_quantity > order_status.filled))
622 {
623 nautilus_status = NautilusOrderStatus::PartiallyFilled;
624 }
625
626 let venue_order_id = ib_venue_order_id(order_status.order_id, order_status.perm_id);
627
628 let client_order_id = order
629 .and_then(|order| normalized_order_ref(&order.order_ref))
630 .map(ClientOrderId::new);
631
632 let order_type = order.map_or(OrderType::Market, |order| {
634 map_ib_order_type(&order.order_type, order.limit_price)
635 });
636
637 let expire_time = order
640 .filter(|order| !order.good_till_date.is_empty())
641 .and_then(|order| match parse_execution_time(&order.good_till_date) {
642 Ok(expire_time) => Some(expire_time),
643 Err(e) => {
644 tracing::warn!(
645 "Reporting IB order {} as GTC: cannot parse goodTillDate: {e}",
646 order_status.order_id
647 );
648 None
649 }
650 });
651
652 let time_in_force = if let Some(order) = order {
654 let ib_time_in_force = IbTimeInForce::from(order.tif.clone());
655
656 if expire_time.is_some() {
657 TimeInForce::Gtd
658 } else if ib_time_in_force == IbTimeInForce::GoodTilDate {
659 TimeInForce::Gtc
660 } else {
661 ib_time_in_force.nautilus_time_in_force()
662 }
663 } else {
664 TimeInForce::Day };
666
667 let mut report = OrderStatusReport::new(
669 account_id,
670 instrument_id,
671 client_order_id,
672 venue_order_id,
673 order_side.into(),
674 order_type,
675 time_in_force,
676 nautilus_status,
677 quantity,
678 filled_qty,
679 ts_init, ts_init, ts_init,
682 Some(nautilus_core::UUID4::new()), );
684
685 if let Some(expire_time) = expire_time {
686 report = report.with_expire_time(expire_time);
687 }
688
689 if let Some(order) = order {
691 if let Some(limit_price) = order.limit_price
694 && matches!(
695 order_type,
696 OrderType::Limit
697 | OrderType::StopLimit
698 | OrderType::LimitIfTouched
699 | OrderType::TrailingStopLimit
700 | OrderType::MarketToLimit
701 )
702 {
703 let converted = from_ib_price(limit_price, price_magnifier);
704 report = report.with_price(Price::new(converted, price_precision));
705 }
706
707 let (trigger_price, limit_offset, trailing_offset, trailing_offset_type) =
708 parse_ib_order_pricing_fields(order, order_type, price_magnifier, price_precision)?;
709
710 if let Some(trigger_price) = trigger_price {
711 report = report.with_trigger_price(trigger_price);
712 }
713
714 if matches!(
715 order_type,
716 OrderType::LimitIfTouched
717 | OrderType::MarketIfTouched
718 | OrderType::StopLimit
719 | OrderType::StopMarket
720 | OrderType::TrailingStopLimit
721 | OrderType::TrailingStopMarket
722 ) {
723 report =
724 report.with_trigger_type(ib_trigger_method_to_trigger_type(order.trigger_method));
725 }
726
727 if let Some(limit_offset) = limit_offset {
728 report = report.with_limit_offset(limit_offset);
729 }
730
731 if let Some(trailing_offset) = trailing_offset {
732 report = report.with_trailing_offset(trailing_offset);
733 }
734
735 if let Some(trailing_offset_type) = trailing_offset_type {
736 report = report.with_trailing_offset_type(trailing_offset_type);
737 }
738 }
739
740 if let Some(avg_px) = avg_px {
741 report = report.with_avg_px(avg_px);
742 }
743
744 if let Some(display_size) = order
745 .and_then(|order| order.display_size)
746 .filter(|size| *size > 0 && f64::from(*size) < quantity.as_f64())
747 {
748 report = report.with_display_qty(Quantity::new(f64::from(display_size), size_precision));
749 }
750
751 Ok(report)
752}
753
754fn map_ib_order_type(order_type: &str, limit_price: Option<f64>) -> OrderType {
755 if order_type == "IBALGO" && limit_price.is_some_and(|price| price != 0.0) {
756 OrderType::Limit
757 } else {
758 IbOrderType::from_str(order_type)
759 .map_or(OrderType::Market, IbOrderType::nautilus_order_type)
760 }
761}
762
763#[allow(clippy::type_complexity)] fn parse_ib_order_pricing_fields(
765 order: &ibapi::orders::Order,
766 order_type: OrderType,
767 price_magnifier: i32,
768 price_precision: u8,
769) -> anyhow::Result<(
770 Option<Price>,
771 Option<Decimal>,
772 Option<Decimal>,
773 Option<TrailingOffsetType>,
774)> {
775 let mut trigger_price = None;
776 let mut limit_offset = None;
777 let mut trailing_offset = None;
778 let mut trailing_offset_type = None;
779
780 if matches!(
781 order_type,
782 OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
783 ) {
784 if let Some(trail_stop_price) = order.trail_stop_price {
785 trigger_price = Some(Price::new(
786 from_ib_price(trail_stop_price, price_magnifier),
787 price_precision,
788 ));
789 }
790
791 if let Some(aux_price) = order.aux_price {
792 trailing_offset = Some(decimal_from_f64(aux_price)? / Decimal::from(price_magnifier));
793 trailing_offset_type = Some(TrailingOffsetType::Price);
794 } else if let Some(trailing_percent) = order.trailing_percent {
795 trailing_offset = Some(decimal_from_f64(trailing_percent)? * Decimal::from(100));
796 trailing_offset_type = Some(TrailingOffsetType::BasisPoints);
797 }
798
799 if order_type == OrderType::TrailingStopLimit
800 && let Some(limit_price_offset) = order.limit_price_offset
801 {
802 limit_offset =
803 Some(decimal_from_f64(limit_price_offset)? / Decimal::from(price_magnifier));
804 trailing_offset_type = Some(trailing_offset_type.unwrap_or(TrailingOffsetType::Price));
805 }
806
807 return Ok((
808 trigger_price,
809 limit_offset,
810 trailing_offset,
811 trailing_offset_type,
812 ));
813 }
814
815 if let Some(aux_price) = order.aux_price {
816 trigger_price = Some(Price::new(
817 from_ib_price(aux_price, price_magnifier),
818 price_precision,
819 ));
820 }
821
822 Ok((
823 trigger_price,
824 limit_offset,
825 trailing_offset,
826 trailing_offset_type,
827 ))
828}
829
830fn decimal_from_f64(value: f64) -> anyhow::Result<Decimal> {
831 Decimal::from_str(&value.to_string())
832 .with_context(|| format!("Failed to convert IB floating-point value {value} to Decimal"))
833}
834
835pub fn parse_execution_time(time_str: &str) -> anyhow::Result<UnixNanos> {
859 const NAIVE_FORMAT: &str = "%Y%m%d %H:%M:%S";
860
861 if !time_str.contains(' ') {
863 let normalized = time_str.replace('-', " ");
864
865 let dt = DateTime::strptime(NAIVE_FORMAT, &normalized).map_err(|e| {
866 anyhow::anyhow!("Failed to parse execution timestamp '{time_str}': {e}")
867 })?;
868
869 return datetime_to_unix_nanos(Offset::UTC.to_timestamp(dt)?, time_str);
870 }
871
872 let mut parts = time_str.splitn(3, ' ');
876
877 let (Some(date), Some(time)) = (parts.next(), parts.next()) else {
878 anyhow::bail!("Invalid execution time format: {time_str}");
879 };
880
881 let tz_str = parts.next().unwrap_or("").trim();
882
883 let naive_str = format!("{date} {time}");
884 let dt = DateTime::strptime(NAIVE_FORMAT, &naive_str)
885 .map_err(|e| anyhow::anyhow!("Failed to parse execution timestamp '{time_str}': {e}"))?;
886
887 let utc = if tz_str.is_empty() {
888 Offset::UTC.to_timestamp(dt)?
889 } else {
890 localize_with_zone(dt, tz_str, time_str)?
891 };
892
893 datetime_to_unix_nanos(utc, time_str)
894}
895
896fn localize_with_zone(dt: DateTime, tz_str: &str, time_str: &str) -> anyhow::Result<Timestamp> {
901 let tz_name = if tz_str.eq_ignore_ascii_case("Z") {
902 "UTC"
903 } else {
904 tz_str
905 };
906
907 let zone = get_timezone(tz_name).map_err(|_| {
908 anyhow::anyhow!(
909 "Unrecognized execution timezone '{tz_str}' in '{time_str}'. Configure TWS / IB Gateway to emit a standard timezone (e.g. UTC)"
910 )
911 })?;
912
913 let ambiguous = zone.to_ambiguous_timestamp(dt);
914 match ambiguous.offset() {
915 AmbiguousOffset::Unambiguous { .. } => Ok(ambiguous.unambiguous()?),
916 AmbiguousOffset::Fold { .. } => Ok(ambiguous.earlier()?),
918 AmbiguousOffset::Gap { .. } => {
919 anyhow::bail!("Execution timestamp '{time_str}' is non-existent in timezone '{tz_str}'")
920 }
921 }
922}
923
924fn datetime_to_unix_nanos(dt: Timestamp, time_str: &str) -> anyhow::Result<UnixNanos> {
925 let nanos: u64 = dt
926 .as_nanosecond()
927 .try_into()
928 .map_err(|_| anyhow::anyhow!("Execution timestamp '{time_str}' was before Unix epoch"))?;
929 Ok(UnixNanos::new(nanos))
930}
931
932#[cfg(test)]
933mod tests {
934 use ibapi::{
935 contracts::Contract,
936 orders::{Action, ExecutionSide, Liquidity, Order, OrderStatusKind},
937 };
938 use nautilus_model::{
939 enums::{AssetClass, PositionSide, TrailingOffsetType, TriggerType},
940 identifiers::{Symbol, Venue},
941 instruments::{
942 CurrencyPair, FuturesContract, InstrumentAny,
943 stubs::{equity_aapl, equity_aapl_itch},
944 },
945 };
946 use rust_decimal::Decimal;
947 use ustr::Ustr;
948
949 use super::*;
950 use crate::{
951 config::InteractiveBrokersInstrumentProviderConfig, execution::reports::IbReportContext,
952 providers::instruments::InteractiveBrokersInstrumentProvider,
953 };
954
955 fn create_test_instrument_provider() -> InteractiveBrokersInstrumentProvider {
956 let config = InteractiveBrokersInstrumentProviderConfig::default();
957 InteractiveBrokersInstrumentProvider::new(config).unwrap()
958 }
959
960 fn create_test_instrument_id() -> InstrumentId {
961 InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"))
962 }
963
964 use rstest::rstest;
965
966 fn create_test_avg_px_provider() -> InteractiveBrokersInstrumentProvider {
967 let provider = InteractiveBrokersInstrumentProvider::new(
968 crate::config::InteractiveBrokersInstrumentProviderConfig::default(),
969 )
970 .unwrap();
971 provider.insert_test_instrument(InstrumentAny::Equity(equity_aapl()), 265_598, 1);
972 provider
973 }
974
975 fn create_test_entry_fill(
978 side: OrderSide,
979 qty: &str,
980 px: &str,
981 trade_id: &str,
982 ts_event: u64,
983 ) -> FillReport {
984 FillReport::new(
985 AccountId::from("IB-DU001"),
986 equity_aapl().id,
987 VenueOrderId::new(format!("PERM-{trade_id}")),
988 TradeId::new(trade_id),
989 side,
990 Quantity::from(qty),
991 Price::from(px),
992 Money::from("1.00 USD"),
993 LiquiditySide::Taker,
994 None,
995 None,
996 UnixNanos::from(ts_event),
997 UnixNanos::default(),
998 None,
999 )
1000 }
1001
1002 fn create_test_entry_position_report(
1003 side: PositionSide,
1004 qty: &str,
1005 venue_avg_px: &str,
1006 ) -> PositionStatusReport {
1007 PositionStatusReport::new(
1008 AccountId::from("IB-DU001"),
1009 equity_aapl().id,
1010 side,
1011 Quantity::from(qty),
1012 UnixNanos::default(),
1013 UnixNanos::default(),
1014 None,
1015 None,
1016 Some(Decimal::from_str(venue_avg_px).unwrap()),
1017 )
1018 }
1019
1020 fn rebuild_avg_px_open(
1021 position_report: PositionStatusReport,
1022 evidence: &FillEvidence,
1023 ) -> Option<Decimal> {
1024 let mut position_reports = vec![position_report];
1025 fill_position_avg_px_from_fills(
1026 &mut position_reports,
1027 evidence,
1028 &create_test_avg_px_provider(),
1029 );
1030 position_reports[0].avg_px_open
1031 }
1032
1033 #[rstest]
1034 #[case::fills_open_position(
1037 PositionSide::Long,
1038 vec![(OrderSide::Buy, "7", "229.67")],
1039 "229.81",
1040 "229.67"
1041 )]
1042 #[case::fills_average_in(
1043 PositionSide::Long,
1044 vec![(OrderSide::Buy, "5", "229.00"), (OrderSide::Buy, "2", "232.50")],
1045 "230.29",
1046 "230.00"
1047 )]
1048 #[case::reduce_keeps_entry(
1049 PositionSide::Long,
1050 vec![(OrderSide::Buy, "4", "229.00"), (OrderSide::Buy, "4", "231.00"), (OrderSide::Sell, "1", "235.00")],
1051 "230.25",
1052 "230.00"
1053 )]
1054 #[case::flip_carries_no_commission(
1055 PositionSide::Short,
1056 vec![(OrderSide::Buy, "2", "230.00"), (OrderSide::Sell, "8", "228.00"), (OrderSide::Sell, "1", "221.00")],
1057 "226.86",
1058 "227.00"
1059 )]
1060 #[case::fills_partial_history(
1061 PositionSide::Long,
1062 vec![(OrderSide::Buy, "2", "230.00")],
1063 "229.81",
1064 "229.81"
1065 )]
1066 #[case::remnant_closed_then_reopened(
1067 PositionSide::Long,
1068 vec![(OrderSide::Sell, "7", "229.00"), (OrderSide::Buy, "7", "231.00")],
1069 "231.14",
1070 "231.00"
1071 )]
1072 #[case::remnant_flipped_in_window(
1073 PositionSide::Short,
1074 vec![(OrderSide::Sell, "7", "228.00"), (OrderSide::Sell, "1", "221.00")],
1075 "226.86",
1076 "227.00"
1077 )]
1078 #[case::remnant_reduced_in_window(
1079 PositionSide::Long,
1080 vec![(OrderSide::Sell, "2", "235.00")],
1081 "229.81",
1082 "229.81"
1083 )]
1084 #[case::no_fills(PositionSide::Long, vec![], "229.81", "229.81")]
1085 #[case::average_cost_disagrees(
1088 PositionSide::Long,
1089 vec![(OrderSide::Buy, "7", "229.67")],
1090 "230.50",
1091 "230.50"
1092 )]
1093 fn test_fill_position_avg_px_from_fills(
1094 #[case] position_side: PositionSide,
1095 #[case] fills: Vec<(OrderSide, &str, &str)>,
1096 #[case] venue_avg_px: &str,
1097 #[case] expected: &str,
1098 ) {
1099 let other_fill = FillReport::new(
1100 AccountId::from("IB-DU001"),
1101 InstrumentId::from("MSFT.NASDAQ"),
1102 VenueOrderId::new("PERM-9"),
1103 TradeId::new("T-OTHER"),
1104 OrderSide::Sell,
1105 Quantity::from(7),
1106 Price::from("150.00"),
1107 Money::from("1.00 USD"),
1108 LiquiditySide::Taker,
1109 None,
1110 None,
1111 UnixNanos::from(1),
1112 UnixNanos::default(),
1113 None,
1114 );
1115 let mut reports: Vec<_> = fills
1116 .into_iter()
1117 .enumerate()
1118 .rev()
1119 .map(|(index, (side, qty, px))| {
1120 create_test_entry_fill(side, qty, px, &format!("T-{index}"), index as u64 + 10)
1121 })
1122 .collect();
1123 reports.push(other_fill);
1124 let evidence = FillEvidence {
1125 reports,
1126 ..Default::default()
1127 };
1128
1129 let avg_px_open = rebuild_avg_px_open(
1130 create_test_entry_position_report(position_side, "7", venue_avg_px),
1131 &evidence,
1132 );
1133
1134 assert_eq!(avg_px_open, Some(Decimal::from_str(expected).unwrap()));
1135 }
1136
1137 #[rstest]
1139 fn test_fill_position_avg_px_from_fills_for_magnified_contract() {
1140 let instrument = InstrumentAny::from(
1141 FuturesContract::builder()
1142 .instrument_id(InstrumentId::from("ZCZ6.CBOT"))
1143 .raw_symbol(Symbol::from("ZCZ6"))
1144 .asset_class(AssetClass::Commodity)
1145 .exchange(Ustr::from("CBOT"))
1146 .underlying(Ustr::from("ZC"))
1147 .activation_ns(UnixNanos::default())
1148 .expiration_ns(UnixNanos::from(1_800_000_000_000_000_000_u64))
1149 .currency(Currency::USD())
1150 .price_precision(4)
1151 .price_increment(Price::from("0.0025"))
1152 .multiplier(Quantity::from(5000))
1153 .lot_size(Quantity::from(1))
1154 .ts_event(UnixNanos::default())
1155 .ts_init(UnixNanos::default())
1156 .build()
1157 .unwrap(),
1158 );
1159 let instrument_id = instrument.id();
1160 let provider = create_test_instrument_provider();
1161 provider.insert_test_instrument(instrument.clone(), 265_598, 100);
1162
1163 let avg_px_open = IbReportContext::position_avg_px_open(&instrument, 24_340.51);
1166 assert_eq!(avg_px_open, Some(Decimal::from_str("4.8681").unwrap()));
1167
1168 let fill = FillReport::new(
1169 AccountId::from("IB-DU001"),
1170 instrument_id,
1171 VenueOrderId::new("PERM-1"),
1172 TradeId::new("T-1"),
1173 OrderSide::Buy,
1174 Quantity::from(1),
1175 Price::from("4.8675"),
1176 Money::from("3.01 USD"),
1177 LiquiditySide::Taker,
1178 None,
1179 None,
1180 UnixNanos::from(10),
1181 UnixNanos::default(),
1182 None,
1183 );
1184 let mut position_reports = vec![PositionStatusReport::new(
1185 AccountId::from("IB-DU001"),
1186 instrument_id,
1187 PositionSide::Long,
1188 Quantity::from(1),
1189 UnixNanos::default(),
1190 UnixNanos::default(),
1191 None,
1192 None,
1193 avg_px_open,
1194 )];
1195
1196 let evidence = FillEvidence {
1197 reports: vec![fill],
1198 ..Default::default()
1199 };
1200
1201 fill_position_avg_px_from_fills(&mut position_reports, &evidence, &provider);
1202
1203 assert_eq!(
1206 position_reports[0].avg_px_open,
1207 Some(Decimal::from_str("4.8675").unwrap())
1208 );
1209 }
1210
1211 #[rstest]
1212 fn test_fill_position_avg_px_from_fills_keeps_venue_average_for_other_commission_currency() {
1213 let mut fill = create_test_entry_fill(OrderSide::Buy, "7", "229.67", "T-1", 10);
1214 fill.commission = Money::from("1.00 EUR");
1215 let evidence = FillEvidence {
1216 reports: vec![fill],
1217 ..Default::default()
1218 };
1219
1220 let avg_px_open = rebuild_avg_px_open(
1221 create_test_entry_position_report(PositionSide::Long, "7", "229.81"),
1222 &evidence,
1223 );
1224
1225 assert_eq!(avg_px_open, Some(Decimal::from_str("229.81").unwrap()));
1226 }
1227
1228 #[rstest]
1229 #[case::commission_rounded_to_cents(
1233 vec![(OrderSide::Buy, "0.00795734", "2507.10")],
1234 "0.00795734",
1235 "2532.27",
1236 "2507.10"
1237 )]
1238 #[case::average_cost_disagrees(
1239 vec![(OrderSide::Buy, "0.00795734", "2507.10")],
1240 "0.00795734",
1241 "2540.00",
1242 "2540.00"
1243 )]
1244 #[case::partial_close_agrees(
1248 vec![(OrderSide::Buy, "0.01", "2500.00"), (OrderSide::Sell, "0.0099", "2510.00")],
1249 "0.0001",
1250 "2520.00",
1251 "2500.00"
1252 )]
1253 #[case::partial_close_disagrees(
1254 vec![(OrderSide::Buy, "0.01", "2500.00"), (OrderSide::Sell, "0.0099", "2510.00")],
1255 "0.0001",
1256 "2530.00",
1257 "2530.00"
1258 )]
1259 fn test_fill_position_avg_px_from_fills_allows_commission_rounding(
1260 #[case] fills: Vec<(OrderSide, &str, &str)>,
1261 #[case] position_qty: &str,
1262 #[case] venue_avg_px: &str,
1263 #[case] expected: &str,
1264 ) {
1265 let instrument = CurrencyPair::builder()
1266 .instrument_id(InstrumentId::from("ETH/USD.PAXOS"))
1267 .raw_symbol(Symbol::from("ETH.USD"))
1268 .base_currency(Currency::from("ETH"))
1269 .quote_currency(Currency::USD())
1270 .price_precision(2)
1271 .size_precision(8)
1272 .price_increment(Price::from("0.01"))
1273 .size_increment(Quantity::from("0.00000001"))
1274 .ts_event(UnixNanos::default())
1275 .ts_init(UnixNanos::default())
1276 .build()
1277 .unwrap();
1278 let instrument_id = instrument.id;
1279 let provider = InteractiveBrokersInstrumentProvider::new(
1280 crate::config::InteractiveBrokersInstrumentProviderConfig::default(),
1281 )
1282 .unwrap();
1283 provider.insert_test_instrument(InstrumentAny::CurrencyPair(instrument), 495_759_171, 1);
1284 let reports = fills
1285 .into_iter()
1286 .enumerate()
1287 .map(|(index, (side, qty, px))| {
1288 FillReport::new(
1289 AccountId::from("IB-DU001"),
1290 instrument_id,
1291 VenueOrderId::new(format!("PERM-{index}")),
1292 TradeId::new(format!("T-{index}")),
1293 side,
1294 Quantity::from(qty),
1295 Price::from(px),
1296 Money::from("0.20 USD"),
1297 LiquiditySide::Taker,
1298 None,
1299 None,
1300 UnixNanos::from(index as u64 + 10),
1301 UnixNanos::default(),
1302 None,
1303 )
1304 })
1305 .collect();
1306 let evidence = FillEvidence {
1307 reports,
1308 ..Default::default()
1309 };
1310 let mut position_reports = vec![PositionStatusReport::new(
1311 AccountId::from("IB-DU001"),
1312 instrument_id,
1313 PositionSide::Long,
1314 Quantity::from(position_qty),
1315 UnixNanos::default(),
1316 UnixNanos::default(),
1317 None,
1318 None,
1319 Some(Decimal::from_str(venue_avg_px).unwrap()),
1320 )];
1321
1322 fill_position_avg_px_from_fills(&mut position_reports, &evidence, &provider);
1323
1324 assert_eq!(
1325 position_reports[0].avg_px_open,
1326 Some(Decimal::from_str(expected).unwrap())
1327 );
1328 }
1329
1330 #[rstest]
1331 #[case::instrument_missing(false, vec![equity_aapl().id], "101.00")]
1335 #[case::all_missing(true, vec![], "101.00")]
1336 #[case::other_instrument_missing(false, vec![InstrumentId::from("MSFT.NASDAQ")], "100.00")]
1337 fn test_fill_position_avg_px_from_fills_keeps_venue_average_for_missing_executions(
1338 #[case] missing_all: bool,
1339 #[case] missing_instruments: Vec<InstrumentId>,
1340 #[case] expected: &str,
1341 ) {
1342 let evidence = FillEvidence {
1343 reports: vec![create_test_entry_fill(
1344 OrderSide::Buy,
1345 "1",
1346 "100.00",
1347 "T-1",
1348 10,
1349 )],
1350 combo_leg_reports: Vec::new(),
1351 missing_all,
1352 missing_instruments: missing_instruments.into_iter().collect(),
1353 };
1354
1355 let avg_px_open = rebuild_avg_px_open(
1356 create_test_entry_position_report(PositionSide::Long, "1", "101.00"),
1357 &evidence,
1358 );
1359
1360 assert_eq!(avg_px_open, Some(Decimal::from_str(expected).unwrap()));
1361 }
1362
1363 #[rstest]
1364 #[case::opposite_sides(
1369 PositionSide::Long,
1370 "2",
1371 vec![(OrderSide::Buy, "2", "110.00"), (OrderSide::Sell, "2", "120.00")],
1372 vec![(OrderSide::Buy, "1", "100.00")],
1373 "105.50",
1374 "105.50"
1375 )]
1376 #[case::same_side_flip(
1377 PositionSide::Long,
1378 "2",
1379 vec![(OrderSide::Buy, "1", "100.00"), (OrderSide::Buy, "2", "110.00")],
1380 vec![],
1381 "110.50",
1382 "110.50"
1383 )]
1384 #[case::same_side_from_flat(
1385 PositionSide::Long,
1386 "2",
1387 vec![(OrderSide::Buy, "1", "100.00"), (OrderSide::Buy, "1", "110.00")],
1388 vec![],
1389 "106.00",
1390 "105.00"
1391 )]
1392 #[case::reopened_after_ambiguous_group(
1393 PositionSide::Long,
1394 "1",
1395 vec![(OrderSide::Buy, "1", "100.00"), (OrderSide::Sell, "1", "105.00")],
1396 vec![(OrderSide::Buy, "1", "110.00")],
1397 "111.00",
1398 "110.00"
1399 )]
1400 fn test_fill_position_avg_px_from_fills_same_timestamp_fills_ignore_delivery_order(
1401 #[case] position_side: PositionSide,
1402 #[case] quantity: &str,
1403 #[case] tied: Vec<(OrderSide, &str, &str)>,
1404 #[case] later: Vec<(OrderSide, &str, &str)>,
1405 #[case] venue_avg_px: &str,
1406 #[case] expected: &str,
1407 #[values(false, true)] reversed: bool,
1408 ) {
1409 let mut reports: Vec<_> = tied
1410 .into_iter()
1411 .enumerate()
1412 .map(|(index, (side, qty, px))| {
1413 create_test_entry_fill(side, qty, px, &format!("T-{index}"), 10)
1414 })
1415 .collect();
1416
1417 if reversed {
1418 reports.reverse();
1419 }
1420
1421 reports.extend(
1422 later
1423 .into_iter()
1424 .enumerate()
1425 .map(|(index, (side, qty, px))| {
1426 create_test_entry_fill(side, qty, px, &format!("L-{index}"), 20 + index as u64)
1427 }),
1428 );
1429 let evidence = FillEvidence {
1430 reports,
1431 ..Default::default()
1432 };
1433
1434 let avg_px_open = rebuild_avg_px_open(
1435 create_test_entry_position_report(position_side, quantity, venue_avg_px),
1436 &evidence,
1437 );
1438
1439 assert_eq!(avg_px_open, Some(Decimal::from_str(expected).unwrap()));
1440 }
1441
1442 #[rstest]
1443 #[case::with_combo_legs(true, "4.00")]
1444 #[case::without_combo_legs(false, "3.00")]
1445 fn test_fill_position_avg_px_from_fills_counts_combo_leg_fills(
1446 #[case] with_combo_legs: bool,
1447 #[case] expected: &str,
1448 ) {
1449 let combo_leg_reports = if with_combo_legs {
1454 vec![create_test_entry_fill(
1455 OrderSide::Buy,
1456 "2",
1457 "4.00",
1458 "T-3",
1459 30,
1460 )]
1461 } else {
1462 Vec::new()
1463 };
1464 let evidence = FillEvidence {
1465 reports: vec![
1466 create_test_entry_fill(OrderSide::Sell, "2", "5.00", "T-1", 10),
1467 create_test_entry_fill(OrderSide::Buy, "1", "3.00", "T-2", 20),
1468 ],
1469 combo_leg_reports,
1470 ..Default::default()
1471 };
1472
1473 let avg_px_open = rebuild_avg_px_open(
1474 create_test_entry_position_report(PositionSide::Long, "1", "4.00"),
1475 &evidence,
1476 );
1477
1478 assert_eq!(avg_px_open, Some(Decimal::from_str(expected).unwrap()));
1479 }
1480
1481 #[rstest]
1482 fn test_fill_missing_avg_px_uses_fill_reports() {
1483 let instrument_id = InstrumentId::from("ESZ6.XCME");
1484 let account_id = AccountId::from("IB-DU001");
1485
1486 let order_report = |venue_order_id: &str, avg_px: Option<Decimal>| {
1487 let mut report = OrderStatusReport::new(
1488 account_id,
1489 instrument_id,
1490 None,
1491 VenueOrderId::new(venue_order_id),
1492 Some(OrderSide::Buy),
1493 OrderType::Market,
1494 TimeInForce::Day,
1495 NautilusOrderStatus::Filled,
1496 Quantity::from(3),
1497 Quantity::from(3),
1498 UnixNanos::default(),
1499 UnixNanos::default(),
1500 UnixNanos::default(),
1501 None,
1502 );
1503 report.avg_px = avg_px;
1504 report
1505 };
1506
1507 let fill = |venue_order_id: &str, trade_id: &str, qty: u64, px: &str| {
1508 FillReport::new(
1509 account_id,
1510 instrument_id,
1511 VenueOrderId::new(venue_order_id),
1512 TradeId::new(trade_id),
1513 OrderSide::Buy,
1514 Quantity::from(qty),
1515 Price::from(px),
1516 Money::from("0.00 USD"),
1517 LiquiditySide::Taker,
1518 None,
1519 None,
1520 UnixNanos::default(),
1521 UnixNanos::default(),
1522 None,
1523 )
1524 };
1525
1526 let mut reports = vec![
1527 order_report("PERM-1", None),
1528 order_report("PERM-2", Some(Decimal::from(7))),
1529 order_report("PERM-3", None),
1530 ];
1531 let fills = vec![
1532 fill("PERM-1", "T-1", 1, "2.55"),
1533 fill("PERM-1", "T-2", 2, "2.40"),
1534 fill("PERM-2", "T-3", 3, "9.00"),
1535 ];
1536
1537 fill_missing_avg_px(&mut reports, &fills);
1538
1539 assert_eq!(reports[0].avg_px, Some(Decimal::from_str("2.45").unwrap()));
1540 assert_eq!(reports[1].avg_px, Some(Decimal::from(7)));
1541 assert_eq!(reports[2].avg_px, None);
1542 }
1543
1544 #[rstest]
1545 #[case::combo(ibapi::contracts::SecurityType::Spread, true)]
1546 #[case::leg(ibapi::contracts::SecurityType::FuturesOption, false)]
1547 fn test_is_combo_execution(
1548 #[case] security_type: ibapi::contracts::SecurityType,
1549 #[case] expected: bool,
1550 ) {
1551 let exec_data = ibapi::orders::ExecutionData {
1552 contract: ibapi::contracts::Contract {
1553 security_type,
1554 ..Default::default()
1555 },
1556 ..Default::default()
1557 };
1558
1559 assert_eq!(is_combo_execution(&exec_data), expected);
1560 }
1561
1562 #[rstest]
1563 fn test_ibalgo_with_zero_limit_price_maps_to_market() {
1564 assert_eq!(map_ib_order_type("IBALGO", Some(0.0)), OrderType::Market);
1565 }
1566
1567 #[rstest]
1568 fn test_parse_execution_time_hyphenated_format() {
1569 let time_str = "20250225-15:15:00";
1570 let result = parse_execution_time(time_str);
1571 assert!(result.is_ok());
1572 let timestamp = result.unwrap();
1573 assert!(timestamp.as_i64() > 0);
1574 }
1575
1576 #[rstest]
1577 fn test_parse_execution_time_with_met_timezone() {
1578 let met = parse_execution_time("20230223 00:43:36 MET").unwrap();
1581 let utc = parse_execution_time("20230223 00:43:36 Universal").unwrap();
1582 assert_eq!(
1584 met.as_i64(),
1585 utc.as_i64() - 3_600_000_000_000,
1586 "MET (CET) should be 1h ahead of UTC in February"
1587 );
1588 assert!(met.as_i64() > 0);
1589 }
1590
1591 #[rstest]
1592 fn test_parse_execution_time_applies_dst_for_regional_timezone() {
1593 let winter = parse_execution_time("20230223 00:43:36 America/New_York").unwrap();
1596 let summer = parse_execution_time("20230715 00:43:36 America/New_York").unwrap();
1597 let winter_utc = parse_execution_time("20230223 00:43:36 Universal").unwrap();
1598 let summer_utc = parse_execution_time("20230715 00:43:36 Universal").unwrap();
1599 assert_eq!(winter.as_i64(), winter_utc.as_i64() + 5 * 3_600_000_000_000); assert_eq!(summer.as_i64(), summer_utc.as_i64() + 4 * 3_600_000_000_000); }
1602
1603 #[rstest]
1604 fn test_parse_execution_time_dst_fall_back_fold_resolves_to_earliest() {
1605 let fold = parse_execution_time("20231105 01:30:00 America/Chicago").unwrap();
1609 assert_eq!(
1610 fold.as_i64(),
1611 parse_execution_time("20231105 06:30:00 Universal")
1612 .unwrap()
1613 .as_i64()
1614 );
1615 assert_ne!(
1616 fold.as_i64(),
1617 parse_execution_time("20231105 07:30:00 Universal")
1618 .unwrap()
1619 .as_i64()
1620 );
1621 }
1622
1623 #[rstest]
1624 fn test_parse_execution_time_dst_spring_forward_gap_errors() {
1625 let gap = parse_execution_time("20230312 02:30:00 America/Chicago");
1627 assert!(gap.is_err());
1628 }
1629
1630 #[rstest]
1631 fn test_parse_execution_time_fixed_offset_zone_without_dst() {
1632 let tokyo = parse_execution_time("20230223 00:43:36 Asia/Tokyo").unwrap();
1634 let utc = parse_execution_time("20230223 00:43:36 Universal").unwrap();
1635 assert_eq!(tokyo.as_i64(), utc.as_i64() - 9 * 3_600_000_000_000);
1636 }
1637
1638 #[rstest]
1639 fn test_parse_execution_time_with_unrecognized_timezone_errors() {
1640 let time_str = "20230223 00:43:36 Mars/Olympus";
1641 let result = parse_execution_time(time_str);
1642 assert!(result.is_err());
1643 }
1644
1645 #[rstest]
1646 fn test_parse_execution_time_utc() {
1647 let time_str = "20230223 00:43:36 Universal";
1648 let result = parse_execution_time(time_str);
1649 assert!(result.is_ok());
1650 let timestamp = result.unwrap();
1651 assert!(timestamp.as_i64() > 0);
1652 }
1653
1654 #[rstest]
1655 fn test_parse_execution_time_no_timezone_assumes_utc() {
1656 let time_str = "20230223 00:43:36";
1657 let result = parse_execution_time(time_str);
1658 assert!(result.is_ok());
1659 let timestamp = result.unwrap();
1660 assert!(timestamp.as_i64() > 0);
1661 }
1662
1663 #[rstest]
1664 fn test_parse_execution_time_invalid_format() {
1665 let time_str = "invalid format";
1666 let result = parse_execution_time(time_str);
1667 assert!(result.is_err());
1668 }
1669
1670 #[rstest]
1671 fn test_parse_execution_time_short_format() {
1672 let time_str = "20230223 00:43";
1673 let result = parse_execution_time(time_str);
1674 assert!(result.is_err());
1675 }
1676
1677 #[rstest]
1678 fn test_parse_order_status_to_report_submitted() {
1679 let instrument_provider = create_test_instrument_provider();
1680 let instrument_id = create_test_instrument_id();
1681 let account_id = AccountId::from("IB-001");
1682
1683 let order_status = OrderStatus {
1684 order_id: 12345,
1685 status: OrderStatusKind::Submitted,
1686 filled: 0.0,
1687 remaining: 100.0,
1688 average_fill_price: Some(0.0),
1689 perm_id: 0,
1690 parent_id: 0,
1691 last_fill_price: Some(0.0),
1692 client_id: 0,
1693 why_held: String::new(),
1694 market_cap_price: Some(0.0),
1695 };
1696
1697 let result = parse_order_status_to_report(
1698 &order_status,
1699 None,
1700 instrument_id,
1701 account_id,
1702 &instrument_provider,
1703 UnixNanos::new(0),
1704 );
1705
1706 if let Err(e) = result {
1708 let error_msg = e.to_string();
1709 assert!(
1710 error_msg.contains("not found") || error_msg.contains("instrument"),
1711 "Unexpected error: {error_msg}"
1712 );
1713 }
1714 }
1715
1716 #[rstest]
1717 fn test_parse_order_status_to_report_filled() {
1718 let instrument_provider = create_test_instrument_provider();
1719 let instrument_id = create_test_instrument_id();
1720 let account_id = AccountId::from("IB-001");
1721
1722 let order_status = OrderStatus {
1723 order_id: 12345,
1724 status: OrderStatusKind::Filled,
1725 filled: 100.0,
1726 remaining: 0.0,
1727 average_fill_price: Some(150.25),
1728 perm_id: 0,
1729 parent_id: 0,
1730 last_fill_price: Some(150.25),
1731 client_id: 0,
1732 why_held: String::new(),
1733 market_cap_price: Some(0.0),
1734 };
1735
1736 let result = parse_order_status_to_report(
1737 &order_status,
1738 None,
1739 instrument_id,
1740 account_id,
1741 &instrument_provider,
1742 UnixNanos::new(0),
1743 );
1744
1745 if let Err(e) = result {
1747 let error_msg = e.to_string();
1748 assert!(
1749 error_msg.contains("not found") || error_msg.contains("instrument"),
1750 "Unexpected error: {error_msg}"
1751 );
1752 }
1753 }
1754
1755 #[rstest]
1756 fn test_parse_order_status_to_report_spread_allows_negative_avg_fill_price() {
1757 let instrument_provider = create_test_instrument_provider();
1758
1759 let instrument_id = InstrumentId::new(
1760 Symbol::from("(1)SPY C400___((1))SPY C410"),
1761 Venue::from("SMART"),
1762 );
1763 let account_id = AccountId::from("IB-001");
1764
1765 let order_status = OrderStatus {
1766 order_id: 12345,
1767 status: OrderStatusKind::Filled,
1768 filled: 1.0,
1769 remaining: 0.0,
1770 average_fill_price: Some(-2.25),
1771 perm_id: 0,
1772 parent_id: 0,
1773 last_fill_price: Some(-2.25),
1774 client_id: 0,
1775 why_held: String::new(),
1776 market_cap_price: Some(0.0),
1777 };
1778
1779 let report = parse_order_status_to_report(
1780 &order_status,
1781 None,
1782 instrument_id,
1783 account_id,
1784 &instrument_provider,
1785 UnixNanos::new(0),
1786 )
1787 .unwrap();
1788
1789 assert_eq!(report.avg_px, Some(Decimal::from_str("-2.25").unwrap()));
1790 }
1791
1792 #[rstest]
1793 fn test_parse_order_status_to_report_inactive_maps_to_rejected() {
1794 let instrument_provider = create_test_instrument_provider();
1795 let instrument_id = create_test_instrument_id();
1796 let account_id = AccountId::from("IB-001");
1797
1798 let order_status = OrderStatus {
1799 order_id: 12345,
1800 status: OrderStatusKind::Inactive,
1801 filled: 0.0,
1802 remaining: 100.0,
1803 average_fill_price: Some(0.0),
1804 perm_id: 0,
1805 parent_id: 0,
1806 last_fill_price: Some(0.0),
1807 client_id: 0,
1808 why_held: String::new(),
1809 market_cap_price: Some(0.0),
1810 };
1811
1812 let report = parse_order_status_to_report(
1813 &order_status,
1814 None,
1815 instrument_id,
1816 account_id,
1817 &instrument_provider,
1818 UnixNanos::new(0),
1819 )
1820 .unwrap();
1821
1822 assert_eq!(report.order_status, NautilusOrderStatus::Rejected);
1823 }
1824
1825 #[rstest]
1826 fn test_parse_order_status_to_report_partial_fill_and_perm_fallback() {
1827 let instrument_provider = create_test_instrument_provider();
1828 let instrument_id = create_test_instrument_id();
1829 let account_id = AccountId::from("IB-001");
1830
1831 let order_status = OrderStatus {
1832 order_id: 0,
1833 status: OrderStatusKind::Submitted,
1834 filled: 3.0,
1835 remaining: 7.0,
1836 average_fill_price: Some(150.25),
1837 perm_id: 123_456,
1838 parent_id: 0,
1839 last_fill_price: Some(150.25),
1840 client_id: 0,
1841 why_held: String::new(),
1842 market_cap_price: Some(0.0),
1843 };
1844
1845 let order = Order {
1846 action: Action::Buy,
1847 total_quantity: 10.0,
1848 order_type: "LMT".to_string(),
1849 limit_price: Some(150.25),
1850 order_ref: "O-20260527-001:123".to_string(),
1851 ..Default::default()
1852 };
1853
1854 let report = parse_order_status_to_report(
1855 &order_status,
1856 Some(&order),
1857 instrument_id,
1858 account_id,
1859 &instrument_provider,
1860 UnixNanos::new(0),
1861 )
1862 .unwrap();
1863
1864 assert_eq!(report.order_status, NautilusOrderStatus::PartiallyFilled);
1865 assert_eq!(report.venue_order_id.to_string(), "PERM-123456");
1866 assert_eq!(
1867 report.client_order_id,
1868 Some(ClientOrderId::from("O-20260527-001"))
1869 );
1870 }
1871
1872 #[rstest]
1873 fn test_parse_order_status_to_report_recovers_completed_order_quantity() {
1874 let instrument_provider = create_test_instrument_provider();
1875 let instrument_id = create_test_instrument_id();
1876
1877 let order_status = OrderStatus {
1878 status: OrderStatusKind::Filled,
1879 filled: 3.0,
1880 remaining: 0.0,
1881 ..Default::default()
1882 };
1883
1884 let order = Order {
1885 total_quantity: 0.0,
1886 filled_quantity: 3.0,
1887 ..Default::default()
1888 };
1889
1890 let report = parse_order_status_to_report(
1891 &order_status,
1892 Some(&order),
1893 instrument_id,
1894 AccountId::from("IB-001"),
1895 &instrument_provider,
1896 UnixNanos::new(0),
1897 )
1898 .unwrap();
1899
1900 assert_eq!(report.quantity, Quantity::from(3));
1901 assert_eq!(report.filled_qty, Quantity::from(3));
1902 }
1903
1904 #[rstest]
1905 fn test_parse_order_status_to_report_sets_stop_trigger_type() {
1906 let instrument_provider = create_test_instrument_provider();
1907 let instrument_id = create_test_instrument_id();
1908
1909 let order_status = OrderStatus {
1910 status: OrderStatusKind::Cancelled,
1911 remaining: 1.0,
1912 ..Default::default()
1913 };
1914
1915 let order = Order {
1916 total_quantity: 1.0,
1917 order_type: "STP".to_string(),
1918 aux_price: Some(100.0),
1919 trigger_method: ibapi::orders::conditions::TriggerMethod::Last,
1920 ..Default::default()
1921 };
1922
1923 let report = parse_order_status_to_report(
1924 &order_status,
1925 Some(&order),
1926 instrument_id,
1927 AccountId::from("IB-001"),
1928 &instrument_provider,
1929 UnixNanos::new(0),
1930 )
1931 .unwrap();
1932
1933 assert_eq!(report.order_type, OrderType::StopMarket);
1934 assert_eq!(report.trigger_price, Some(Price::new(100.0, 0)));
1935 assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1936 }
1937
1938 #[rstest]
1939 #[case(
1940 "GTD",
1941 "20260925 20:00:00 UTC",
1942 TimeInForce::Gtd,
1943 Some(1_790_366_400_000_000_000)
1944 )]
1945 #[case(
1946 "GTC",
1947 "20260925-20:00:00",
1948 TimeInForce::Gtd,
1949 Some(1_790_366_400_000_000_000)
1950 )]
1951 #[case("GTD", "not a date", TimeInForce::Gtc, None)]
1952 #[case("DAY", "", TimeInForce::Day, None)]
1953 fn test_parse_order_status_to_report_sets_gtd_expire_time(
1954 #[case] tif: &str,
1955 #[case] good_till_date: &str,
1956 #[case] expected_time_in_force: TimeInForce,
1957 #[case] expected_expire_time: Option<u64>,
1958 ) {
1959 let instrument_provider = create_test_instrument_provider();
1960 let instrument_id = create_test_instrument_id();
1961
1962 let order_status = OrderStatus {
1963 status: OrderStatusKind::Filled,
1964 filled: 1.0,
1965 ..Default::default()
1966 };
1967
1968 let order = Order {
1969 total_quantity: 1.0,
1970 order_type: "LMT".to_string(),
1971 limit_price: Some(101.0),
1972 tif: ibapi::orders::TimeInForce::from(tif),
1973 good_till_date: good_till_date.to_string(),
1974 ..Default::default()
1975 };
1976
1977 let report = parse_order_status_to_report(
1978 &order_status,
1979 Some(&order),
1980 instrument_id,
1981 AccountId::from("IB-001"),
1982 &instrument_provider,
1983 UnixNanos::new(0),
1984 )
1985 .unwrap();
1986
1987 assert_eq!(report.time_in_force, expected_time_in_force);
1988 assert_eq!(
1989 report.expire_time,
1990 expected_expire_time.map(UnixNanos::from)
1991 );
1992 }
1993
1994 #[rstest]
1995 #[case("MKT", Some(0.0), OrderType::Market, None)]
1996 #[case("STP", Some(0.0), OrderType::StopMarket, None)]
1997 #[case("LMT", Some(101.0), OrderType::Limit, Some(Price::new(101.0, 0)))]
1998 fn test_parse_order_status_to_report_sets_price_only_for_limit_types(
1999 #[case] ib_order_type: &str,
2000 #[case] limit_price: Option<f64>,
2001 #[case] expected_order_type: OrderType,
2002 #[case] expected_price: Option<Price>,
2003 ) {
2004 let instrument_provider = create_test_instrument_provider();
2005 let instrument_id = create_test_instrument_id();
2006
2007 let order_status = OrderStatus {
2008 status: OrderStatusKind::Filled,
2009 filled: 1.0,
2010 ..Default::default()
2011 };
2012
2013 let order = Order {
2014 total_quantity: 1.0,
2015 order_type: ib_order_type.to_string(),
2016 limit_price,
2017 aux_price: Some(100.0),
2018 ..Default::default()
2019 };
2020
2021 let report = parse_order_status_to_report(
2022 &order_status,
2023 Some(&order),
2024 instrument_id,
2025 AccountId::from("IB-001"),
2026 &instrument_provider,
2027 UnixNanos::new(0),
2028 )
2029 .unwrap();
2030
2031 assert_eq!(report.order_type, expected_order_type);
2032 assert_eq!(report.price, expected_price);
2033 }
2034
2035 #[rstest]
2036 fn test_ib_venue_order_id_prefers_perm_id_and_falls_back_to_order_id() {
2037 assert_eq!(ib_venue_order_id(123, 456).to_string(), "PERM-456");
2038 assert_eq!(ib_venue_order_id(123, 0).to_string(), "123");
2039 }
2040
2041 #[rstest]
2042 fn test_normalized_order_ref_strips_ib_suffix() {
2043 assert_eq!(normalized_order_ref("O-001:123"), Some("O-001"));
2044 assert_eq!(normalized_order_ref("O-001"), Some("O-001"));
2045 assert_eq!(normalized_order_ref(""), None);
2046 }
2047
2048 #[rstest]
2049 #[case(Liquidity::AddedLiquidity, LiquiditySide::Maker)]
2050 #[case(Liquidity::RemovedLiquidity, LiquiditySide::Taker)]
2051 #[case(Liquidity::None, LiquiditySide::NoLiquiditySide)]
2052 #[case(Liquidity::LiquidityRoutedOut, LiquiditySide::NoLiquiditySide)]
2053 #[case(Liquidity::Unknown(4), LiquiditySide::NoLiquiditySide)]
2054 fn test_execution_liquidity_side(
2055 #[case] liquidity: Liquidity,
2056 #[case] expected: LiquiditySide,
2057 ) {
2058 assert_eq!(execution_liquidity_side(&liquidity), expected);
2059 }
2060
2061 struct PricingExpectation {
2062 order_type: OrderType,
2063 price: Option<Price>,
2064 trigger_price: Option<Price>,
2065 limit_offset: Option<Decimal>,
2066 trailing_offset: Option<Decimal>,
2067 trailing_offset_type: Option<TrailingOffsetType>,
2068 }
2069
2070 #[rstest]
2071 #[case(
2072 "MKT",
2073 None,
2074 None,
2075 None,
2076 None,
2077 PricingExpectation {
2078 order_type: OrderType::Market,
2079 price: None,
2080 trigger_price: None,
2081 limit_offset: None,
2082 trailing_offset: None,
2083 trailing_offset_type: None,
2084 }
2085 )]
2086 #[case(
2087 "LMT",
2088 Some(185.0),
2089 None,
2090 None,
2091 None,
2092 PricingExpectation {
2093 order_type: OrderType::Limit,
2094 price: Some(Price::new(185.0, 0)),
2095 trigger_price: None,
2096 limit_offset: None,
2097 trailing_offset: None,
2098 trailing_offset_type: None,
2099 }
2100 )]
2101 #[case(
2102 "IBALGO",
2103 Some(185.0),
2104 None,
2105 None,
2106 None,
2107 PricingExpectation {
2108 order_type: OrderType::Limit,
2109 price: Some(Price::new(185.0, 0)),
2110 trigger_price: None,
2111 limit_offset: None,
2112 trailing_offset: None,
2113 trailing_offset_type: None,
2114 }
2115 )]
2116 #[case(
2117 "IBALGO",
2118 None,
2119 None,
2120 None,
2121 None,
2122 PricingExpectation {
2123 order_type: OrderType::Market,
2124 price: None,
2125 trigger_price: None,
2126 limit_offset: None,
2127 trailing_offset: None,
2128 trailing_offset_type: None,
2129 }
2130 )]
2131 #[case(
2132 "MIT",
2133 None,
2134 Some(180.0),
2135 None,
2136 None,
2137 PricingExpectation {
2138 order_type: OrderType::MarketIfTouched,
2139 price: None,
2140 trigger_price: Some(Price::new(180.0, 0)),
2141 limit_offset: None,
2142 trailing_offset: None,
2143 trailing_offset_type: None,
2144 }
2145 )]
2146 #[case(
2147 "LIT",
2148 Some(179.0),
2149 Some(180.0),
2150 None,
2151 None,
2152 PricingExpectation {
2153 order_type: OrderType::LimitIfTouched,
2154 price: Some(Price::new(179.0, 0)),
2155 trigger_price: Some(Price::new(180.0, 0)),
2156 limit_offset: None,
2157 trailing_offset: None,
2158 trailing_offset_type: None,
2159 }
2160 )]
2161 #[case(
2162 "STP",
2163 None,
2164 Some(180.0),
2165 None,
2166 None,
2167 PricingExpectation {
2168 order_type: OrderType::StopMarket,
2169 price: None,
2170 trigger_price: Some(Price::new(180.0, 0)),
2171 limit_offset: None,
2172 trailing_offset: None,
2173 trailing_offset_type: None,
2174 }
2175 )]
2176 #[case(
2177 "STP LMT",
2178 Some(179.0),
2179 Some(180.0),
2180 None,
2181 None,
2182 PricingExpectation {
2183 order_type: OrderType::StopLimit,
2184 price: Some(Price::new(179.0, 0)),
2185 trigger_price: Some(Price::new(180.0, 0)),
2186 limit_offset: None,
2187 trailing_offset: None,
2188 trailing_offset_type: None,
2189 }
2190 )]
2191 #[case(
2192 "TRAIL LIMIT",
2193 None,
2194 Some(2.5),
2195 Some(185.0),
2196 Some(0.25),
2197 PricingExpectation {
2198 order_type: OrderType::TrailingStopLimit,
2199 price: None,
2200 trigger_price: Some(Price::new(185.0, 0)),
2201 limit_offset: Some(Decimal::from_str("0.25").unwrap()),
2202 trailing_offset: Some(Decimal::from_str("2.5").unwrap()),
2203 trailing_offset_type: Some(TrailingOffsetType::Price),
2204 },
2205 )]
2206 fn test_parse_order_status_to_report_maps_pricing_fields_by_order_type(
2207 #[case] ib_order_type: &str,
2208 #[case] limit_price: Option<f64>,
2209 #[case] aux_price: Option<f64>,
2210 #[case] trail_stop_price: Option<f64>,
2211 #[case] limit_price_offset: Option<f64>,
2212 #[case] expected: PricingExpectation,
2213 ) {
2214 let instrument_provider = create_test_instrument_provider();
2215 let instrument_id = create_test_instrument_id();
2216 let account_id = AccountId::from("IB-001");
2217
2218 let order_status = OrderStatus {
2219 order_id: 12345,
2220 status: OrderStatusKind::Submitted,
2221 filled: 0.0,
2222 remaining: 5.0,
2223 average_fill_price: Some(0.0),
2224 perm_id: 0,
2225 parent_id: 0,
2226 last_fill_price: Some(0.0),
2227 client_id: 0,
2228 why_held: String::new(),
2229 market_cap_price: Some(0.0),
2230 };
2231
2232 let order = Order {
2233 action: Action::Buy,
2234 total_quantity: 5.0,
2235 order_type: ib_order_type.to_string(),
2236 limit_price,
2237 aux_price,
2238 trail_stop_price,
2239 limit_price_offset,
2240 tif: ibapi::orders::TimeInForce::GoodTillCanceled,
2241 ..Default::default()
2242 };
2243
2244 let report = parse_order_status_to_report(
2245 &order_status,
2246 Some(&order),
2247 instrument_id,
2248 account_id,
2249 &instrument_provider,
2250 UnixNanos::new(0),
2251 )
2252 .unwrap();
2253
2254 assert_eq!(report.order_type, expected.order_type);
2255 assert_eq!(report.price, expected.price);
2256 assert_eq!(report.trigger_price, expected.trigger_price);
2257 assert_eq!(report.limit_offset, expected.limit_offset);
2258 assert_eq!(report.trailing_offset, expected.trailing_offset);
2259 assert_eq!(report.trailing_offset_type, expected.trailing_offset_type);
2260 }
2261
2262 #[rstest]
2263 fn test_parse_order_status_to_report_maps_trailing_percent_to_basis_points() {
2264 let instrument_provider = create_test_instrument_provider();
2265 let instrument_id = create_test_instrument_id();
2266 let account_id = AccountId::from("IB-001");
2267
2268 let order_status = OrderStatus {
2269 order_id: 12345,
2270 status: OrderStatusKind::Submitted,
2271 filled: 0.0,
2272 remaining: 5.0,
2273 average_fill_price: Some(0.0),
2274 perm_id: 0,
2275 parent_id: 0,
2276 last_fill_price: Some(0.0),
2277 client_id: 0,
2278 why_held: String::new(),
2279 market_cap_price: Some(0.0),
2280 };
2281
2282 let order = Order {
2283 action: Action::Buy,
2284 total_quantity: 5.0,
2285 order_type: "TRAIL".to_string(),
2286 trail_stop_price: Some(185.0),
2287 trailing_percent: Some(2.5),
2288 tif: ibapi::orders::TimeInForce::GoodTillCanceled,
2289 ..Default::default()
2290 };
2291
2292 let report = parse_order_status_to_report(
2293 &order_status,
2294 Some(&order),
2295 instrument_id,
2296 account_id,
2297 &instrument_provider,
2298 UnixNanos::new(0),
2299 )
2300 .unwrap();
2301
2302 assert_eq!(report.order_type, OrderType::TrailingStopMarket);
2303 assert_eq!(report.trigger_price, Some(Price::new(185.0, 0)));
2304 assert_eq!(
2305 report.trailing_offset,
2306 Some(Decimal::from_str("250").unwrap())
2307 );
2308 assert_eq!(
2309 report.trailing_offset_type,
2310 Some(TrailingOffsetType::BasisPoints),
2311 );
2312 assert_eq!(report.limit_offset, None);
2313 }
2314
2315 #[rstest]
2316 fn test_parse_execution_to_fill_report_buy() {
2317 let instrument_provider = create_test_instrument_provider();
2318 let instrument_id = create_test_instrument_id();
2319 let account_id = AccountId::from("IB-001");
2320
2321 let execution = Execution {
2322 order_id: 12345,
2323 client_id: 0,
2324 execution_id: String::from("EXEC-001"),
2325 time: String::from("20230223 00:43:36 Universal"),
2326 account_number: String::new(),
2327 exchange: String::new(),
2328 side: ExecutionSide::Bought,
2329 shares: 100.0,
2330 price: 150.25,
2331 perm_id: 0,
2332 liquidation: 0,
2333 cumulative_quantity: 100.0,
2334 average_price: 150.25,
2335 order_reference: String::from("ORDER-REF-001"),
2336 ev_rule: String::new(),
2337 ev_multiplier: None,
2338 model_code: String::new(),
2339 last_liquidity: Liquidity::None,
2340 pending_price_revision: false,
2341 submitter: String::new(),
2342 };
2343
2344 let contract = Contract::default();
2345 let result = parse_execution_to_fill_report(
2346 &execution,
2347 &contract,
2348 1.0,
2349 "USD",
2350 instrument_id,
2351 account_id,
2352 &instrument_provider,
2353 UnixNanos::new(0),
2354 None, );
2356
2357 match result {
2359 Err(e) => {
2360 let error_msg = e.to_string();
2361 assert!(
2362 error_msg.contains("not found") || error_msg.contains("instrument"),
2363 "Unexpected error: {error_msg}"
2364 );
2365 }
2366 Ok(fill) => {
2367 assert_eq!(fill.order_side, OrderSide::Buy);
2368 assert_eq!(fill.trade_id.to_string(), "EXEC-001");
2369 }
2370 }
2371 }
2372
2373 #[rstest]
2374 fn test_parse_execution_to_fill_report_clamps_only_pending_commission_sentinel() {
2375 let instrument_provider = create_test_instrument_provider();
2376 let instrument = equity_aapl();
2377 let instrument_id = instrument.id();
2378 instrument_provider.insert_test_instrument(InstrumentAny::from(instrument), 265598, 1);
2379 let account_id = AccountId::from("IB-001");
2380 let contract = Contract::default();
2381
2382 for (commission, expected) in [(-1.0, 0.0), (-0.25, -0.25)] {
2383 let execution = Execution {
2384 order_id: 12345,
2385 client_id: 0,
2386 execution_id: format!("EXEC-{commission}"),
2387 time: String::from("20230223 00:43:36 Universal"),
2388 account_number: String::new(),
2389 exchange: String::new(),
2390 side: ExecutionSide::Bought,
2391 shares: 100.0,
2392 price: 150.25,
2393 perm_id: 0,
2394 liquidation: 0,
2395 cumulative_quantity: 100.0,
2396 average_price: 150.25,
2397 order_reference: String::from("ORDER-REF-001"),
2398 ev_rule: String::new(),
2399 ev_multiplier: None,
2400 model_code: String::new(),
2401 last_liquidity: Liquidity::None,
2402 pending_price_revision: false,
2403 submitter: String::new(),
2404 };
2405
2406 let report = parse_execution_to_fill_report(
2407 &execution,
2408 &contract,
2409 commission,
2410 "USD",
2411 instrument_id,
2412 account_id,
2413 &instrument_provider,
2414 UnixNanos::new(0),
2415 None,
2416 )
2417 .unwrap();
2418
2419 assert_eq!(report.commission, Money::new(expected, Currency::USD()));
2420 }
2421 }
2422
2423 #[rstest]
2424 fn test_parse_execution_to_fill_report_sell() {
2425 let instrument_provider = create_test_instrument_provider();
2426 let instrument_id = create_test_instrument_id();
2427 let account_id = AccountId::from("IB-001");
2428
2429 let execution = Execution {
2430 order_id: 12345,
2431 client_id: 0,
2432 execution_id: String::from("EXEC-002"),
2433 time: String::from("20230223 00:43:36 Universal"),
2434 account_number: String::new(),
2435 exchange: String::new(),
2436 side: ExecutionSide::Sold,
2437 shares: 50.0,
2438 price: 151.0,
2439 perm_id: 0,
2440 liquidation: 0,
2441 cumulative_quantity: 50.0,
2442 average_price: 151.0,
2443 order_reference: String::new(),
2444 ev_rule: String::new(),
2445 ev_multiplier: None,
2446 model_code: String::new(),
2447 last_liquidity: Liquidity::None,
2448 pending_price_revision: false,
2449 submitter: String::new(),
2450 };
2451
2452 let contract = Contract::default();
2453 let result = parse_execution_to_fill_report(
2454 &execution,
2455 &contract,
2456 0.5,
2457 "USD",
2458 instrument_id,
2459 account_id,
2460 &instrument_provider,
2461 UnixNanos::new(0),
2462 None, );
2464
2465 match result {
2467 Err(e) => {
2468 let error_msg = e.to_string();
2469 assert!(
2470 error_msg.contains("not found") || error_msg.contains("instrument"),
2471 "Unexpected error: {error_msg}"
2472 );
2473 }
2474 Ok(fill) => {
2475 assert_eq!(fill.order_side, OrderSide::Sell);
2476 }
2477 }
2478 }
2479
2480 fn contingency_report(
2481 client_order_id: Option<&str>,
2482 venue_order_id: &str,
2483 ) -> OrderStatusReport {
2484 OrderStatusReport::new(
2485 AccountId::from("IB-001"),
2486 create_test_instrument_id(),
2487 client_order_id.map(ClientOrderId::from),
2488 VenueOrderId::from(venue_order_id),
2489 OrderSide::Sell.into(),
2490 OrderType::StopMarket,
2491 TimeInForce::Gtc,
2492 NautilusOrderStatus::Accepted,
2493 Quantity::from(1),
2494 Quantity::from(0),
2495 UnixNanos::default(),
2496 UnixNanos::default(),
2497 UnixNanos::default(),
2498 None,
2499 )
2500 }
2501
2502 fn contingency_order(order_id: i32, perm_id: i64, parent_id: i32, oca: &str) -> Order {
2503 Order {
2504 order_id,
2505 perm_id,
2506 client_id: 7,
2507 parent_id,
2508 account: "DU123".to_string(),
2509 oca_group: oca.to_string(),
2510 oca_type: ibapi::orders::OcaType::ReduceWithBlock,
2511 ..Default::default()
2512 }
2513 }
2514
2515 #[rstest]
2516 fn test_link_order_contingencies_restores_bracket_and_oca_group() {
2517 let mut reports = vec![
2518 contingency_report(Some("O-ENTRY"), "PERM-10"),
2519 contingency_report(Some("O-STOP"), "PERM-11"),
2520 contingency_report(None, "PERM-12"),
2521 contingency_report(Some("O-ALONE"), "PERM-13"),
2522 ];
2523 let orders = vec![
2524 contingency_order(1, 10, 0, ""),
2525 contingency_order(2, 11, 1, "EXIT-1"),
2526 contingency_order(3, 12, 1, "EXIT-1"),
2527 contingency_order(4, 13, 99, ""),
2528 ];
2529
2530 link_order_contingencies(&mut reports, &orders);
2531
2532 let links = reports
2533 .iter()
2534 .map(|report| {
2535 (
2536 report.contingency_type,
2537 report.linked_order_ids.clone(),
2538 report.parent_order_id,
2539 )
2540 })
2541 .collect::<Vec<_>>();
2542
2543 assert_eq!(
2544 links,
2545 vec![
2546 (
2547 Some(ContingencyType::Oto),
2548 Some(vec![
2549 ClientOrderId::from("O-STOP"),
2550 ClientOrderId::from("PERM-12")
2551 ]),
2552 None,
2553 ),
2554 (
2555 Some(ContingencyType::Ouo),
2556 Some(vec![ClientOrderId::from("PERM-12")]),
2557 Some(ClientOrderId::from("O-ENTRY")),
2558 ),
2559 (
2560 Some(ContingencyType::Ouo),
2561 Some(vec![ClientOrderId::from("O-STOP")]),
2562 Some(ClientOrderId::from("O-ENTRY")),
2563 ),
2564 (None, None, None),
2565 ]
2566 );
2567 }
2568
2569 #[rstest]
2570 #[case::cancel(ibapi::orders::OcaType::CancelWithBlock, Some(ContingencyType::Oco))]
2571 #[case::reduce_without_block(
2572 ibapi::orders::OcaType::ReduceWithoutBlock,
2573 Some(ContingencyType::Ouo)
2574 )]
2575 #[case::unset(ibapi::orders::OcaType::None, None)]
2576 fn test_link_order_contingencies_maps_oca_type(
2577 #[case] oca_type: ibapi::orders::OcaType,
2578 #[case] expected: Option<ContingencyType>,
2579 ) {
2580 let mut reports = vec![
2581 contingency_report(Some("O-A"), "PERM-20"),
2582 contingency_report(Some("O-B"), "PERM-21"),
2583 ];
2584 let mut orders = vec![
2585 contingency_order(1, 20, 0, "GROUP"),
2586 contingency_order(2, 21, 0, "GROUP"),
2587 ];
2588
2589 for order in &mut orders {
2590 order.oca_type = oca_type;
2591 }
2592
2593 link_order_contingencies(&mut reports, &orders);
2594
2595 assert_eq!(
2596 reports
2597 .iter()
2598 .map(|report| (report.contingency_type, report.linked_order_ids.clone()))
2599 .collect::<Vec<_>>(),
2600 vec![
2601 (expected, expected.map(|_| vec![ClientOrderId::from("O-B")])),
2602 (expected, expected.map(|_| vec![ClientOrderId::from("O-A")])),
2603 ]
2604 );
2605 }
2606
2607 #[rstest]
2608 #[case::iceberg(Some(2), Some(Quantity::from(2)))]
2609 #[case::full_size(Some(5), None)]
2610 #[case::unset(Some(0), None)]
2611 fn test_parse_order_status_to_report_sets_display_qty(
2612 #[case] display_size: Option<i32>,
2613 #[case] expected: Option<Quantity>,
2614 ) {
2615 let instrument_provider = create_test_instrument_provider();
2616
2617 let order = Order {
2618 total_quantity: 5.0,
2619 order_type: "LMT".to_string(),
2620 limit_price: Some(101.0),
2621 display_size,
2622 ..Default::default()
2623 };
2624
2625 let order_status = OrderStatus {
2626 status: OrderStatusKind::Submitted,
2627 remaining: 5.0,
2628 ..Default::default()
2629 };
2630
2631 let report = parse_order_status_to_report(
2632 &order_status,
2633 Some(&order),
2634 create_test_instrument_id(),
2635 AccountId::from("IB-001"),
2636 &instrument_provider,
2637 UnixNanos::new(0),
2638 )
2639 .unwrap();
2640
2641 assert_eq!(report.display_qty, expected);
2642 }
2643
2644 #[rstest]
2645 #[case::completed("20260924 12:02:04 UTC", 1_790_251_324_000_000_000)]
2646 #[case::open("", 7)]
2647 fn test_parse_order_data_to_report_uses_completed_time(
2648 #[case] completed_time: &str,
2649 #[case] expected_ts_last: u64,
2650 ) {
2651 let instrument_provider = create_test_instrument_provider();
2652
2653 let mut data = OrderData {
2654 order_id: 1,
2655 order: Order {
2656 total_quantity: 1.0,
2657 order_type: "MKT".to_string(),
2658 ..Default::default()
2659 },
2660 ..Default::default()
2661 };
2662
2663 data.order_state.completed_time = completed_time.to_string();
2664
2665 let report = parse_order_data_to_report(
2666 &data,
2667 create_test_instrument_id(),
2668 AccountId::from("IB-001"),
2669 &instrument_provider,
2670 UnixNanos::new(7),
2671 )
2672 .unwrap();
2673
2674 assert_eq!(report.ts_last, UnixNanos::from(expected_ts_last));
2675 }
2676
2677 fn create_magnified_provider() -> (InteractiveBrokersInstrumentProvider, InstrumentId) {
2679 let provider = create_test_instrument_provider();
2680 let instrument = equity_aapl_itch();
2681 let instrument_id = instrument.id();
2682 provider.insert_test_instrument(InstrumentAny::from(instrument), 265_598, 100);
2683 (provider, instrument_id)
2684 }
2685
2686 fn create_test_order_status(average_fill_price: Option<f64>) -> OrderStatus {
2687 OrderStatus {
2688 order_id: 12345,
2689 status: OrderStatusKind::Submitted,
2690 filled: 3.0,
2691 remaining: 7.0,
2692 average_fill_price,
2693 perm_id: 0,
2694 parent_id: 0,
2695 last_fill_price: average_fill_price,
2696 client_id: 0,
2697 why_held: String::new(),
2698 market_cap_price: Some(0.0),
2699 }
2700 }
2701
2702 #[rstest]
2703 fn test_parse_execution_to_fill_report_divides_magnified_price() {
2704 let (provider, instrument_id) = create_magnified_provider();
2705
2706 let execution = Execution {
2707 order_id: 12345,
2708 client_id: 0,
2709 execution_id: String::from("EXEC-001"),
2710 time: String::from("20230223 00:43:36 Universal"),
2711 account_number: String::new(),
2712 exchange: String::new(),
2713 side: ExecutionSide::Bought,
2714 shares: 1.0,
2715 price: 425.25,
2716 perm_id: 0,
2717 liquidation: 0,
2718 cumulative_quantity: 1.0,
2719 average_price: 425.25,
2720 order_reference: String::from("ORDER-REF-001"),
2721 ev_rule: String::new(),
2722 ev_multiplier: None,
2723 model_code: String::new(),
2724 last_liquidity: Liquidity::None,
2725 pending_price_revision: false,
2726 submitter: String::new(),
2727 };
2728
2729 let fill = parse_execution_to_fill_report(
2730 &execution,
2731 &Contract::default(),
2732 1.0,
2733 "USD",
2734 instrument_id,
2735 AccountId::from("IB-001"),
2736 &provider,
2737 UnixNanos::new(0),
2738 None,
2739 )
2740 .unwrap();
2741
2742 assert_eq!(fill.last_px, Price::from("4.2525"));
2743 }
2744
2745 #[rstest]
2746 fn test_parse_order_status_to_report_divides_magnified_prices() {
2747 let (provider, instrument_id) = create_magnified_provider();
2748
2749 let order = Order {
2750 action: Action::Buy,
2751 total_quantity: 10.0,
2752 order_type: "STP LMT".to_string(),
2753 limit_price: Some(425.25),
2754 aux_price: Some(430.25),
2755 ..Default::default()
2756 };
2757
2758 let report = parse_order_status_to_report(
2759 &create_test_order_status(Some(425.5)),
2760 Some(&order),
2761 instrument_id,
2762 AccountId::from("IB-001"),
2763 &provider,
2764 UnixNanos::new(0),
2765 )
2766 .unwrap();
2767
2768 assert_eq!(report.price, Some(Price::from("4.2525")));
2769 assert_eq!(report.trigger_price, Some(Price::from("4.3025")));
2770 assert_eq!(report.avg_px, Some(Decimal::from_str("4.255").unwrap()));
2771 }
2772
2773 #[rstest]
2774 #[case(400.02, "4.0002")]
2775 #[case(486.75, "4.8675")]
2776 #[case(117.3, "1.173")]
2777 #[case(0.07, "0.0007")]
2778 fn test_parse_order_status_to_report_divides_magnified_avg_px_exactly(
2779 #[case] ib_avg_fill_price: f64,
2780 #[case] expected: &str,
2781 ) {
2782 let (provider, instrument_id) = create_magnified_provider();
2783
2784 let report = parse_order_status_to_report(
2785 &create_test_order_status(Some(ib_avg_fill_price)),
2786 None,
2787 instrument_id,
2788 AccountId::from("IB-001"),
2789 &provider,
2790 UnixNanos::new(0),
2791 )
2792 .unwrap();
2793
2794 assert_eq!(report.avg_px, Some(Decimal::from_str(expected).unwrap()));
2795 }
2796
2797 #[rstest]
2798 fn test_parse_order_status_to_report_divides_magnified_trailing_prices() {
2799 let (provider, instrument_id) = create_magnified_provider();
2800
2801 let order = Order {
2802 action: Action::Sell,
2803 total_quantity: 10.0,
2804 order_type: "TRAIL".to_string(),
2805 trail_stop_price: Some(481.25),
2806 aux_price: Some(5.25),
2807 ..Default::default()
2808 };
2809
2810 let report = parse_order_status_to_report(
2811 &create_test_order_status(Some(0.0)),
2812 Some(&order),
2813 instrument_id,
2814 AccountId::from("IB-001"),
2815 &provider,
2816 UnixNanos::new(0),
2817 )
2818 .unwrap();
2819
2820 assert_eq!(report.trigger_price, Some(Price::from("4.8125")));
2821 assert_eq!(
2822 report.trailing_offset,
2823 Some(Decimal::from_str("0.0525").unwrap())
2824 );
2825 assert_eq!(report.trailing_offset_type, Some(TrailingOffsetType::Price));
2826 }
2827}