1use std::str::FromStr;
19
20use anyhow::Context;
21use ibapi::orders::{Execution, OrderStatus};
22use jiff::{
23 Timestamp,
24 civil::DateTime,
25 tz::{AmbiguousOffset, Offset},
26};
27use nautilus_core::{UnixNanos, datetime::get_timezone};
28use nautilus_model::{
29 enums::{
30 LiquiditySide, OrderSide, OrderStatus as NautilusOrderStatus, OrderType, TimeInForce,
31 TrailingOffsetType,
32 },
33 identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
34 instruments::Instrument,
35 reports::{FillReport, OrderStatusReport},
36 types::{Currency, Money, Price, Quantity},
37};
38use rust_decimal::Decimal;
39
40use crate::{
41 common::{
42 enums::{IbAction, IbOrderStatus, IbOrderType, IbTimeInForce},
43 parse::is_spread_instrument_id,
44 },
45 providers::instruments::InteractiveBrokersInstrumentProvider,
46};
47
48pub(crate) fn should_use_avg_fill_price(avg_fill_price: f64, instrument_id: &InstrumentId) -> bool {
49 avg_fill_price.is_finite()
50 && avg_fill_price != f64::MAX
51 && avg_fill_price != 0.0
52 && (avg_fill_price > 0.0 || is_spread_instrument_id(instrument_id))
53}
54
55pub(crate) fn ib_venue_order_id(order_id: i32, perm_id: i64) -> VenueOrderId {
56 if perm_id != 0 {
57 VenueOrderId::new(format!("PERM-{perm_id}"))
58 } else {
59 VenueOrderId::new(order_id.to_string())
60 }
61}
62
63pub(crate) fn normalized_order_ref(order_ref: &str) -> Option<&str> {
64 if order_ref.is_empty() {
65 return None;
66 }
67
68 Some(
69 order_ref
70 .rsplit_once(':')
71 .map_or(order_ref, |(base, _)| base),
72 )
73}
74
75#[allow(clippy::too_many_arguments)]
86pub fn parse_execution_to_fill_report(
87 execution: &Execution,
88 _contract: &ibapi::contracts::Contract,
89 commission: f64,
90 commission_currency: &str,
91 instrument_id: InstrumentId,
92 account_id: AccountId,
93 instrument_provider: &InteractiveBrokersInstrumentProvider,
94 ts_init: UnixNanos,
95 avg_px: Option<Price>,
96) -> anyhow::Result<FillReport> {
97 let price_magnifier = instrument_provider.get_price_magnifier(&instrument_id) as f64;
99
100 let execution_price = execution.price * price_magnifier;
102
103 let order_side = IbAction::from_str(execution.side.as_str())?.order_side();
105
106 let instrument = instrument_provider
108 .find(&instrument_id)
109 .context("Instrument not found")?;
110
111 let last_qty = Quantity::new(execution.shares, instrument.size_precision());
113 let last_px = Price::new(execution_price, instrument.price_precision());
114
115 let commission_clamped = if commission == -1.0 { 0.0 } else { commission };
117 let commission_money = Money::new(commission_clamped, Currency::from_str(commission_currency)?);
118
119 let ts_event = parse_execution_time(&execution.time)?;
121
122 let trade_id = TradeId::new(&execution.execution_id);
124
125 let venue_order_id = ib_venue_order_id(execution.order_id, execution.perm_id);
126
127 let client_order_id = normalized_order_ref(&execution.order_reference).map(ClientOrderId::new);
128
129 let mut report = FillReport::new(
130 account_id,
131 instrument_id,
132 venue_order_id,
133 trade_id,
134 order_side,
135 last_qty,
136 last_px,
137 commission_money,
138 LiquiditySide::NoLiquiditySide,
139 client_order_id,
140 None, ts_event,
142 ts_init,
143 Some(nautilus_core::UUID4::new()),
144 );
145 report.avg_px = avg_px.map(|price: Price| price.as_decimal());
146
147 Ok(report)
148}
149
150pub fn parse_order_status_to_report(
156 order_status: &OrderStatus,
157 order: Option<&ibapi::orders::Order>,
158 instrument_id: InstrumentId,
159 account_id: AccountId,
160 instrument_provider: &InteractiveBrokersInstrumentProvider,
161 ts_init: UnixNanos,
162) -> anyhow::Result<OrderStatusReport> {
163 let price_magnifier = instrument_provider.get_price_magnifier(&instrument_id) as f64;
165
166 let mut nautilus_status = match IbOrderStatus::from_str(order_status.status.as_str()) {
167 Ok(status) => status.nautilus_status(),
168 _ => {
169 tracing::warn!(
170 "Unknown order status: {}, defaulting to SUBMITTED",
171 order_status.status.as_str()
172 );
173 NautilusOrderStatus::Submitted
174 }
175 };
176
177 let order_side = if let Some(order) = order {
179 IbAction::from(order.action).order_side()
180 } else {
181 OrderSide::Buy
183 };
184
185 let instrument = instrument_provider.find(&instrument_id);
186
187 let size_precision = instrument
189 .as_ref()
190 .map_or(0, |instr| instr.size_precision());
191 let price_precision = instrument
192 .as_ref()
193 .map_or(0, |instr| instr.price_precision());
194
195 let quantity = if let Some(order) = order {
197 Quantity::new(order.total_quantity, size_precision)
198 } else {
199 Quantity::zero(size_precision)
200 };
201
202 let filled_qty = Quantity::new(order_status.filled, size_precision);
204
205 let average_fill_price = order_status.average_fill_price.unwrap_or(0.0);
207 let include_avg_px = should_use_avg_fill_price(average_fill_price, &instrument_id);
208 let avg_px_value = if include_avg_px {
209 average_fill_price * price_magnifier
210 } else {
211 0.0
212 };
213
214 if order_status.filled > 0.0
215 && (order_status.remaining > 0.0
216 || order.is_some_and(|order| order.total_quantity > order_status.filled))
217 {
218 nautilus_status = NautilusOrderStatus::PartiallyFilled;
219 }
220
221 let venue_order_id = ib_venue_order_id(order_status.order_id, order_status.perm_id);
222
223 let client_order_id = order
224 .and_then(|order| normalized_order_ref(&order.order_ref))
225 .map(ClientOrderId::new);
226
227 let order_type = order
229 .map(|order| map_ib_order_type(&order.order_type, order.limit_price))
230 .unwrap_or(OrderType::Market);
231
232 let time_in_force = if let Some(order) = order {
234 let ib_time_in_force = IbTimeInForce::from(order.tif.clone());
235 if ib_time_in_force == IbTimeInForce::GoodTilDate || !order.good_till_date.is_empty() {
236 TimeInForce::Gtd
237 } else {
238 ib_time_in_force.nautilus_time_in_force()
239 }
240 } else {
241 TimeInForce::Day };
243
244 let mut report = OrderStatusReport::new(
246 account_id,
247 instrument_id,
248 client_order_id,
249 venue_order_id,
250 order_side.into(),
251 order_type,
252 time_in_force,
253 nautilus_status,
254 quantity,
255 filled_qty,
256 ts_init, ts_init, ts_init,
259 Some(nautilus_core::UUID4::new()), );
261
262 if let Some(order) = order {
264 if let Some(limit_price) = order.limit_price {
265 let converted = limit_price * price_magnifier;
266 report = report.with_price(Price::new(converted, price_precision));
267 }
268
269 let (trigger_price, limit_offset, trailing_offset, trailing_offset_type) =
270 parse_ib_order_pricing_fields(order, order_type, price_magnifier, price_precision)?;
271
272 if let Some(trigger_price) = trigger_price {
273 report = report.with_trigger_price(trigger_price);
274 }
275
276 if let Some(limit_offset) = limit_offset {
277 report = report.with_limit_offset(limit_offset);
278 }
279
280 if let Some(trailing_offset) = trailing_offset {
281 report = report.with_trailing_offset(trailing_offset);
282 }
283
284 if let Some(trailing_offset_type) = trailing_offset_type {
285 report = report.with_trailing_offset_type(trailing_offset_type);
286 }
287 }
288
289 if include_avg_px {
290 report = report.with_avg_px(decimal_from_f64(avg_px_value)?);
291 }
292
293 Ok(report)
294}
295
296fn map_ib_order_type(order_type: &str, limit_price: Option<f64>) -> OrderType {
297 if order_type == "IBALGO" && limit_price.is_some_and(|price| price != 0.0) {
298 OrderType::Limit
299 } else {
300 IbOrderType::from_str(order_type)
301 .map_or(OrderType::Market, IbOrderType::nautilus_order_type)
302 }
303}
304
305fn parse_ib_order_pricing_fields(
306 order: &ibapi::orders::Order,
307 order_type: OrderType,
308 price_magnifier: f64,
309 price_precision: u8,
310) -> anyhow::Result<(
311 Option<Price>,
312 Option<Decimal>,
313 Option<Decimal>,
314 Option<TrailingOffsetType>,
315)> {
316 let mut trigger_price = None;
317 let mut limit_offset = None;
318 let mut trailing_offset = None;
319 let mut trailing_offset_type = None;
320
321 if matches!(
322 order_type,
323 OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
324 ) {
325 if let Some(trail_stop_price) = order.trail_stop_price {
326 trigger_price = Some(Price::new(
327 trail_stop_price * price_magnifier,
328 price_precision,
329 ));
330 }
331
332 if let Some(aux_price) = order.aux_price {
333 trailing_offset = Some(decimal_from_f64(aux_price)?);
334 trailing_offset_type = Some(TrailingOffsetType::Price);
335 } else if let Some(trailing_percent) = order.trailing_percent {
336 trailing_offset = Some(decimal_from_f64(trailing_percent)? * Decimal::from(100));
337 trailing_offset_type = Some(TrailingOffsetType::BasisPoints);
338 }
339
340 if order_type == OrderType::TrailingStopLimit
341 && let Some(limit_price_offset) = order.limit_price_offset
342 {
343 limit_offset = Some(decimal_from_f64(limit_price_offset)?);
344 trailing_offset_type = Some(trailing_offset_type.unwrap_or(TrailingOffsetType::Price));
345 }
346
347 return Ok((
348 trigger_price,
349 limit_offset,
350 trailing_offset,
351 trailing_offset_type,
352 ));
353 }
354
355 if let Some(aux_price) = order.aux_price {
356 trigger_price = Some(Price::new(aux_price * price_magnifier, price_precision));
357 }
358
359 Ok((
360 trigger_price,
361 limit_offset,
362 trailing_offset,
363 trailing_offset_type,
364 ))
365}
366
367fn decimal_from_f64(value: f64) -> anyhow::Result<Decimal> {
368 Decimal::from_str(&value.to_string())
369 .with_context(|| format!("Failed to convert IB floating-point value {value} to Decimal"))
370}
371
372pub fn parse_execution_time(time_str: &str) -> anyhow::Result<UnixNanos> {
396 const NAIVE_FORMAT: &str = "%Y%m%d %H:%M:%S";
397
398 if !time_str.contains(' ') {
400 let normalized = time_str.replace('-', " ");
401 let dt = DateTime::strptime(NAIVE_FORMAT, &normalized).map_err(|e| {
402 anyhow::anyhow!("Failed to parse execution timestamp '{time_str}': {e}")
403 })?;
404 return datetime_to_unix_nanos(Offset::UTC.to_timestamp(dt)?, time_str);
405 }
406
407 let mut parts = time_str.splitn(3, ' ');
411 let (Some(date), Some(time)) = (parts.next(), parts.next()) else {
412 anyhow::bail!("Invalid execution time format: {time_str}");
413 };
414 let tz_str = parts.next().unwrap_or("").trim();
415
416 let naive_str = format!("{date} {time}");
417 let dt = DateTime::strptime(NAIVE_FORMAT, &naive_str)
418 .map_err(|e| anyhow::anyhow!("Failed to parse execution timestamp '{time_str}': {e}"))?;
419
420 let utc = if tz_str.is_empty() {
421 Offset::UTC.to_timestamp(dt)?
422 } else {
423 localize_with_zone(dt, tz_str, time_str)?
424 };
425
426 datetime_to_unix_nanos(utc, time_str)
427}
428
429fn localize_with_zone(dt: DateTime, tz_str: &str, time_str: &str) -> anyhow::Result<Timestamp> {
434 let tz_name = if tz_str.eq_ignore_ascii_case("Z") {
435 "UTC"
436 } else {
437 tz_str
438 };
439
440 let zone = get_timezone(tz_name).map_err(|_| {
441 anyhow::anyhow!(
442 "Unrecognised execution timezone '{tz_str}' in '{time_str}'. Configure TWS / IB Gateway to emit a standard timezone (e.g. UTC)"
443 )
444 })?;
445 let ambiguous = zone.to_ambiguous_timestamp(dt);
446 match ambiguous.offset() {
447 AmbiguousOffset::Unambiguous { .. } => Ok(ambiguous.unambiguous()?),
448 AmbiguousOffset::Fold { .. } => Ok(ambiguous.earlier()?),
450 AmbiguousOffset::Gap { .. } => {
451 anyhow::bail!("Execution timestamp '{time_str}' is non-existent in timezone '{tz_str}'")
452 }
453 }
454}
455
456fn datetime_to_unix_nanos(dt: Timestamp, time_str: &str) -> anyhow::Result<UnixNanos> {
457 let nanos: u64 = dt
458 .as_nanosecond()
459 .try_into()
460 .map_err(|_| anyhow::anyhow!("Execution timestamp '{time_str}' was before Unix epoch"))?;
461 Ok(UnixNanos::new(nanos))
462}
463
464#[cfg(test)]
465mod tests {
466 use ibapi::{
467 contracts::Contract,
468 orders::{Action, ExecutionSide, Liquidity, Order, OrderStatusKind},
469 };
470 use nautilus_model::{
471 enums::TrailingOffsetType,
472 identifiers::{Symbol, Venue},
473 instruments::{InstrumentAny, stubs::equity_aapl},
474 };
475 use rust_decimal::Decimal;
476
477 use super::*;
478 use crate::{
479 config::InteractiveBrokersInstrumentProviderConfig,
480 providers::instruments::InteractiveBrokersInstrumentProvider,
481 };
482
483 fn create_test_instrument_provider() -> InteractiveBrokersInstrumentProvider {
484 let config = InteractiveBrokersInstrumentProviderConfig::default();
485 InteractiveBrokersInstrumentProvider::new(config)
486 }
487
488 fn create_test_instrument_id() -> InstrumentId {
489 InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"))
490 }
491
492 use rstest::rstest;
493
494 #[rstest]
495 fn test_ibalgo_with_zero_limit_price_maps_to_market() {
496 assert_eq!(map_ib_order_type("IBALGO", Some(0.0)), OrderType::Market);
497 }
498
499 #[rstest]
500 fn test_parse_execution_time_hyphenated_format() {
501 let time_str = "20250225-15:15:00";
502 let result = parse_execution_time(time_str);
503 assert!(result.is_ok());
504 let timestamp = result.unwrap();
505 assert!(timestamp.as_i64() > 0);
506 }
507
508 #[rstest]
509 fn test_parse_execution_time_with_met_timezone() {
510 let met = parse_execution_time("20230223 00:43:36 MET").unwrap();
513 let utc = parse_execution_time("20230223 00:43:36 Universal").unwrap();
514 assert_eq!(
516 met.as_i64(),
517 utc.as_i64() - 3_600_000_000_000,
518 "MET (CET) should be 1h ahead of UTC in February"
519 );
520 assert!(met.as_i64() > 0);
521 }
522
523 #[rstest]
524 fn test_parse_execution_time_applies_dst_for_regional_timezone() {
525 let winter = parse_execution_time("20230223 00:43:36 America/New_York").unwrap();
528 let summer = parse_execution_time("20230715 00:43:36 America/New_York").unwrap();
529 let winter_utc = parse_execution_time("20230223 00:43:36 Universal").unwrap();
530 let summer_utc = parse_execution_time("20230715 00:43:36 Universal").unwrap();
531 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); }
534
535 #[rstest]
536 fn test_parse_execution_time_dst_fall_back_fold_resolves_to_earliest() {
537 let fold = parse_execution_time("20231105 01:30:00 America/Chicago").unwrap();
541 assert_eq!(
542 fold.as_i64(),
543 parse_execution_time("20231105 06:30:00 Universal")
544 .unwrap()
545 .as_i64()
546 );
547 assert_ne!(
548 fold.as_i64(),
549 parse_execution_time("20231105 07:30:00 Universal")
550 .unwrap()
551 .as_i64()
552 );
553 }
554
555 #[rstest]
556 fn test_parse_execution_time_dst_spring_forward_gap_errors() {
557 let gap = parse_execution_time("20230312 02:30:00 America/Chicago");
559 assert!(gap.is_err());
560 }
561
562 #[rstest]
563 fn test_parse_execution_time_fixed_offset_zone_without_dst() {
564 let tokyo = parse_execution_time("20230223 00:43:36 Asia/Tokyo").unwrap();
566 let utc = parse_execution_time("20230223 00:43:36 Universal").unwrap();
567 assert_eq!(tokyo.as_i64(), utc.as_i64() - 9 * 3_600_000_000_000);
568 }
569
570 #[rstest]
571 fn test_parse_execution_time_with_unrecognised_timezone_errors() {
572 let time_str = "20230223 00:43:36 Mars/Olympus";
573 let result = parse_execution_time(time_str);
574 assert!(result.is_err());
575 }
576
577 #[rstest]
578 fn test_parse_execution_time_utc() {
579 let time_str = "20230223 00:43:36 Universal";
580 let result = parse_execution_time(time_str);
581 assert!(result.is_ok());
582 let timestamp = result.unwrap();
583 assert!(timestamp.as_i64() > 0);
584 }
585
586 #[rstest]
587 fn test_parse_execution_time_no_timezone_assumes_utc() {
588 let time_str = "20230223 00:43:36";
589 let result = parse_execution_time(time_str);
590 assert!(result.is_ok());
591 let timestamp = result.unwrap();
592 assert!(timestamp.as_i64() > 0);
593 }
594
595 #[rstest]
596 fn test_parse_execution_time_invalid_format() {
597 let time_str = "invalid format";
598 let result = parse_execution_time(time_str);
599 assert!(result.is_err());
600 }
601
602 #[rstest]
603 fn test_parse_execution_time_short_format() {
604 let time_str = "20230223 00:43";
605 let result = parse_execution_time(time_str);
606 assert!(result.is_err());
607 }
608
609 #[rstest]
610 fn test_parse_order_status_to_report_submitted() {
611 let instrument_provider = create_test_instrument_provider();
612 let instrument_id = create_test_instrument_id();
613 let account_id = AccountId::from("IB-001");
614
615 let order_status = OrderStatus {
616 order_id: 12345,
617 status: OrderStatusKind::Submitted,
618 filled: 0.0,
619 remaining: 100.0,
620 average_fill_price: Some(0.0),
621 perm_id: 0,
622 parent_id: 0,
623 last_fill_price: Some(0.0),
624 client_id: 0,
625 why_held: String::new(),
626 market_cap_price: Some(0.0),
627 };
628
629 let result = parse_order_status_to_report(
630 &order_status,
631 None,
632 instrument_id,
633 account_id,
634 &instrument_provider,
635 UnixNanos::new(0),
636 );
637
638 if let Err(e) = result {
640 let error_msg = e.to_string();
641 assert!(
642 error_msg.contains("not found") || error_msg.contains("instrument"),
643 "Unexpected error: {}",
644 error_msg
645 );
646 }
647 }
648
649 #[rstest]
650 fn test_parse_order_status_to_report_filled() {
651 let instrument_provider = create_test_instrument_provider();
652 let instrument_id = create_test_instrument_id();
653 let account_id = AccountId::from("IB-001");
654
655 let order_status = OrderStatus {
656 order_id: 12345,
657 status: OrderStatusKind::Filled,
658 filled: 100.0,
659 remaining: 0.0,
660 average_fill_price: Some(150.25),
661 perm_id: 0,
662 parent_id: 0,
663 last_fill_price: Some(150.25),
664 client_id: 0,
665 why_held: String::new(),
666 market_cap_price: Some(0.0),
667 };
668
669 let result = parse_order_status_to_report(
670 &order_status,
671 None,
672 instrument_id,
673 account_id,
674 &instrument_provider,
675 UnixNanos::new(0),
676 );
677
678 if let Err(e) = result {
680 let error_msg = e.to_string();
681 assert!(
682 error_msg.contains("not found") || error_msg.contains("instrument"),
683 "Unexpected error: {}",
684 error_msg
685 );
686 }
687 }
688
689 #[rstest]
690 fn test_parse_order_status_to_report_spread_allows_negative_avg_fill_price() {
691 let instrument_provider = create_test_instrument_provider();
692 let instrument_id = InstrumentId::new(
693 Symbol::from("(1)SPY C400_((1))SPY C410"),
694 Venue::from("SMART"),
695 );
696 let account_id = AccountId::from("IB-001");
697
698 let order_status = OrderStatus {
699 order_id: 12345,
700 status: OrderStatusKind::Filled,
701 filled: 1.0,
702 remaining: 0.0,
703 average_fill_price: Some(-2.25),
704 perm_id: 0,
705 parent_id: 0,
706 last_fill_price: Some(-2.25),
707 client_id: 0,
708 why_held: String::new(),
709 market_cap_price: Some(0.0),
710 };
711
712 let report = parse_order_status_to_report(
713 &order_status,
714 None,
715 instrument_id,
716 account_id,
717 &instrument_provider,
718 UnixNanos::new(0),
719 )
720 .unwrap();
721
722 assert_eq!(report.avg_px, Some(Decimal::from_str("-2.25").unwrap()));
723 }
724
725 #[rstest]
726 fn test_parse_order_status_to_report_inactive_maps_to_rejected() {
727 let instrument_provider = create_test_instrument_provider();
728 let instrument_id = create_test_instrument_id();
729 let account_id = AccountId::from("IB-001");
730
731 let order_status = OrderStatus {
732 order_id: 12345,
733 status: OrderStatusKind::Inactive,
734 filled: 0.0,
735 remaining: 100.0,
736 average_fill_price: Some(0.0),
737 perm_id: 0,
738 parent_id: 0,
739 last_fill_price: Some(0.0),
740 client_id: 0,
741 why_held: String::new(),
742 market_cap_price: Some(0.0),
743 };
744
745 let report = parse_order_status_to_report(
746 &order_status,
747 None,
748 instrument_id,
749 account_id,
750 &instrument_provider,
751 UnixNanos::new(0),
752 )
753 .unwrap();
754
755 assert_eq!(report.order_status, NautilusOrderStatus::Rejected);
756 }
757
758 #[rstest]
759 fn test_parse_order_status_to_report_partial_fill_and_perm_fallback() {
760 let instrument_provider = create_test_instrument_provider();
761 let instrument_id = create_test_instrument_id();
762 let account_id = AccountId::from("IB-001");
763
764 let order_status = OrderStatus {
765 order_id: 0,
766 status: OrderStatusKind::Submitted,
767 filled: 3.0,
768 remaining: 7.0,
769 average_fill_price: Some(150.25),
770 perm_id: 123_456,
771 parent_id: 0,
772 last_fill_price: Some(150.25),
773 client_id: 0,
774 why_held: String::new(),
775 market_cap_price: Some(0.0),
776 };
777 let order = Order {
778 action: Action::Buy,
779 total_quantity: 10.0,
780 order_type: "LMT".to_string(),
781 limit_price: Some(150.25),
782 order_ref: "O-20260527-001:123".to_string(),
783 ..Default::default()
784 };
785
786 let report = parse_order_status_to_report(
787 &order_status,
788 Some(&order),
789 instrument_id,
790 account_id,
791 &instrument_provider,
792 UnixNanos::new(0),
793 )
794 .unwrap();
795
796 assert_eq!(report.order_status, NautilusOrderStatus::PartiallyFilled);
797 assert_eq!(report.venue_order_id.to_string(), "PERM-123456");
798 assert_eq!(
799 report.client_order_id,
800 Some(ClientOrderId::from("O-20260527-001"))
801 );
802 }
803
804 #[rstest]
805 fn test_ib_venue_order_id_prefers_perm_id_and_falls_back_to_order_id() {
806 assert_eq!(ib_venue_order_id(123, 456).to_string(), "PERM-456");
807 assert_eq!(ib_venue_order_id(123, 0).to_string(), "123");
808 }
809
810 #[rstest]
811 fn test_normalized_order_ref_strips_ib_suffix() {
812 assert_eq!(normalized_order_ref("O-001:123"), Some("O-001"));
813 assert_eq!(normalized_order_ref("O-001"), Some("O-001"));
814 assert_eq!(normalized_order_ref(""), None);
815 }
816
817 #[rstest]
818 #[case(
819 "MKT",
820 None,
821 None,
822 None,
823 None,
824 OrderType::Market,
825 None,
826 None,
827 None,
828 None,
829 None
830 )]
831 #[case(
832 "LMT",
833 Some(185.0),
834 None,
835 None,
836 None,
837 OrderType::Limit,
838 Some(Price::new(185.0, 0)),
839 None,
840 None,
841 None,
842 None
843 )]
844 #[case(
845 "IBALGO",
846 Some(185.0),
847 None,
848 None,
849 None,
850 OrderType::Limit,
851 Some(Price::new(185.0, 0)),
852 None,
853 None,
854 None,
855 None
856 )]
857 #[case(
858 "IBALGO",
859 None,
860 None,
861 None,
862 None,
863 OrderType::Market,
864 None,
865 None,
866 None,
867 None,
868 None
869 )]
870 #[case(
871 "MIT",
872 None,
873 Some(180.0),
874 None,
875 None,
876 OrderType::MarketIfTouched,
877 None,
878 Some(Price::new(180.0, 0)),
879 None,
880 None,
881 None
882 )]
883 #[case(
884 "LIT",
885 Some(179.0),
886 Some(180.0),
887 None,
888 None,
889 OrderType::LimitIfTouched,
890 Some(Price::new(179.0, 0)),
891 Some(Price::new(180.0, 0)),
892 None,
893 None,
894 None
895 )]
896 #[case(
897 "STP",
898 None,
899 Some(180.0),
900 None,
901 None,
902 OrderType::StopMarket,
903 None,
904 Some(Price::new(180.0, 0)),
905 None,
906 None,
907 None
908 )]
909 #[case(
910 "STP LMT",
911 Some(179.0),
912 Some(180.0),
913 None,
914 None,
915 OrderType::StopLimit,
916 Some(Price::new(179.0, 0)),
917 Some(Price::new(180.0, 0)),
918 None,
919 None,
920 None
921 )]
922 #[case(
923 "TRAIL LIMIT",
924 None,
925 Some(2.5),
926 Some(185.0),
927 Some(0.25),
928 OrderType::TrailingStopLimit,
929 None,
930 Some(Price::new(185.0, 0)),
931 Some(Decimal::from_str("0.25").unwrap()),
932 Some(Decimal::from_str("2.5").unwrap()),
933 Some(TrailingOffsetType::Price),
934 )]
935 fn test_parse_order_status_to_report_maps_pricing_fields_by_order_type(
936 #[case] ib_order_type: &str,
937 #[case] limit_price: Option<f64>,
938 #[case] aux_price: Option<f64>,
939 #[case] trail_stop_price: Option<f64>,
940 #[case] limit_price_offset: Option<f64>,
941 #[case] expected_order_type: OrderType,
942 #[case] expected_price: Option<Price>,
943 #[case] expected_trigger_price: Option<Price>,
944 #[case] expected_limit_offset: Option<Decimal>,
945 #[case] expected_trailing_offset: Option<Decimal>,
946 #[case] expected_trailing_offset_type: Option<TrailingOffsetType>,
947 ) {
948 let instrument_provider = create_test_instrument_provider();
949 let instrument_id = create_test_instrument_id();
950 let account_id = AccountId::from("IB-001");
951
952 let order_status = OrderStatus {
953 order_id: 12345,
954 status: OrderStatusKind::Submitted,
955 filled: 0.0,
956 remaining: 5.0,
957 average_fill_price: Some(0.0),
958 perm_id: 0,
959 parent_id: 0,
960 last_fill_price: Some(0.0),
961 client_id: 0,
962 why_held: String::new(),
963 market_cap_price: Some(0.0),
964 };
965
966 let order = Order {
967 action: Action::Buy,
968 total_quantity: 5.0,
969 order_type: ib_order_type.to_string(),
970 limit_price,
971 aux_price,
972 trail_stop_price,
973 limit_price_offset,
974 tif: ibapi::orders::TimeInForce::GoodTilCanceled,
975 ..Default::default()
976 };
977
978 let report = parse_order_status_to_report(
979 &order_status,
980 Some(&order),
981 instrument_id,
982 account_id,
983 &instrument_provider,
984 UnixNanos::new(0),
985 )
986 .unwrap();
987
988 assert_eq!(report.order_type, expected_order_type);
989 assert_eq!(report.price, expected_price);
990 assert_eq!(report.trigger_price, expected_trigger_price);
991 assert_eq!(report.limit_offset, expected_limit_offset);
992 assert_eq!(report.trailing_offset, expected_trailing_offset);
993 assert_eq!(report.trailing_offset_type, expected_trailing_offset_type);
994 }
995
996 #[rstest]
997 fn test_parse_order_status_to_report_maps_trailing_percent_to_basis_points() {
998 let instrument_provider = create_test_instrument_provider();
999 let instrument_id = create_test_instrument_id();
1000 let account_id = AccountId::from("IB-001");
1001
1002 let order_status = OrderStatus {
1003 order_id: 12345,
1004 status: OrderStatusKind::Submitted,
1005 filled: 0.0,
1006 remaining: 5.0,
1007 average_fill_price: Some(0.0),
1008 perm_id: 0,
1009 parent_id: 0,
1010 last_fill_price: Some(0.0),
1011 client_id: 0,
1012 why_held: String::new(),
1013 market_cap_price: Some(0.0),
1014 };
1015
1016 let order = Order {
1017 action: Action::Buy,
1018 total_quantity: 5.0,
1019 order_type: "TRAIL".to_string(),
1020 trail_stop_price: Some(185.0),
1021 trailing_percent: Some(2.5),
1022 tif: ibapi::orders::TimeInForce::GoodTilCanceled,
1023 ..Default::default()
1024 };
1025
1026 let report = parse_order_status_to_report(
1027 &order_status,
1028 Some(&order),
1029 instrument_id,
1030 account_id,
1031 &instrument_provider,
1032 UnixNanos::new(0),
1033 )
1034 .unwrap();
1035
1036 assert_eq!(report.order_type, OrderType::TrailingStopMarket);
1037 assert_eq!(report.trigger_price, Some(Price::new(185.0, 0)));
1038 assert_eq!(
1039 report.trailing_offset,
1040 Some(Decimal::from_str("250").unwrap())
1041 );
1042 assert_eq!(
1043 report.trailing_offset_type,
1044 Some(TrailingOffsetType::BasisPoints),
1045 );
1046 assert_eq!(report.limit_offset, None);
1047 }
1048
1049 #[rstest]
1050 fn test_parse_execution_to_fill_report_buy() {
1051 let instrument_provider = create_test_instrument_provider();
1052 let instrument_id = create_test_instrument_id();
1053 let account_id = AccountId::from("IB-001");
1054
1055 let execution = Execution {
1056 order_id: 12345,
1057 client_id: 0,
1058 execution_id: String::from("EXEC-001"),
1059 time: String::from("20230223 00:43:36 Universal"),
1060 account_number: String::new(),
1061 exchange: String::new(),
1062 side: ExecutionSide::Bought,
1063 shares: 100.0,
1064 price: 150.25,
1065 perm_id: 0,
1066 liquidation: 0,
1067 cumulative_quantity: 100.0,
1068 average_price: 150.25,
1069 order_reference: String::from("ORDER-REF-001"),
1070 ev_rule: String::new(),
1071 ev_multiplier: None,
1072 model_code: String::new(),
1073 last_liquidity: Liquidity::None,
1074 pending_price_revision: false,
1075 submitter: String::new(),
1076 };
1077
1078 let contract = Contract::default();
1079 let result = parse_execution_to_fill_report(
1080 &execution,
1081 &contract,
1082 1.0,
1083 "USD",
1084 instrument_id,
1085 account_id,
1086 &instrument_provider,
1087 UnixNanos::new(0),
1088 None, );
1090
1091 match result {
1093 Err(e) => {
1094 let error_msg = e.to_string();
1095 assert!(
1096 error_msg.contains("not found") || error_msg.contains("instrument"),
1097 "Unexpected error: {}",
1098 error_msg
1099 );
1100 }
1101 Ok(fill) => {
1102 assert_eq!(fill.order_side, OrderSide::Buy);
1103 assert_eq!(fill.trade_id.to_string(), "EXEC-001");
1104 }
1105 }
1106 }
1107
1108 #[rstest]
1109 fn test_parse_execution_to_fill_report_clamps_only_pending_commission_sentinel() {
1110 let instrument_provider = create_test_instrument_provider();
1111 let instrument = equity_aapl();
1112 let instrument_id = instrument.id();
1113 instrument_provider.insert_test_instrument(InstrumentAny::from(instrument), 265598, 1);
1114 let account_id = AccountId::from("IB-001");
1115 let contract = Contract::default();
1116
1117 for (commission, expected) in [(-1.0, 0.0), (-0.25, -0.25)] {
1118 let execution = Execution {
1119 order_id: 12345,
1120 client_id: 0,
1121 execution_id: format!("EXEC-{commission}"),
1122 time: String::from("20230223 00:43:36 Universal"),
1123 account_number: String::new(),
1124 exchange: String::new(),
1125 side: ExecutionSide::Bought,
1126 shares: 100.0,
1127 price: 150.25,
1128 perm_id: 0,
1129 liquidation: 0,
1130 cumulative_quantity: 100.0,
1131 average_price: 150.25,
1132 order_reference: String::from("ORDER-REF-001"),
1133 ev_rule: String::new(),
1134 ev_multiplier: None,
1135 model_code: String::new(),
1136 last_liquidity: Liquidity::None,
1137 pending_price_revision: false,
1138 submitter: String::new(),
1139 };
1140
1141 let report = parse_execution_to_fill_report(
1142 &execution,
1143 &contract,
1144 commission,
1145 "USD",
1146 instrument_id,
1147 account_id,
1148 &instrument_provider,
1149 UnixNanos::new(0),
1150 None,
1151 )
1152 .unwrap();
1153
1154 assert_eq!(report.commission, Money::new(expected, Currency::USD()));
1155 }
1156 }
1157
1158 #[rstest]
1159 fn test_parse_execution_to_fill_report_sell() {
1160 let instrument_provider = create_test_instrument_provider();
1161 let instrument_id = create_test_instrument_id();
1162 let account_id = AccountId::from("IB-001");
1163
1164 let execution = Execution {
1165 order_id: 12345,
1166 client_id: 0,
1167 execution_id: String::from("EXEC-002"),
1168 time: String::from("20230223 00:43:36 Universal"),
1169 account_number: String::new(),
1170 exchange: String::new(),
1171 side: ExecutionSide::Sold,
1172 shares: 50.0,
1173 price: 151.0,
1174 perm_id: 0,
1175 liquidation: 0,
1176 cumulative_quantity: 50.0,
1177 average_price: 151.0,
1178 order_reference: String::new(),
1179 ev_rule: String::new(),
1180 ev_multiplier: None,
1181 model_code: String::new(),
1182 last_liquidity: Liquidity::None,
1183 pending_price_revision: false,
1184 submitter: String::new(),
1185 };
1186
1187 let contract = Contract::default();
1188 let result = parse_execution_to_fill_report(
1189 &execution,
1190 &contract,
1191 0.5,
1192 "USD",
1193 instrument_id,
1194 account_id,
1195 &instrument_provider,
1196 UnixNanos::new(0),
1197 None, );
1199
1200 match result {
1202 Err(e) => {
1203 let error_msg = e.to_string();
1204 assert!(
1205 error_msg.contains("not found") || error_msg.contains("instrument"),
1206 "Unexpected error: {}",
1207 error_msg
1208 );
1209 }
1210 Ok(fill) => {
1211 assert_eq!(fill.order_side, OrderSide::Sell);
1212 }
1213 }
1214 }
1215}