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::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()
221 * instrument.multiplier().as_decimal()
222 * Decimal::from(instrument_provider.get_price_magnifier(&instrument_id));
223 let commission_px = lot.commission / units;
224 let venue_avg_px_expected = if report.signed_decimal_qty.is_sign_positive() {
225 lot.avg_px + commission_px
226 } else {
227 lot.avg_px - commission_px
228 };
229
230 let tolerance = Decimal::new(1, u32::from(instrument.price_precision()));
232
233 if (venue_avg_px - venue_avg_px_expected).abs() <= tolerance {
234 report.avg_px_open = Some(lot.avg_px);
235 } else {
236 tracing::debug!(
237 "Keeping IB average cost {venue_avg_px} for {instrument_id}: fills give entry \
238 {} with average cost {venue_avg_px_expected}",
239 lot.avg_px,
240 );
241 }
242 }
243}
244
245fn signed_fill_qty(fill: &FillReport) -> Decimal {
246 let last_qty = fill.last_qty.as_decimal();
247
248 match fill.order_side {
249 OrderSide::Buy => last_qty,
250 OrderSide::Sell => -last_qty,
251 }
252}
253
254struct EntryLot {
257 avg_px: Decimal,
258 commission: Decimal,
259}
260
261fn entry_after_window(pre_window_qty: Decimal, fills: &[&FillReport]) -> Option<EntryLot> {
269 let mut quantity = pre_window_qty;
270 let mut lot: Option<EntryLot> = None;
271
272 for group in fills.chunk_by(|a, b| a.ts_event == b.ts_event) {
273 if group.len() > 1 {
274 let group_qty: Decimal = group.iter().map(|fill| signed_fill_qty(fill)).sum();
275 let end_qty = quantity + group_qty;
276 let mixes_sides = group
277 .iter()
278 .any(|fill| fill.order_side != group[0].order_side);
279 let flips = !quantity.is_zero()
280 && !end_qty.is_zero()
281 && quantity.is_sign_negative() != end_qty.is_sign_negative();
282
283 if mixes_sides || flips {
284 quantity = end_qty;
285 lot = None;
286 continue;
287 }
288 }
289
290 for fill in group {
291 let last_qty = fill.last_qty.as_decimal();
292 let last_px = fill.last_px.as_decimal();
293 let commission = fill.commission.as_decimal();
294 let signed_qty = signed_fill_qty(fill);
295 let open_qty = quantity.abs();
296
297 if quantity.is_zero() {
298 lot = Some(EntryLot {
299 avg_px: last_px,
300 commission,
301 });
302 } else if quantity.is_sign_negative() == signed_qty.is_sign_negative() {
303 lot = lot.map(|lot| EntryLot {
304 avg_px: (lot.avg_px * open_qty + last_px * last_qty) / (open_qty + last_qty),
305 commission: lot.commission + commission,
306 });
307 } else if last_qty > open_qty {
308 lot = Some(EntryLot {
309 avg_px: last_px,
310 commission: Decimal::ZERO,
311 });
312 } else if last_qty < open_qty {
313 lot = lot.map(|lot| EntryLot {
314 avg_px: lot.avg_px,
315 commission: lot.commission * (open_qty - last_qty) / open_qty,
316 });
317 }
318
319 quantity += signed_qty;
320 }
321 }
322
323 if quantity.is_zero() {
324 return None;
325 }
326
327 lot
328}
329
330pub(crate) fn is_combo_execution(exec_data: &ibapi::orders::ExecutionData) -> bool {
335 matches!(
336 exec_data.contract.security_type,
337 ibapi::contracts::SecurityType::Spread
338 )
339}
340
341pub(crate) fn link_order_contingencies(
342 reports: &mut [OrderStatusReport],
343 orders: &[ibapi::orders::Order],
344) {
345 debug_assert_eq!(reports.len(), orders.len());
346
347 let order_ids: Vec<ClientOrderId> = reports
348 .iter()
349 .map(|report| {
350 report
351 .client_order_id
352 .unwrap_or_else(|| ClientOrderId::from(report.venue_order_id.as_str()))
353 })
354 .collect();
355
356 let mut oca_groups: AHashMap<(&str, &str), Vec<usize>> = AHashMap::new();
357
358 for (index, order) in orders.iter().enumerate() {
359 if !order.oca_group.is_empty() {
360 oca_groups
361 .entry((order.account.as_str(), order.oca_group.as_str()))
362 .or_default()
363 .push(index);
364 }
365 }
366
367 let mut children: AHashMap<usize, Vec<usize>> = AHashMap::new();
368
369 for (index, order) in orders.iter().enumerate() {
370 let parent = orders.iter().position(|candidate| {
371 order
372 .parent_perm_id
373 .is_some_and(|perm_id| perm_id != 0 && candidate.perm_id == perm_id)
374 || (order.parent_id != 0
375 && candidate.order_id == order.parent_id
376 && candidate.client_id == order.client_id)
377 });
378
379 if let Some(parent) = parent.filter(|parent| *parent != index) {
380 reports[index].parent_order_id = Some(order_ids[parent]);
381 children.entry(parent).or_default().push(index);
382 }
383 }
384
385 for (index, order) in orders.iter().enumerate() {
386 if let Some(members) = oca_groups.get(&(order.account.as_str(), order.oca_group.as_str())) {
387 let contingency_type = match order.oca_type {
388 ibapi::orders::OcaType::CancelWithBlock => Some(ContingencyType::Oco),
389 ibapi::orders::OcaType::ReduceWithBlock
390 | ibapi::orders::OcaType::ReduceWithoutBlock => Some(ContingencyType::Ouo),
391 _ => None,
392 };
393
394 let linked: Vec<ClientOrderId> = members
395 .iter()
396 .filter(|member| **member != index)
397 .map(|member| order_ids[*member])
398 .collect();
399
400 if let Some(contingency_type) = contingency_type
401 && !linked.is_empty()
402 {
403 reports[index].contingency_type = Some(contingency_type);
404 reports[index].linked_order_ids = Some(linked);
405 continue;
406 }
407 }
408
409 if let Some(child_indices) = children.get(&index) {
410 reports[index].contingency_type = Some(ContingencyType::Oto);
411 reports[index].linked_order_ids = Some(
412 child_indices
413 .iter()
414 .map(|child| order_ids[*child])
415 .collect(),
416 );
417 }
418 }
419}
420
421pub(crate) fn should_use_avg_fill_price(avg_fill_price: f64, instrument_id: &InstrumentId) -> bool {
422 avg_fill_price.is_finite()
423 && avg_fill_price != f64::MAX
424 && avg_fill_price != 0.0
425 && (avg_fill_price > 0.0 || is_spread_instrument_id(instrument_id))
426}
427
428pub(crate) fn ib_venue_order_id(order_id: i32, perm_id: i64) -> VenueOrderId {
429 if perm_id != 0 {
430 VenueOrderId::new(format!("PERM-{perm_id}"))
431 } else {
432 VenueOrderId::new(order_id.to_string())
433 }
434}
435
436pub(crate) fn normalized_order_ref(order_ref: &str) -> Option<&str> {
437 if order_ref.is_empty() {
438 return None;
439 }
440
441 Some(
442 order_ref
443 .rsplit_once(':')
444 .map_or(order_ref, |(base, _)| base),
445 )
446}
447
448#[allow(clippy::too_many_arguments)]
459pub fn parse_execution_to_fill_report(
460 execution: &Execution,
461 _contract: &ibapi::contracts::Contract,
462 commission: f64,
463 commission_currency: &str,
464 instrument_id: InstrumentId,
465 account_id: AccountId,
466 instrument_provider: &InteractiveBrokersInstrumentProvider,
467 ts_init: UnixNanos,
468 avg_px: Option<Price>,
469) -> anyhow::Result<FillReport> {
470 let price_magnifier = instrument_provider.get_price_magnifier(&instrument_id) as f64;
472
473 let execution_price = execution.price * price_magnifier;
475
476 let order_side = IbAction::from_str(execution.side.as_str())?.order_side();
478
479 let instrument = instrument_provider
481 .find(&instrument_id)
482 .context("Instrument not found")?;
483
484 let last_qty = Quantity::new(execution.shares, instrument.size_precision());
486 let last_px = Price::new(execution_price, instrument.price_precision());
487
488 let commission_clamped = if commission == -1.0 { 0.0 } else { commission };
490 let commission_money = Money::new(commission_clamped, Currency::from_str(commission_currency)?);
491
492 let ts_event = parse_execution_time(&execution.time)?;
494
495 let trade_id = TradeId::new(&execution.execution_id);
497
498 let venue_order_id = ib_venue_order_id(execution.order_id, execution.perm_id);
499
500 let client_order_id = normalized_order_ref(&execution.order_reference).map(ClientOrderId::new);
501 let liquidity_side = execution_liquidity_side(&execution.last_liquidity);
502
503 let mut report = FillReport::new(
504 account_id,
505 instrument_id,
506 venue_order_id,
507 trade_id,
508 order_side,
509 last_qty,
510 last_px,
511 commission_money,
512 liquidity_side,
513 client_order_id,
514 None, ts_event,
516 ts_init,
517 Some(nautilus_core::UUID4::new()),
518 );
519 report.avg_px = avg_px.map(|price: Price| price.as_decimal());
520
521 Ok(report)
522}
523
524fn execution_liquidity_side(liquidity: &Liquidity) -> LiquiditySide {
525 match liquidity {
526 Liquidity::AddedLiquidity => LiquiditySide::Maker,
527 Liquidity::RemovedLiquidity => LiquiditySide::Taker,
528 Liquidity::None => LiquiditySide::NoLiquiditySide,
529 Liquidity::LiquidityRoutedOut => {
530 tracing::warn!(
531 "IB execution liquidity was routed out and has no maker/taker representation"
532 );
533 LiquiditySide::NoLiquiditySide
534 }
535 Liquidity::Unknown(code) => {
536 tracing::warn!("IB execution used unknown liquidity code {code}");
537 LiquiditySide::NoLiquiditySide
538 }
539 }
540}
541
542pub fn parse_order_status_to_report(
548 order_status: &OrderStatus,
549 order: Option<&ibapi::orders::Order>,
550 instrument_id: InstrumentId,
551 account_id: AccountId,
552 instrument_provider: &InteractiveBrokersInstrumentProvider,
553 ts_init: UnixNanos,
554) -> anyhow::Result<OrderStatusReport> {
555 let price_magnifier = instrument_provider.get_price_magnifier(&instrument_id) as f64;
557
558 let mut nautilus_status = match IbOrderStatus::from_str(order_status.status.as_str()) {
559 Ok(status) => status.nautilus_status(),
560 _ => {
561 tracing::warn!(
562 "Unknown order status: {}, defaulting to SUBMITTED",
563 order_status.status.as_str()
564 );
565 NautilusOrderStatus::Submitted
566 }
567 };
568
569 let order_side = if let Some(order) = order {
571 IbAction::from(order.action).order_side()
572 } else {
573 OrderSide::Buy
575 };
576
577 let instrument = instrument_provider.find(&instrument_id);
578
579 let size_precision = instrument
581 .as_ref()
582 .map_or(0, |instr| instr.size_precision());
583 let price_precision = instrument
584 .as_ref()
585 .map_or(0, |instr| instr.price_precision());
586
587 let total_quantity = order.map_or(0.0, |order| order.total_quantity);
589
590 let quantity = Quantity::new(
591 total_quantity.max(order_status.filled + order_status.remaining),
592 size_precision,
593 );
594
595 let filled_qty = Quantity::new(order_status.filled, size_precision);
597
598 let average_fill_price = order_status.average_fill_price.unwrap_or(0.0);
600 let include_avg_px = should_use_avg_fill_price(average_fill_price, &instrument_id);
601 let avg_px_value = if include_avg_px {
602 average_fill_price * price_magnifier
603 } else {
604 0.0
605 };
606
607 if order_status.filled > 0.0
608 && (order_status.remaining > 0.0
609 || order.is_some_and(|order| order.total_quantity > order_status.filled))
610 {
611 nautilus_status = NautilusOrderStatus::PartiallyFilled;
612 }
613
614 let venue_order_id = ib_venue_order_id(order_status.order_id, order_status.perm_id);
615
616 let client_order_id = order
617 .and_then(|order| normalized_order_ref(&order.order_ref))
618 .map(ClientOrderId::new);
619
620 let order_type = order.map_or(OrderType::Market, |order| {
622 map_ib_order_type(&order.order_type, order.limit_price)
623 });
624
625 let expire_time = order
628 .filter(|order| !order.good_till_date.is_empty())
629 .and_then(|order| match parse_execution_time(&order.good_till_date) {
630 Ok(expire_time) => Some(expire_time),
631 Err(e) => {
632 tracing::warn!(
633 "Reporting IB order {} as GTC: cannot parse goodTillDate: {e}",
634 order_status.order_id
635 );
636 None
637 }
638 });
639
640 let time_in_force = if let Some(order) = order {
642 let ib_time_in_force = IbTimeInForce::from(order.tif.clone());
643
644 if expire_time.is_some() {
645 TimeInForce::Gtd
646 } else if ib_time_in_force == IbTimeInForce::GoodTilDate {
647 TimeInForce::Gtc
648 } else {
649 ib_time_in_force.nautilus_time_in_force()
650 }
651 } else {
652 TimeInForce::Day };
654
655 let mut report = OrderStatusReport::new(
657 account_id,
658 instrument_id,
659 client_order_id,
660 venue_order_id,
661 order_side.into(),
662 order_type,
663 time_in_force,
664 nautilus_status,
665 quantity,
666 filled_qty,
667 ts_init, ts_init, ts_init,
670 Some(nautilus_core::UUID4::new()), );
672
673 if let Some(expire_time) = expire_time {
674 report = report.with_expire_time(expire_time);
675 }
676
677 if let Some(order) = order {
679 if let Some(limit_price) = order.limit_price
682 && matches!(
683 order_type,
684 OrderType::Limit
685 | OrderType::StopLimit
686 | OrderType::LimitIfTouched
687 | OrderType::TrailingStopLimit
688 | OrderType::MarketToLimit
689 )
690 {
691 let converted = limit_price * price_magnifier;
692 report = report.with_price(Price::new(converted, price_precision));
693 }
694
695 let (trigger_price, limit_offset, trailing_offset, trailing_offset_type) =
696 parse_ib_order_pricing_fields(order, order_type, price_magnifier, price_precision)?;
697
698 if let Some(trigger_price) = trigger_price {
699 report = report.with_trigger_price(trigger_price);
700 }
701
702 if matches!(
703 order_type,
704 OrderType::LimitIfTouched
705 | OrderType::MarketIfTouched
706 | OrderType::StopLimit
707 | OrderType::StopMarket
708 | OrderType::TrailingStopLimit
709 | OrderType::TrailingStopMarket
710 ) {
711 report =
712 report.with_trigger_type(ib_trigger_method_to_trigger_type(order.trigger_method));
713 }
714
715 if let Some(limit_offset) = limit_offset {
716 report = report.with_limit_offset(limit_offset);
717 }
718
719 if let Some(trailing_offset) = trailing_offset {
720 report = report.with_trailing_offset(trailing_offset);
721 }
722
723 if let Some(trailing_offset_type) = trailing_offset_type {
724 report = report.with_trailing_offset_type(trailing_offset_type);
725 }
726 }
727
728 if include_avg_px {
729 report = report.with_avg_px(decimal_from_f64(avg_px_value)?);
730 }
731
732 if let Some(display_size) = order
733 .and_then(|order| order.display_size)
734 .filter(|size| *size > 0 && f64::from(*size) < quantity.as_f64())
735 {
736 report = report.with_display_qty(Quantity::new(f64::from(display_size), size_precision));
737 }
738
739 Ok(report)
740}
741
742fn map_ib_order_type(order_type: &str, limit_price: Option<f64>) -> OrderType {
743 if order_type == "IBALGO" && limit_price.is_some_and(|price| price != 0.0) {
744 OrderType::Limit
745 } else {
746 IbOrderType::from_str(order_type)
747 .map_or(OrderType::Market, IbOrderType::nautilus_order_type)
748 }
749}
750
751#[allow(clippy::type_complexity)] fn parse_ib_order_pricing_fields(
753 order: &ibapi::orders::Order,
754 order_type: OrderType,
755 price_magnifier: f64,
756 price_precision: u8,
757) -> anyhow::Result<(
758 Option<Price>,
759 Option<Decimal>,
760 Option<Decimal>,
761 Option<TrailingOffsetType>,
762)> {
763 let mut trigger_price = None;
764 let mut limit_offset = None;
765 let mut trailing_offset = None;
766 let mut trailing_offset_type = None;
767
768 if matches!(
769 order_type,
770 OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
771 ) {
772 if let Some(trail_stop_price) = order.trail_stop_price {
773 trigger_price = Some(Price::new(
774 trail_stop_price * price_magnifier,
775 price_precision,
776 ));
777 }
778
779 if let Some(aux_price) = order.aux_price {
780 trailing_offset = Some(decimal_from_f64(aux_price)?);
781 trailing_offset_type = Some(TrailingOffsetType::Price);
782 } else if let Some(trailing_percent) = order.trailing_percent {
783 trailing_offset = Some(decimal_from_f64(trailing_percent)? * Decimal::from(100));
784 trailing_offset_type = Some(TrailingOffsetType::BasisPoints);
785 }
786
787 if order_type == OrderType::TrailingStopLimit
788 && let Some(limit_price_offset) = order.limit_price_offset
789 {
790 limit_offset = Some(decimal_from_f64(limit_price_offset)?);
791 trailing_offset_type = Some(trailing_offset_type.unwrap_or(TrailingOffsetType::Price));
792 }
793
794 return Ok((
795 trigger_price,
796 limit_offset,
797 trailing_offset,
798 trailing_offset_type,
799 ));
800 }
801
802 if let Some(aux_price) = order.aux_price {
803 trigger_price = Some(Price::new(aux_price * price_magnifier, price_precision));
804 }
805
806 Ok((
807 trigger_price,
808 limit_offset,
809 trailing_offset,
810 trailing_offset_type,
811 ))
812}
813
814fn decimal_from_f64(value: f64) -> anyhow::Result<Decimal> {
815 Decimal::from_str(&value.to_string())
816 .with_context(|| format!("Failed to convert IB floating-point value {value} to Decimal"))
817}
818
819pub fn parse_execution_time(time_str: &str) -> anyhow::Result<UnixNanos> {
843 const NAIVE_FORMAT: &str = "%Y%m%d %H:%M:%S";
844
845 if !time_str.contains(' ') {
847 let normalized = time_str.replace('-', " ");
848
849 let dt = DateTime::strptime(NAIVE_FORMAT, &normalized).map_err(|e| {
850 anyhow::anyhow!("Failed to parse execution timestamp '{time_str}': {e}")
851 })?;
852
853 return datetime_to_unix_nanos(Offset::UTC.to_timestamp(dt)?, time_str);
854 }
855
856 let mut parts = time_str.splitn(3, ' ');
860
861 let (Some(date), Some(time)) = (parts.next(), parts.next()) else {
862 anyhow::bail!("Invalid execution time format: {time_str}");
863 };
864
865 let tz_str = parts.next().unwrap_or("").trim();
866
867 let naive_str = format!("{date} {time}");
868 let dt = DateTime::strptime(NAIVE_FORMAT, &naive_str)
869 .map_err(|e| anyhow::anyhow!("Failed to parse execution timestamp '{time_str}': {e}"))?;
870
871 let utc = if tz_str.is_empty() {
872 Offset::UTC.to_timestamp(dt)?
873 } else {
874 localize_with_zone(dt, tz_str, time_str)?
875 };
876
877 datetime_to_unix_nanos(utc, time_str)
878}
879
880fn localize_with_zone(dt: DateTime, tz_str: &str, time_str: &str) -> anyhow::Result<Timestamp> {
885 let tz_name = if tz_str.eq_ignore_ascii_case("Z") {
886 "UTC"
887 } else {
888 tz_str
889 };
890
891 let zone = get_timezone(tz_name).map_err(|_| {
892 anyhow::anyhow!(
893 "Unrecognized execution timezone '{tz_str}' in '{time_str}'. Configure TWS / IB Gateway to emit a standard timezone (e.g. UTC)"
894 )
895 })?;
896
897 let ambiguous = zone.to_ambiguous_timestamp(dt);
898 match ambiguous.offset() {
899 AmbiguousOffset::Unambiguous { .. } => Ok(ambiguous.unambiguous()?),
900 AmbiguousOffset::Fold { .. } => Ok(ambiguous.earlier()?),
902 AmbiguousOffset::Gap { .. } => {
903 anyhow::bail!("Execution timestamp '{time_str}' is non-existent in timezone '{tz_str}'")
904 }
905 }
906}
907
908fn datetime_to_unix_nanos(dt: Timestamp, time_str: &str) -> anyhow::Result<UnixNanos> {
909 let nanos: u64 = dt
910 .as_nanosecond()
911 .try_into()
912 .map_err(|_| anyhow::anyhow!("Execution timestamp '{time_str}' was before Unix epoch"))?;
913 Ok(UnixNanos::new(nanos))
914}
915
916#[cfg(test)]
917mod tests {
918 use ibapi::{
919 contracts::Contract,
920 orders::{Action, ExecutionSide, Liquidity, Order, OrderStatusKind},
921 };
922 use nautilus_model::{
923 enums::{PositionSide, TrailingOffsetType, TriggerType},
924 identifiers::{Symbol, Venue},
925 instruments::{InstrumentAny, stubs::equity_aapl},
926 };
927 use rust_decimal::Decimal;
928
929 use super::*;
930 use crate::{
931 config::InteractiveBrokersInstrumentProviderConfig,
932 providers::instruments::InteractiveBrokersInstrumentProvider,
933 };
934
935 fn create_test_instrument_provider() -> InteractiveBrokersInstrumentProvider {
936 let config = InteractiveBrokersInstrumentProviderConfig::default();
937 InteractiveBrokersInstrumentProvider::new(config)
938 }
939
940 fn create_test_instrument_id() -> InstrumentId {
941 InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"))
942 }
943
944 use rstest::rstest;
945
946 fn create_test_avg_px_provider() -> InteractiveBrokersInstrumentProvider {
947 let provider = InteractiveBrokersInstrumentProvider::new(
948 crate::config::InteractiveBrokersInstrumentProviderConfig::default(),
949 );
950 provider.insert_test_instrument(InstrumentAny::Equity(equity_aapl()), 265_598, 1);
951 provider
952 }
953
954 fn create_test_entry_fill(
957 side: OrderSide,
958 qty: &str,
959 px: &str,
960 trade_id: &str,
961 ts_event: u64,
962 ) -> FillReport {
963 FillReport::new(
964 AccountId::from("IB-DU001"),
965 equity_aapl().id,
966 VenueOrderId::new(format!("PERM-{trade_id}")),
967 TradeId::new(trade_id),
968 side,
969 Quantity::from(qty),
970 Price::from(px),
971 Money::from("1.00 USD"),
972 LiquiditySide::Taker,
973 None,
974 None,
975 UnixNanos::from(ts_event),
976 UnixNanos::default(),
977 None,
978 )
979 }
980
981 fn create_test_entry_position_report(
982 side: PositionSide,
983 qty: &str,
984 venue_avg_px: &str,
985 ) -> PositionStatusReport {
986 PositionStatusReport::new(
987 AccountId::from("IB-DU001"),
988 equity_aapl().id,
989 side,
990 Quantity::from(qty),
991 UnixNanos::default(),
992 UnixNanos::default(),
993 None,
994 None,
995 Some(Decimal::from_str(venue_avg_px).unwrap()),
996 )
997 }
998
999 fn rebuild_avg_px_open(
1000 position_report: PositionStatusReport,
1001 evidence: &FillEvidence,
1002 ) -> Option<Decimal> {
1003 let mut position_reports = vec![position_report];
1004 fill_position_avg_px_from_fills(
1005 &mut position_reports,
1006 evidence,
1007 &create_test_avg_px_provider(),
1008 );
1009 position_reports[0].avg_px_open
1010 }
1011
1012 #[rstest]
1013 #[case::fills_open_position(
1016 PositionSide::Long,
1017 vec![(OrderSide::Buy, "7", "229.67")],
1018 "229.81",
1019 "229.67"
1020 )]
1021 #[case::fills_average_in(
1022 PositionSide::Long,
1023 vec![(OrderSide::Buy, "5", "229.00"), (OrderSide::Buy, "2", "232.50")],
1024 "230.29",
1025 "230.00"
1026 )]
1027 #[case::reduce_keeps_entry(
1028 PositionSide::Long,
1029 vec![(OrderSide::Buy, "4", "229.00"), (OrderSide::Buy, "4", "231.00"), (OrderSide::Sell, "1", "235.00")],
1030 "230.25",
1031 "230.00"
1032 )]
1033 #[case::flip_carries_no_commission(
1034 PositionSide::Short,
1035 vec![(OrderSide::Buy, "2", "230.00"), (OrderSide::Sell, "8", "228.00"), (OrderSide::Sell, "1", "221.00")],
1036 "226.86",
1037 "227.00"
1038 )]
1039 #[case::fills_partial_history(
1040 PositionSide::Long,
1041 vec![(OrderSide::Buy, "2", "230.00")],
1042 "229.81",
1043 "229.81"
1044 )]
1045 #[case::remnant_closed_then_reopened(
1046 PositionSide::Long,
1047 vec![(OrderSide::Sell, "7", "229.00"), (OrderSide::Buy, "7", "231.00")],
1048 "231.14",
1049 "231.00"
1050 )]
1051 #[case::remnant_flipped_in_window(
1052 PositionSide::Short,
1053 vec![(OrderSide::Sell, "7", "228.00"), (OrderSide::Sell, "1", "221.00")],
1054 "226.86",
1055 "227.00"
1056 )]
1057 #[case::remnant_reduced_in_window(
1058 PositionSide::Long,
1059 vec![(OrderSide::Sell, "2", "235.00")],
1060 "229.81",
1061 "229.81"
1062 )]
1063 #[case::no_fills(PositionSide::Long, vec![], "229.81", "229.81")]
1064 #[case::average_cost_disagrees(
1067 PositionSide::Long,
1068 vec![(OrderSide::Buy, "7", "229.67")],
1069 "230.50",
1070 "230.50"
1071 )]
1072 fn test_fill_position_avg_px_from_fills(
1073 #[case] position_side: PositionSide,
1074 #[case] fills: Vec<(OrderSide, &str, &str)>,
1075 #[case] venue_avg_px: &str,
1076 #[case] expected: &str,
1077 ) {
1078 let other_fill = FillReport::new(
1079 AccountId::from("IB-DU001"),
1080 InstrumentId::from("MSFT.NASDAQ"),
1081 VenueOrderId::new("PERM-9"),
1082 TradeId::new("T-OTHER"),
1083 OrderSide::Sell,
1084 Quantity::from(7),
1085 Price::from("150.00"),
1086 Money::from("1.00 USD"),
1087 LiquiditySide::Taker,
1088 None,
1089 None,
1090 UnixNanos::from(1),
1091 UnixNanos::default(),
1092 None,
1093 );
1094 let mut reports: Vec<_> = fills
1095 .into_iter()
1096 .enumerate()
1097 .rev()
1098 .map(|(index, (side, qty, px))| {
1099 create_test_entry_fill(side, qty, px, &format!("T-{index}"), index as u64 + 10)
1100 })
1101 .collect();
1102 reports.push(other_fill);
1103 let evidence = FillEvidence {
1104 reports,
1105 ..Default::default()
1106 };
1107
1108 let avg_px_open = rebuild_avg_px_open(
1109 create_test_entry_position_report(position_side, "7", venue_avg_px),
1110 &evidence,
1111 );
1112
1113 assert_eq!(avg_px_open, Some(Decimal::from_str(expected).unwrap()));
1114 }
1115
1116 #[rstest]
1117 fn test_fill_position_avg_px_from_fills_keeps_venue_average_for_other_commission_currency() {
1118 let mut fill = create_test_entry_fill(OrderSide::Buy, "7", "229.67", "T-1", 10);
1119 fill.commission = Money::from("1.00 EUR");
1120 let evidence = FillEvidence {
1121 reports: vec![fill],
1122 ..Default::default()
1123 };
1124
1125 let avg_px_open = rebuild_avg_px_open(
1126 create_test_entry_position_report(PositionSide::Long, "7", "229.81"),
1127 &evidence,
1128 );
1129
1130 assert_eq!(avg_px_open, Some(Decimal::from_str("229.81").unwrap()));
1131 }
1132
1133 #[rstest]
1134 #[case::instrument_missing(false, vec![equity_aapl().id], "101.00")]
1138 #[case::all_missing(true, vec![], "101.00")]
1139 #[case::other_instrument_missing(false, vec![InstrumentId::from("MSFT.NASDAQ")], "100.00")]
1140 fn test_fill_position_avg_px_from_fills_keeps_venue_average_for_missing_executions(
1141 #[case] missing_all: bool,
1142 #[case] missing_instruments: Vec<InstrumentId>,
1143 #[case] expected: &str,
1144 ) {
1145 let evidence = FillEvidence {
1146 reports: vec![create_test_entry_fill(
1147 OrderSide::Buy,
1148 "1",
1149 "100.00",
1150 "T-1",
1151 10,
1152 )],
1153 combo_leg_reports: Vec::new(),
1154 missing_all,
1155 missing_instruments: missing_instruments.into_iter().collect(),
1156 };
1157
1158 let avg_px_open = rebuild_avg_px_open(
1159 create_test_entry_position_report(PositionSide::Long, "1", "101.00"),
1160 &evidence,
1161 );
1162
1163 assert_eq!(avg_px_open, Some(Decimal::from_str(expected).unwrap()));
1164 }
1165
1166 #[rstest]
1167 #[case::opposite_sides(
1172 PositionSide::Long,
1173 "2",
1174 vec![(OrderSide::Buy, "2", "110.00"), (OrderSide::Sell, "2", "120.00")],
1175 vec![(OrderSide::Buy, "1", "100.00")],
1176 "105.50",
1177 "105.50"
1178 )]
1179 #[case::same_side_flip(
1180 PositionSide::Long,
1181 "2",
1182 vec![(OrderSide::Buy, "1", "100.00"), (OrderSide::Buy, "2", "110.00")],
1183 vec![],
1184 "110.50",
1185 "110.50"
1186 )]
1187 #[case::same_side_from_flat(
1188 PositionSide::Long,
1189 "2",
1190 vec![(OrderSide::Buy, "1", "100.00"), (OrderSide::Buy, "1", "110.00")],
1191 vec![],
1192 "106.00",
1193 "105.00"
1194 )]
1195 #[case::reopened_after_ambiguous_group(
1196 PositionSide::Long,
1197 "1",
1198 vec![(OrderSide::Buy, "1", "100.00"), (OrderSide::Sell, "1", "105.00")],
1199 vec![(OrderSide::Buy, "1", "110.00")],
1200 "111.00",
1201 "110.00"
1202 )]
1203 fn test_fill_position_avg_px_from_fills_same_timestamp_fills_ignore_delivery_order(
1204 #[case] position_side: PositionSide,
1205 #[case] quantity: &str,
1206 #[case] tied: Vec<(OrderSide, &str, &str)>,
1207 #[case] later: Vec<(OrderSide, &str, &str)>,
1208 #[case] venue_avg_px: &str,
1209 #[case] expected: &str,
1210 #[values(false, true)] reversed: bool,
1211 ) {
1212 let mut reports: Vec<_> = tied
1213 .into_iter()
1214 .enumerate()
1215 .map(|(index, (side, qty, px))| {
1216 create_test_entry_fill(side, qty, px, &format!("T-{index}"), 10)
1217 })
1218 .collect();
1219
1220 if reversed {
1221 reports.reverse();
1222 }
1223
1224 reports.extend(
1225 later
1226 .into_iter()
1227 .enumerate()
1228 .map(|(index, (side, qty, px))| {
1229 create_test_entry_fill(side, qty, px, &format!("L-{index}"), 20 + index as u64)
1230 }),
1231 );
1232 let evidence = FillEvidence {
1233 reports,
1234 ..Default::default()
1235 };
1236
1237 let avg_px_open = rebuild_avg_px_open(
1238 create_test_entry_position_report(position_side, quantity, venue_avg_px),
1239 &evidence,
1240 );
1241
1242 assert_eq!(avg_px_open, Some(Decimal::from_str(expected).unwrap()));
1243 }
1244
1245 #[rstest]
1246 #[case::with_combo_legs(true, "4.00")]
1247 #[case::without_combo_legs(false, "3.00")]
1248 fn test_fill_position_avg_px_from_fills_counts_combo_leg_fills(
1249 #[case] with_combo_legs: bool,
1250 #[case] expected: &str,
1251 ) {
1252 let combo_leg_reports = if with_combo_legs {
1257 vec![create_test_entry_fill(
1258 OrderSide::Buy,
1259 "2",
1260 "4.00",
1261 "T-3",
1262 30,
1263 )]
1264 } else {
1265 Vec::new()
1266 };
1267 let evidence = FillEvidence {
1268 reports: vec![
1269 create_test_entry_fill(OrderSide::Sell, "2", "5.00", "T-1", 10),
1270 create_test_entry_fill(OrderSide::Buy, "1", "3.00", "T-2", 20),
1271 ],
1272 combo_leg_reports,
1273 ..Default::default()
1274 };
1275
1276 let avg_px_open = rebuild_avg_px_open(
1277 create_test_entry_position_report(PositionSide::Long, "1", "4.00"),
1278 &evidence,
1279 );
1280
1281 assert_eq!(avg_px_open, Some(Decimal::from_str(expected).unwrap()));
1282 }
1283
1284 #[rstest]
1285 fn test_fill_missing_avg_px_uses_fill_reports() {
1286 let instrument_id = InstrumentId::from("ESZ6.XCME");
1287 let account_id = AccountId::from("IB-DU001");
1288
1289 let order_report = |venue_order_id: &str, avg_px: Option<Decimal>| {
1290 let mut report = OrderStatusReport::new(
1291 account_id,
1292 instrument_id,
1293 None,
1294 VenueOrderId::new(venue_order_id),
1295 Some(OrderSide::Buy),
1296 OrderType::Market,
1297 TimeInForce::Day,
1298 NautilusOrderStatus::Filled,
1299 Quantity::from(3),
1300 Quantity::from(3),
1301 UnixNanos::default(),
1302 UnixNanos::default(),
1303 UnixNanos::default(),
1304 None,
1305 );
1306 report.avg_px = avg_px;
1307 report
1308 };
1309
1310 let fill = |venue_order_id: &str, trade_id: &str, qty: u64, px: &str| {
1311 FillReport::new(
1312 account_id,
1313 instrument_id,
1314 VenueOrderId::new(venue_order_id),
1315 TradeId::new(trade_id),
1316 OrderSide::Buy,
1317 Quantity::from(qty),
1318 Price::from(px),
1319 Money::from("0.00 USD"),
1320 LiquiditySide::Taker,
1321 None,
1322 None,
1323 UnixNanos::default(),
1324 UnixNanos::default(),
1325 None,
1326 )
1327 };
1328
1329 let mut reports = vec![
1330 order_report("PERM-1", None),
1331 order_report("PERM-2", Some(Decimal::from(7))),
1332 order_report("PERM-3", None),
1333 ];
1334 let fills = vec![
1335 fill("PERM-1", "T-1", 1, "2.55"),
1336 fill("PERM-1", "T-2", 2, "2.40"),
1337 fill("PERM-2", "T-3", 3, "9.00"),
1338 ];
1339
1340 fill_missing_avg_px(&mut reports, &fills);
1341
1342 assert_eq!(reports[0].avg_px, Some(Decimal::from_str("2.45").unwrap()));
1343 assert_eq!(reports[1].avg_px, Some(Decimal::from(7)));
1344 assert_eq!(reports[2].avg_px, None);
1345 }
1346
1347 #[rstest]
1348 #[case::combo(ibapi::contracts::SecurityType::Spread, true)]
1349 #[case::leg(ibapi::contracts::SecurityType::FuturesOption, false)]
1350 fn test_is_combo_execution(
1351 #[case] security_type: ibapi::contracts::SecurityType,
1352 #[case] expected: bool,
1353 ) {
1354 let exec_data = ibapi::orders::ExecutionData {
1355 contract: ibapi::contracts::Contract {
1356 security_type,
1357 ..Default::default()
1358 },
1359 ..Default::default()
1360 };
1361
1362 assert_eq!(is_combo_execution(&exec_data), expected);
1363 }
1364
1365 #[rstest]
1366 fn test_ibalgo_with_zero_limit_price_maps_to_market() {
1367 assert_eq!(map_ib_order_type("IBALGO", Some(0.0)), OrderType::Market);
1368 }
1369
1370 #[rstest]
1371 fn test_parse_execution_time_hyphenated_format() {
1372 let time_str = "20250225-15:15:00";
1373 let result = parse_execution_time(time_str);
1374 assert!(result.is_ok());
1375 let timestamp = result.unwrap();
1376 assert!(timestamp.as_i64() > 0);
1377 }
1378
1379 #[rstest]
1380 fn test_parse_execution_time_with_met_timezone() {
1381 let met = parse_execution_time("20230223 00:43:36 MET").unwrap();
1384 let utc = parse_execution_time("20230223 00:43:36 Universal").unwrap();
1385 assert_eq!(
1387 met.as_i64(),
1388 utc.as_i64() - 3_600_000_000_000,
1389 "MET (CET) should be 1h ahead of UTC in February"
1390 );
1391 assert!(met.as_i64() > 0);
1392 }
1393
1394 #[rstest]
1395 fn test_parse_execution_time_applies_dst_for_regional_timezone() {
1396 let winter = parse_execution_time("20230223 00:43:36 America/New_York").unwrap();
1399 let summer = parse_execution_time("20230715 00:43:36 America/New_York").unwrap();
1400 let winter_utc = parse_execution_time("20230223 00:43:36 Universal").unwrap();
1401 let summer_utc = parse_execution_time("20230715 00:43:36 Universal").unwrap();
1402 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); }
1405
1406 #[rstest]
1407 fn test_parse_execution_time_dst_fall_back_fold_resolves_to_earliest() {
1408 let fold = parse_execution_time("20231105 01:30:00 America/Chicago").unwrap();
1412 assert_eq!(
1413 fold.as_i64(),
1414 parse_execution_time("20231105 06:30:00 Universal")
1415 .unwrap()
1416 .as_i64()
1417 );
1418 assert_ne!(
1419 fold.as_i64(),
1420 parse_execution_time("20231105 07:30:00 Universal")
1421 .unwrap()
1422 .as_i64()
1423 );
1424 }
1425
1426 #[rstest]
1427 fn test_parse_execution_time_dst_spring_forward_gap_errors() {
1428 let gap = parse_execution_time("20230312 02:30:00 America/Chicago");
1430 assert!(gap.is_err());
1431 }
1432
1433 #[rstest]
1434 fn test_parse_execution_time_fixed_offset_zone_without_dst() {
1435 let tokyo = parse_execution_time("20230223 00:43:36 Asia/Tokyo").unwrap();
1437 let utc = parse_execution_time("20230223 00:43:36 Universal").unwrap();
1438 assert_eq!(tokyo.as_i64(), utc.as_i64() - 9 * 3_600_000_000_000);
1439 }
1440
1441 #[rstest]
1442 fn test_parse_execution_time_with_unrecognized_timezone_errors() {
1443 let time_str = "20230223 00:43:36 Mars/Olympus";
1444 let result = parse_execution_time(time_str);
1445 assert!(result.is_err());
1446 }
1447
1448 #[rstest]
1449 fn test_parse_execution_time_utc() {
1450 let time_str = "20230223 00:43:36 Universal";
1451 let result = parse_execution_time(time_str);
1452 assert!(result.is_ok());
1453 let timestamp = result.unwrap();
1454 assert!(timestamp.as_i64() > 0);
1455 }
1456
1457 #[rstest]
1458 fn test_parse_execution_time_no_timezone_assumes_utc() {
1459 let time_str = "20230223 00:43:36";
1460 let result = parse_execution_time(time_str);
1461 assert!(result.is_ok());
1462 let timestamp = result.unwrap();
1463 assert!(timestamp.as_i64() > 0);
1464 }
1465
1466 #[rstest]
1467 fn test_parse_execution_time_invalid_format() {
1468 let time_str = "invalid format";
1469 let result = parse_execution_time(time_str);
1470 assert!(result.is_err());
1471 }
1472
1473 #[rstest]
1474 fn test_parse_execution_time_short_format() {
1475 let time_str = "20230223 00:43";
1476 let result = parse_execution_time(time_str);
1477 assert!(result.is_err());
1478 }
1479
1480 #[rstest]
1481 fn test_parse_order_status_to_report_submitted() {
1482 let instrument_provider = create_test_instrument_provider();
1483 let instrument_id = create_test_instrument_id();
1484 let account_id = AccountId::from("IB-001");
1485
1486 let order_status = OrderStatus {
1487 order_id: 12345,
1488 status: OrderStatusKind::Submitted,
1489 filled: 0.0,
1490 remaining: 100.0,
1491 average_fill_price: Some(0.0),
1492 perm_id: 0,
1493 parent_id: 0,
1494 last_fill_price: Some(0.0),
1495 client_id: 0,
1496 why_held: String::new(),
1497 market_cap_price: Some(0.0),
1498 };
1499
1500 let result = parse_order_status_to_report(
1501 &order_status,
1502 None,
1503 instrument_id,
1504 account_id,
1505 &instrument_provider,
1506 UnixNanos::new(0),
1507 );
1508
1509 if let Err(e) = result {
1511 let error_msg = e.to_string();
1512 assert!(
1513 error_msg.contains("not found") || error_msg.contains("instrument"),
1514 "Unexpected error: {error_msg}"
1515 );
1516 }
1517 }
1518
1519 #[rstest]
1520 fn test_parse_order_status_to_report_filled() {
1521 let instrument_provider = create_test_instrument_provider();
1522 let instrument_id = create_test_instrument_id();
1523 let account_id = AccountId::from("IB-001");
1524
1525 let order_status = OrderStatus {
1526 order_id: 12345,
1527 status: OrderStatusKind::Filled,
1528 filled: 100.0,
1529 remaining: 0.0,
1530 average_fill_price: Some(150.25),
1531 perm_id: 0,
1532 parent_id: 0,
1533 last_fill_price: Some(150.25),
1534 client_id: 0,
1535 why_held: String::new(),
1536 market_cap_price: Some(0.0),
1537 };
1538
1539 let result = parse_order_status_to_report(
1540 &order_status,
1541 None,
1542 instrument_id,
1543 account_id,
1544 &instrument_provider,
1545 UnixNanos::new(0),
1546 );
1547
1548 if let Err(e) = result {
1550 let error_msg = e.to_string();
1551 assert!(
1552 error_msg.contains("not found") || error_msg.contains("instrument"),
1553 "Unexpected error: {error_msg}"
1554 );
1555 }
1556 }
1557
1558 #[rstest]
1559 fn test_parse_order_status_to_report_spread_allows_negative_avg_fill_price() {
1560 let instrument_provider = create_test_instrument_provider();
1561
1562 let instrument_id = InstrumentId::new(
1563 Symbol::from("(1)SPY C400___((1))SPY C410"),
1564 Venue::from("SMART"),
1565 );
1566 let account_id = AccountId::from("IB-001");
1567
1568 let order_status = OrderStatus {
1569 order_id: 12345,
1570 status: OrderStatusKind::Filled,
1571 filled: 1.0,
1572 remaining: 0.0,
1573 average_fill_price: Some(-2.25),
1574 perm_id: 0,
1575 parent_id: 0,
1576 last_fill_price: Some(-2.25),
1577 client_id: 0,
1578 why_held: String::new(),
1579 market_cap_price: Some(0.0),
1580 };
1581
1582 let report = parse_order_status_to_report(
1583 &order_status,
1584 None,
1585 instrument_id,
1586 account_id,
1587 &instrument_provider,
1588 UnixNanos::new(0),
1589 )
1590 .unwrap();
1591
1592 assert_eq!(report.avg_px, Some(Decimal::from_str("-2.25").unwrap()));
1593 }
1594
1595 #[rstest]
1596 fn test_parse_order_status_to_report_inactive_maps_to_rejected() {
1597 let instrument_provider = create_test_instrument_provider();
1598 let instrument_id = create_test_instrument_id();
1599 let account_id = AccountId::from("IB-001");
1600
1601 let order_status = OrderStatus {
1602 order_id: 12345,
1603 status: OrderStatusKind::Inactive,
1604 filled: 0.0,
1605 remaining: 100.0,
1606 average_fill_price: Some(0.0),
1607 perm_id: 0,
1608 parent_id: 0,
1609 last_fill_price: Some(0.0),
1610 client_id: 0,
1611 why_held: String::new(),
1612 market_cap_price: Some(0.0),
1613 };
1614
1615 let report = parse_order_status_to_report(
1616 &order_status,
1617 None,
1618 instrument_id,
1619 account_id,
1620 &instrument_provider,
1621 UnixNanos::new(0),
1622 )
1623 .unwrap();
1624
1625 assert_eq!(report.order_status, NautilusOrderStatus::Rejected);
1626 }
1627
1628 #[rstest]
1629 fn test_parse_order_status_to_report_partial_fill_and_perm_fallback() {
1630 let instrument_provider = create_test_instrument_provider();
1631 let instrument_id = create_test_instrument_id();
1632 let account_id = AccountId::from("IB-001");
1633
1634 let order_status = OrderStatus {
1635 order_id: 0,
1636 status: OrderStatusKind::Submitted,
1637 filled: 3.0,
1638 remaining: 7.0,
1639 average_fill_price: Some(150.25),
1640 perm_id: 123_456,
1641 parent_id: 0,
1642 last_fill_price: Some(150.25),
1643 client_id: 0,
1644 why_held: String::new(),
1645 market_cap_price: Some(0.0),
1646 };
1647
1648 let order = Order {
1649 action: Action::Buy,
1650 total_quantity: 10.0,
1651 order_type: "LMT".to_string(),
1652 limit_price: Some(150.25),
1653 order_ref: "O-20260527-001:123".to_string(),
1654 ..Default::default()
1655 };
1656
1657 let report = parse_order_status_to_report(
1658 &order_status,
1659 Some(&order),
1660 instrument_id,
1661 account_id,
1662 &instrument_provider,
1663 UnixNanos::new(0),
1664 )
1665 .unwrap();
1666
1667 assert_eq!(report.order_status, NautilusOrderStatus::PartiallyFilled);
1668 assert_eq!(report.venue_order_id.to_string(), "PERM-123456");
1669 assert_eq!(
1670 report.client_order_id,
1671 Some(ClientOrderId::from("O-20260527-001"))
1672 );
1673 }
1674
1675 #[rstest]
1676 fn test_parse_order_status_to_report_recovers_completed_order_quantity() {
1677 let instrument_provider = create_test_instrument_provider();
1678 let instrument_id = create_test_instrument_id();
1679
1680 let order_status = OrderStatus {
1681 status: OrderStatusKind::Filled,
1682 filled: 3.0,
1683 remaining: 0.0,
1684 ..Default::default()
1685 };
1686
1687 let order = Order {
1688 total_quantity: 0.0,
1689 filled_quantity: 3.0,
1690 ..Default::default()
1691 };
1692
1693 let report = parse_order_status_to_report(
1694 &order_status,
1695 Some(&order),
1696 instrument_id,
1697 AccountId::from("IB-001"),
1698 &instrument_provider,
1699 UnixNanos::new(0),
1700 )
1701 .unwrap();
1702
1703 assert_eq!(report.quantity, Quantity::from(3));
1704 assert_eq!(report.filled_qty, Quantity::from(3));
1705 }
1706
1707 #[rstest]
1708 fn test_parse_order_status_to_report_sets_stop_trigger_type() {
1709 let instrument_provider = create_test_instrument_provider();
1710 let instrument_id = create_test_instrument_id();
1711
1712 let order_status = OrderStatus {
1713 status: OrderStatusKind::Cancelled,
1714 remaining: 1.0,
1715 ..Default::default()
1716 };
1717
1718 let order = Order {
1719 total_quantity: 1.0,
1720 order_type: "STP".to_string(),
1721 aux_price: Some(100.0),
1722 trigger_method: ibapi::orders::conditions::TriggerMethod::Last,
1723 ..Default::default()
1724 };
1725
1726 let report = parse_order_status_to_report(
1727 &order_status,
1728 Some(&order),
1729 instrument_id,
1730 AccountId::from("IB-001"),
1731 &instrument_provider,
1732 UnixNanos::new(0),
1733 )
1734 .unwrap();
1735
1736 assert_eq!(report.order_type, OrderType::StopMarket);
1737 assert_eq!(report.trigger_price, Some(Price::new(100.0, 0)));
1738 assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1739 }
1740
1741 #[rstest]
1742 #[case(
1743 "GTD",
1744 "20260925 20:00:00 UTC",
1745 TimeInForce::Gtd,
1746 Some(1_790_366_400_000_000_000)
1747 )]
1748 #[case(
1749 "GTC",
1750 "20260925-20:00:00",
1751 TimeInForce::Gtd,
1752 Some(1_790_366_400_000_000_000)
1753 )]
1754 #[case("GTD", "not a date", TimeInForce::Gtc, None)]
1755 #[case("DAY", "", TimeInForce::Day, None)]
1756 fn test_parse_order_status_to_report_sets_gtd_expire_time(
1757 #[case] tif: &str,
1758 #[case] good_till_date: &str,
1759 #[case] expected_time_in_force: TimeInForce,
1760 #[case] expected_expire_time: Option<u64>,
1761 ) {
1762 let instrument_provider = create_test_instrument_provider();
1763 let instrument_id = create_test_instrument_id();
1764
1765 let order_status = OrderStatus {
1766 status: OrderStatusKind::Filled,
1767 filled: 1.0,
1768 ..Default::default()
1769 };
1770
1771 let order = Order {
1772 total_quantity: 1.0,
1773 order_type: "LMT".to_string(),
1774 limit_price: Some(101.0),
1775 tif: ibapi::orders::TimeInForce::from(tif),
1776 good_till_date: good_till_date.to_string(),
1777 ..Default::default()
1778 };
1779
1780 let report = parse_order_status_to_report(
1781 &order_status,
1782 Some(&order),
1783 instrument_id,
1784 AccountId::from("IB-001"),
1785 &instrument_provider,
1786 UnixNanos::new(0),
1787 )
1788 .unwrap();
1789
1790 assert_eq!(report.time_in_force, expected_time_in_force);
1791 assert_eq!(
1792 report.expire_time,
1793 expected_expire_time.map(UnixNanos::from)
1794 );
1795 }
1796
1797 #[rstest]
1798 #[case("MKT", Some(0.0), OrderType::Market, None)]
1799 #[case("STP", Some(0.0), OrderType::StopMarket, None)]
1800 #[case("LMT", Some(101.0), OrderType::Limit, Some(Price::new(101.0, 0)))]
1801 fn test_parse_order_status_to_report_sets_price_only_for_limit_types(
1802 #[case] ib_order_type: &str,
1803 #[case] limit_price: Option<f64>,
1804 #[case] expected_order_type: OrderType,
1805 #[case] expected_price: Option<Price>,
1806 ) {
1807 let instrument_provider = create_test_instrument_provider();
1808 let instrument_id = create_test_instrument_id();
1809
1810 let order_status = OrderStatus {
1811 status: OrderStatusKind::Filled,
1812 filled: 1.0,
1813 ..Default::default()
1814 };
1815
1816 let order = Order {
1817 total_quantity: 1.0,
1818 order_type: ib_order_type.to_string(),
1819 limit_price,
1820 aux_price: Some(100.0),
1821 ..Default::default()
1822 };
1823
1824 let report = parse_order_status_to_report(
1825 &order_status,
1826 Some(&order),
1827 instrument_id,
1828 AccountId::from("IB-001"),
1829 &instrument_provider,
1830 UnixNanos::new(0),
1831 )
1832 .unwrap();
1833
1834 assert_eq!(report.order_type, expected_order_type);
1835 assert_eq!(report.price, expected_price);
1836 }
1837
1838 #[rstest]
1839 fn test_ib_venue_order_id_prefers_perm_id_and_falls_back_to_order_id() {
1840 assert_eq!(ib_venue_order_id(123, 456).to_string(), "PERM-456");
1841 assert_eq!(ib_venue_order_id(123, 0).to_string(), "123");
1842 }
1843
1844 #[rstest]
1845 fn test_normalized_order_ref_strips_ib_suffix() {
1846 assert_eq!(normalized_order_ref("O-001:123"), Some("O-001"));
1847 assert_eq!(normalized_order_ref("O-001"), Some("O-001"));
1848 assert_eq!(normalized_order_ref(""), None);
1849 }
1850
1851 #[rstest]
1852 #[case(Liquidity::AddedLiquidity, LiquiditySide::Maker)]
1853 #[case(Liquidity::RemovedLiquidity, LiquiditySide::Taker)]
1854 #[case(Liquidity::None, LiquiditySide::NoLiquiditySide)]
1855 #[case(Liquidity::LiquidityRoutedOut, LiquiditySide::NoLiquiditySide)]
1856 #[case(Liquidity::Unknown(4), LiquiditySide::NoLiquiditySide)]
1857 fn test_execution_liquidity_side(
1858 #[case] liquidity: Liquidity,
1859 #[case] expected: LiquiditySide,
1860 ) {
1861 assert_eq!(execution_liquidity_side(&liquidity), expected);
1862 }
1863
1864 struct PricingExpectation {
1865 order_type: OrderType,
1866 price: Option<Price>,
1867 trigger_price: Option<Price>,
1868 limit_offset: Option<Decimal>,
1869 trailing_offset: Option<Decimal>,
1870 trailing_offset_type: Option<TrailingOffsetType>,
1871 }
1872
1873 #[rstest]
1874 #[case(
1875 "MKT",
1876 None,
1877 None,
1878 None,
1879 None,
1880 PricingExpectation {
1881 order_type: OrderType::Market,
1882 price: None,
1883 trigger_price: None,
1884 limit_offset: None,
1885 trailing_offset: None,
1886 trailing_offset_type: None,
1887 }
1888 )]
1889 #[case(
1890 "LMT",
1891 Some(185.0),
1892 None,
1893 None,
1894 None,
1895 PricingExpectation {
1896 order_type: OrderType::Limit,
1897 price: Some(Price::new(185.0, 0)),
1898 trigger_price: None,
1899 limit_offset: None,
1900 trailing_offset: None,
1901 trailing_offset_type: None,
1902 }
1903 )]
1904 #[case(
1905 "IBALGO",
1906 Some(185.0),
1907 None,
1908 None,
1909 None,
1910 PricingExpectation {
1911 order_type: OrderType::Limit,
1912 price: Some(Price::new(185.0, 0)),
1913 trigger_price: None,
1914 limit_offset: None,
1915 trailing_offset: None,
1916 trailing_offset_type: None,
1917 }
1918 )]
1919 #[case(
1920 "IBALGO",
1921 None,
1922 None,
1923 None,
1924 None,
1925 PricingExpectation {
1926 order_type: OrderType::Market,
1927 price: None,
1928 trigger_price: None,
1929 limit_offset: None,
1930 trailing_offset: None,
1931 trailing_offset_type: None,
1932 }
1933 )]
1934 #[case(
1935 "MIT",
1936 None,
1937 Some(180.0),
1938 None,
1939 None,
1940 PricingExpectation {
1941 order_type: OrderType::MarketIfTouched,
1942 price: None,
1943 trigger_price: Some(Price::new(180.0, 0)),
1944 limit_offset: None,
1945 trailing_offset: None,
1946 trailing_offset_type: None,
1947 }
1948 )]
1949 #[case(
1950 "LIT",
1951 Some(179.0),
1952 Some(180.0),
1953 None,
1954 None,
1955 PricingExpectation {
1956 order_type: OrderType::LimitIfTouched,
1957 price: Some(Price::new(179.0, 0)),
1958 trigger_price: Some(Price::new(180.0, 0)),
1959 limit_offset: None,
1960 trailing_offset: None,
1961 trailing_offset_type: None,
1962 }
1963 )]
1964 #[case(
1965 "STP",
1966 None,
1967 Some(180.0),
1968 None,
1969 None,
1970 PricingExpectation {
1971 order_type: OrderType::StopMarket,
1972 price: None,
1973 trigger_price: Some(Price::new(180.0, 0)),
1974 limit_offset: None,
1975 trailing_offset: None,
1976 trailing_offset_type: None,
1977 }
1978 )]
1979 #[case(
1980 "STP LMT",
1981 Some(179.0),
1982 Some(180.0),
1983 None,
1984 None,
1985 PricingExpectation {
1986 order_type: OrderType::StopLimit,
1987 price: Some(Price::new(179.0, 0)),
1988 trigger_price: Some(Price::new(180.0, 0)),
1989 limit_offset: None,
1990 trailing_offset: None,
1991 trailing_offset_type: None,
1992 }
1993 )]
1994 #[case(
1995 "TRAIL LIMIT",
1996 None,
1997 Some(2.5),
1998 Some(185.0),
1999 Some(0.25),
2000 PricingExpectation {
2001 order_type: OrderType::TrailingStopLimit,
2002 price: None,
2003 trigger_price: Some(Price::new(185.0, 0)),
2004 limit_offset: Some(Decimal::from_str("0.25").unwrap()),
2005 trailing_offset: Some(Decimal::from_str("2.5").unwrap()),
2006 trailing_offset_type: Some(TrailingOffsetType::Price),
2007 },
2008 )]
2009 fn test_parse_order_status_to_report_maps_pricing_fields_by_order_type(
2010 #[case] ib_order_type: &str,
2011 #[case] limit_price: Option<f64>,
2012 #[case] aux_price: Option<f64>,
2013 #[case] trail_stop_price: Option<f64>,
2014 #[case] limit_price_offset: Option<f64>,
2015 #[case] expected: PricingExpectation,
2016 ) {
2017 let instrument_provider = create_test_instrument_provider();
2018 let instrument_id = create_test_instrument_id();
2019 let account_id = AccountId::from("IB-001");
2020
2021 let order_status = OrderStatus {
2022 order_id: 12345,
2023 status: OrderStatusKind::Submitted,
2024 filled: 0.0,
2025 remaining: 5.0,
2026 average_fill_price: Some(0.0),
2027 perm_id: 0,
2028 parent_id: 0,
2029 last_fill_price: Some(0.0),
2030 client_id: 0,
2031 why_held: String::new(),
2032 market_cap_price: Some(0.0),
2033 };
2034
2035 let order = Order {
2036 action: Action::Buy,
2037 total_quantity: 5.0,
2038 order_type: ib_order_type.to_string(),
2039 limit_price,
2040 aux_price,
2041 trail_stop_price,
2042 limit_price_offset,
2043 tif: ibapi::orders::TimeInForce::GoodTillCanceled,
2044 ..Default::default()
2045 };
2046
2047 let report = parse_order_status_to_report(
2048 &order_status,
2049 Some(&order),
2050 instrument_id,
2051 account_id,
2052 &instrument_provider,
2053 UnixNanos::new(0),
2054 )
2055 .unwrap();
2056
2057 assert_eq!(report.order_type, expected.order_type);
2058 assert_eq!(report.price, expected.price);
2059 assert_eq!(report.trigger_price, expected.trigger_price);
2060 assert_eq!(report.limit_offset, expected.limit_offset);
2061 assert_eq!(report.trailing_offset, expected.trailing_offset);
2062 assert_eq!(report.trailing_offset_type, expected.trailing_offset_type);
2063 }
2064
2065 #[rstest]
2066 fn test_parse_order_status_to_report_maps_trailing_percent_to_basis_points() {
2067 let instrument_provider = create_test_instrument_provider();
2068 let instrument_id = create_test_instrument_id();
2069 let account_id = AccountId::from("IB-001");
2070
2071 let order_status = OrderStatus {
2072 order_id: 12345,
2073 status: OrderStatusKind::Submitted,
2074 filled: 0.0,
2075 remaining: 5.0,
2076 average_fill_price: Some(0.0),
2077 perm_id: 0,
2078 parent_id: 0,
2079 last_fill_price: Some(0.0),
2080 client_id: 0,
2081 why_held: String::new(),
2082 market_cap_price: Some(0.0),
2083 };
2084
2085 let order = Order {
2086 action: Action::Buy,
2087 total_quantity: 5.0,
2088 order_type: "TRAIL".to_string(),
2089 trail_stop_price: Some(185.0),
2090 trailing_percent: Some(2.5),
2091 tif: ibapi::orders::TimeInForce::GoodTillCanceled,
2092 ..Default::default()
2093 };
2094
2095 let report = parse_order_status_to_report(
2096 &order_status,
2097 Some(&order),
2098 instrument_id,
2099 account_id,
2100 &instrument_provider,
2101 UnixNanos::new(0),
2102 )
2103 .unwrap();
2104
2105 assert_eq!(report.order_type, OrderType::TrailingStopMarket);
2106 assert_eq!(report.trigger_price, Some(Price::new(185.0, 0)));
2107 assert_eq!(
2108 report.trailing_offset,
2109 Some(Decimal::from_str("250").unwrap())
2110 );
2111 assert_eq!(
2112 report.trailing_offset_type,
2113 Some(TrailingOffsetType::BasisPoints),
2114 );
2115 assert_eq!(report.limit_offset, None);
2116 }
2117
2118 #[rstest]
2119 fn test_parse_execution_to_fill_report_buy() {
2120 let instrument_provider = create_test_instrument_provider();
2121 let instrument_id = create_test_instrument_id();
2122 let account_id = AccountId::from("IB-001");
2123
2124 let execution = Execution {
2125 order_id: 12345,
2126 client_id: 0,
2127 execution_id: String::from("EXEC-001"),
2128 time: String::from("20230223 00:43:36 Universal"),
2129 account_number: String::new(),
2130 exchange: String::new(),
2131 side: ExecutionSide::Bought,
2132 shares: 100.0,
2133 price: 150.25,
2134 perm_id: 0,
2135 liquidation: 0,
2136 cumulative_quantity: 100.0,
2137 average_price: 150.25,
2138 order_reference: String::from("ORDER-REF-001"),
2139 ev_rule: String::new(),
2140 ev_multiplier: None,
2141 model_code: String::new(),
2142 last_liquidity: Liquidity::None,
2143 pending_price_revision: false,
2144 submitter: String::new(),
2145 };
2146
2147 let contract = Contract::default();
2148 let result = parse_execution_to_fill_report(
2149 &execution,
2150 &contract,
2151 1.0,
2152 "USD",
2153 instrument_id,
2154 account_id,
2155 &instrument_provider,
2156 UnixNanos::new(0),
2157 None, );
2159
2160 match result {
2162 Err(e) => {
2163 let error_msg = e.to_string();
2164 assert!(
2165 error_msg.contains("not found") || error_msg.contains("instrument"),
2166 "Unexpected error: {error_msg}"
2167 );
2168 }
2169 Ok(fill) => {
2170 assert_eq!(fill.order_side, OrderSide::Buy);
2171 assert_eq!(fill.trade_id.to_string(), "EXEC-001");
2172 }
2173 }
2174 }
2175
2176 #[rstest]
2177 fn test_parse_execution_to_fill_report_clamps_only_pending_commission_sentinel() {
2178 let instrument_provider = create_test_instrument_provider();
2179 let instrument = equity_aapl();
2180 let instrument_id = instrument.id();
2181 instrument_provider.insert_test_instrument(InstrumentAny::from(instrument), 265598, 1);
2182 let account_id = AccountId::from("IB-001");
2183 let contract = Contract::default();
2184
2185 for (commission, expected) in [(-1.0, 0.0), (-0.25, -0.25)] {
2186 let execution = Execution {
2187 order_id: 12345,
2188 client_id: 0,
2189 execution_id: format!("EXEC-{commission}"),
2190 time: String::from("20230223 00:43:36 Universal"),
2191 account_number: String::new(),
2192 exchange: String::new(),
2193 side: ExecutionSide::Bought,
2194 shares: 100.0,
2195 price: 150.25,
2196 perm_id: 0,
2197 liquidation: 0,
2198 cumulative_quantity: 100.0,
2199 average_price: 150.25,
2200 order_reference: String::from("ORDER-REF-001"),
2201 ev_rule: String::new(),
2202 ev_multiplier: None,
2203 model_code: String::new(),
2204 last_liquidity: Liquidity::None,
2205 pending_price_revision: false,
2206 submitter: String::new(),
2207 };
2208
2209 let report = parse_execution_to_fill_report(
2210 &execution,
2211 &contract,
2212 commission,
2213 "USD",
2214 instrument_id,
2215 account_id,
2216 &instrument_provider,
2217 UnixNanos::new(0),
2218 None,
2219 )
2220 .unwrap();
2221
2222 assert_eq!(report.commission, Money::new(expected, Currency::USD()));
2223 }
2224 }
2225
2226 #[rstest]
2227 fn test_parse_execution_to_fill_report_sell() {
2228 let instrument_provider = create_test_instrument_provider();
2229 let instrument_id = create_test_instrument_id();
2230 let account_id = AccountId::from("IB-001");
2231
2232 let execution = Execution {
2233 order_id: 12345,
2234 client_id: 0,
2235 execution_id: String::from("EXEC-002"),
2236 time: String::from("20230223 00:43:36 Universal"),
2237 account_number: String::new(),
2238 exchange: String::new(),
2239 side: ExecutionSide::Sold,
2240 shares: 50.0,
2241 price: 151.0,
2242 perm_id: 0,
2243 liquidation: 0,
2244 cumulative_quantity: 50.0,
2245 average_price: 151.0,
2246 order_reference: String::new(),
2247 ev_rule: String::new(),
2248 ev_multiplier: None,
2249 model_code: String::new(),
2250 last_liquidity: Liquidity::None,
2251 pending_price_revision: false,
2252 submitter: String::new(),
2253 };
2254
2255 let contract = Contract::default();
2256 let result = parse_execution_to_fill_report(
2257 &execution,
2258 &contract,
2259 0.5,
2260 "USD",
2261 instrument_id,
2262 account_id,
2263 &instrument_provider,
2264 UnixNanos::new(0),
2265 None, );
2267
2268 match result {
2270 Err(e) => {
2271 let error_msg = e.to_string();
2272 assert!(
2273 error_msg.contains("not found") || error_msg.contains("instrument"),
2274 "Unexpected error: {error_msg}"
2275 );
2276 }
2277 Ok(fill) => {
2278 assert_eq!(fill.order_side, OrderSide::Sell);
2279 }
2280 }
2281 }
2282
2283 fn contingency_report(
2284 client_order_id: Option<&str>,
2285 venue_order_id: &str,
2286 ) -> OrderStatusReport {
2287 OrderStatusReport::new(
2288 AccountId::from("IB-001"),
2289 create_test_instrument_id(),
2290 client_order_id.map(ClientOrderId::from),
2291 VenueOrderId::from(venue_order_id),
2292 OrderSide::Sell.into(),
2293 OrderType::StopMarket,
2294 TimeInForce::Gtc,
2295 NautilusOrderStatus::Accepted,
2296 Quantity::from(1),
2297 Quantity::from(0),
2298 UnixNanos::default(),
2299 UnixNanos::default(),
2300 UnixNanos::default(),
2301 None,
2302 )
2303 }
2304
2305 fn contingency_order(order_id: i32, perm_id: i64, parent_id: i32, oca: &str) -> Order {
2306 Order {
2307 order_id,
2308 perm_id,
2309 client_id: 7,
2310 parent_id,
2311 account: "DU123".to_string(),
2312 oca_group: oca.to_string(),
2313 oca_type: ibapi::orders::OcaType::ReduceWithBlock,
2314 ..Default::default()
2315 }
2316 }
2317
2318 #[rstest]
2319 fn test_link_order_contingencies_restores_bracket_and_oca_group() {
2320 let mut reports = vec![
2321 contingency_report(Some("O-ENTRY"), "PERM-10"),
2322 contingency_report(Some("O-STOP"), "PERM-11"),
2323 contingency_report(None, "PERM-12"),
2324 contingency_report(Some("O-ALONE"), "PERM-13"),
2325 ];
2326 let orders = vec![
2327 contingency_order(1, 10, 0, ""),
2328 contingency_order(2, 11, 1, "EXIT-1"),
2329 contingency_order(3, 12, 1, "EXIT-1"),
2330 contingency_order(4, 13, 99, ""),
2331 ];
2332
2333 link_order_contingencies(&mut reports, &orders);
2334
2335 let links = reports
2336 .iter()
2337 .map(|report| {
2338 (
2339 report.contingency_type,
2340 report.linked_order_ids.clone(),
2341 report.parent_order_id,
2342 )
2343 })
2344 .collect::<Vec<_>>();
2345
2346 assert_eq!(
2347 links,
2348 vec![
2349 (
2350 Some(ContingencyType::Oto),
2351 Some(vec![
2352 ClientOrderId::from("O-STOP"),
2353 ClientOrderId::from("PERM-12")
2354 ]),
2355 None,
2356 ),
2357 (
2358 Some(ContingencyType::Ouo),
2359 Some(vec![ClientOrderId::from("PERM-12")]),
2360 Some(ClientOrderId::from("O-ENTRY")),
2361 ),
2362 (
2363 Some(ContingencyType::Ouo),
2364 Some(vec![ClientOrderId::from("O-STOP")]),
2365 Some(ClientOrderId::from("O-ENTRY")),
2366 ),
2367 (None, None, None),
2368 ]
2369 );
2370 }
2371
2372 #[rstest]
2373 #[case::cancel(ibapi::orders::OcaType::CancelWithBlock, Some(ContingencyType::Oco))]
2374 #[case::reduce_without_block(
2375 ibapi::orders::OcaType::ReduceWithoutBlock,
2376 Some(ContingencyType::Ouo)
2377 )]
2378 #[case::unset(ibapi::orders::OcaType::None, None)]
2379 fn test_link_order_contingencies_maps_oca_type(
2380 #[case] oca_type: ibapi::orders::OcaType,
2381 #[case] expected: Option<ContingencyType>,
2382 ) {
2383 let mut reports = vec![
2384 contingency_report(Some("O-A"), "PERM-20"),
2385 contingency_report(Some("O-B"), "PERM-21"),
2386 ];
2387 let mut orders = vec![
2388 contingency_order(1, 20, 0, "GROUP"),
2389 contingency_order(2, 21, 0, "GROUP"),
2390 ];
2391
2392 for order in &mut orders {
2393 order.oca_type = oca_type;
2394 }
2395
2396 link_order_contingencies(&mut reports, &orders);
2397
2398 assert_eq!(
2399 reports
2400 .iter()
2401 .map(|report| (report.contingency_type, report.linked_order_ids.clone()))
2402 .collect::<Vec<_>>(),
2403 vec![
2404 (expected, expected.map(|_| vec![ClientOrderId::from("O-B")])),
2405 (expected, expected.map(|_| vec![ClientOrderId::from("O-A")])),
2406 ]
2407 );
2408 }
2409
2410 #[rstest]
2411 #[case::iceberg(Some(2), Some(Quantity::from(2)))]
2412 #[case::full_size(Some(5), None)]
2413 #[case::unset(Some(0), None)]
2414 fn test_parse_order_status_to_report_sets_display_qty(
2415 #[case] display_size: Option<i32>,
2416 #[case] expected: Option<Quantity>,
2417 ) {
2418 let instrument_provider = create_test_instrument_provider();
2419
2420 let order = Order {
2421 total_quantity: 5.0,
2422 order_type: "LMT".to_string(),
2423 limit_price: Some(101.0),
2424 display_size,
2425 ..Default::default()
2426 };
2427
2428 let order_status = OrderStatus {
2429 status: OrderStatusKind::Submitted,
2430 remaining: 5.0,
2431 ..Default::default()
2432 };
2433
2434 let report = parse_order_status_to_report(
2435 &order_status,
2436 Some(&order),
2437 create_test_instrument_id(),
2438 AccountId::from("IB-001"),
2439 &instrument_provider,
2440 UnixNanos::new(0),
2441 )
2442 .unwrap();
2443
2444 assert_eq!(report.display_qty, expected);
2445 }
2446
2447 #[rstest]
2448 #[case::completed("20260924 12:02:04 UTC", 1_790_251_324_000_000_000)]
2449 #[case::open("", 7)]
2450 fn test_parse_order_data_to_report_uses_completed_time(
2451 #[case] completed_time: &str,
2452 #[case] expected_ts_last: u64,
2453 ) {
2454 let instrument_provider = create_test_instrument_provider();
2455
2456 let mut data = OrderData {
2457 order_id: 1,
2458 order: Order {
2459 total_quantity: 1.0,
2460 order_type: "MKT".to_string(),
2461 ..Default::default()
2462 },
2463 ..Default::default()
2464 };
2465
2466 data.order_state.completed_time = completed_time.to_string();
2467
2468 let report = parse_order_data_to_report(
2469 &data,
2470 create_test_instrument_id(),
2471 AccountId::from("IB-001"),
2472 &instrument_provider,
2473 UnixNanos::new(7),
2474 )
2475 .unwrap();
2476
2477 assert_eq!(report.ts_last, UnixNanos::from(expected_ts_last));
2478 }
2479}