1use std::str::FromStr;
19
20use anyhow::Context;
21use ibapi::orders::{Execution, OrderStatus};
22use nautilus_core::UnixNanos;
23use nautilus_model::{
24 enums::{
25 LiquiditySide, OrderSide, OrderStatus as NautilusOrderStatus, OrderType, TimeInForce,
26 TrailingOffsetType,
27 },
28 identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
29 instruments::Instrument,
30 reports::{FillReport, OrderStatusReport},
31 types::{Currency, Money, Price, Quantity},
32};
33use rust_decimal::Decimal;
34use time::{PrimitiveDateTime, macros::format_description};
35
36use crate::{
37 common::{
38 enums::{IbAction, IbOrderStatus, IbOrderType, IbTimeInForce},
39 parse::is_spread_instrument_id,
40 },
41 providers::instruments::InteractiveBrokersInstrumentProvider,
42};
43
44pub(crate) fn should_use_avg_fill_price(avg_fill_price: f64, instrument_id: &InstrumentId) -> bool {
45 avg_fill_price.is_finite()
46 && avg_fill_price != f64::MAX
47 && avg_fill_price != 0.0
48 && (avg_fill_price > 0.0 || is_spread_instrument_id(instrument_id))
49}
50
51pub(crate) fn ib_venue_order_id(order_id: i32, perm_id: i64) -> VenueOrderId {
52 if perm_id != 0 {
53 VenueOrderId::new(format!("PERM-{perm_id}"))
54 } else {
55 VenueOrderId::new(order_id.to_string())
56 }
57}
58
59pub(crate) fn normalized_order_ref(order_ref: &str) -> Option<&str> {
60 if order_ref.is_empty() {
61 return None;
62 }
63
64 Some(
65 order_ref
66 .rsplit_once(':')
67 .map_or(order_ref, |(base, _)| base),
68 )
69}
70
71#[allow(clippy::too_many_arguments)]
82pub fn parse_execution_to_fill_report(
83 execution: &Execution,
84 _contract: &ibapi::contracts::Contract,
85 commission: f64,
86 commission_currency: &str,
87 instrument_id: InstrumentId,
88 account_id: AccountId,
89 instrument_provider: &InteractiveBrokersInstrumentProvider,
90 ts_init: UnixNanos,
91 avg_px: Option<Price>,
92) -> anyhow::Result<FillReport> {
93 let price_magnifier = instrument_provider.get_price_magnifier(&instrument_id) as f64;
95
96 let execution_price = execution.price * price_magnifier;
98
99 let order_side = IbAction::from_str(execution.side.as_str())?.order_side();
101
102 let instrument = instrument_provider
104 .find(&instrument_id)
105 .context("Instrument not found")?;
106
107 let last_qty = Quantity::new(execution.shares, instrument.size_precision());
109 let last_px = Price::new(execution_price, instrument.price_precision());
110
111 let commission_clamped = if commission == -1.0 { 0.0 } else { commission };
113 let commission_money = Money::new(commission_clamped, Currency::from_str(commission_currency)?);
114
115 let ts_event = parse_execution_time(&execution.time)?;
117
118 let trade_id = TradeId::new(&execution.execution_id);
120
121 let venue_order_id = ib_venue_order_id(execution.order_id, execution.perm_id);
122
123 let client_order_id = normalized_order_ref(&execution.order_reference).map(ClientOrderId::new);
124
125 let mut report = FillReport::new(
126 account_id,
127 instrument_id,
128 venue_order_id,
129 trade_id,
130 order_side,
131 last_qty,
132 last_px,
133 commission_money,
134 LiquiditySide::NoLiquiditySide,
135 client_order_id,
136 None, ts_event,
138 ts_init,
139 Some(nautilus_core::UUID4::new()),
140 );
141 report.avg_px = avg_px.map(|price: Price| price.as_decimal());
142
143 Ok(report)
144}
145
146pub fn parse_order_status_to_report(
152 order_status: &OrderStatus,
153 order: Option<&ibapi::orders::Order>,
154 instrument_id: InstrumentId,
155 account_id: AccountId,
156 instrument_provider: &InteractiveBrokersInstrumentProvider,
157 ts_init: UnixNanos,
158) -> anyhow::Result<OrderStatusReport> {
159 let price_magnifier = instrument_provider.get_price_magnifier(&instrument_id) as f64;
161
162 let mut nautilus_status = match IbOrderStatus::from_str(order_status.status.as_str()) {
163 Ok(status) => status.nautilus_status(),
164 _ => {
165 tracing::warn!(
166 "Unknown order status: {}, defaulting to SUBMITTED",
167 order_status.status.as_str()
168 );
169 NautilusOrderStatus::Submitted
170 }
171 };
172
173 let order_side = if let Some(order) = order {
175 IbAction::from(order.action).order_side()
176 } else {
177 OrderSide::Buy
179 };
180
181 let instrument = instrument_provider.find(&instrument_id);
182
183 let size_precision = instrument
185 .as_ref()
186 .map_or(0, |instr| instr.size_precision());
187 let price_precision = instrument
188 .as_ref()
189 .map_or(0, |instr| instr.price_precision());
190
191 let quantity = if let Some(order) = order {
193 Quantity::new(order.total_quantity, size_precision)
194 } else {
195 Quantity::zero(size_precision)
196 };
197
198 let filled_qty = Quantity::new(order_status.filled, size_precision);
200
201 let average_fill_price = order_status.average_fill_price.unwrap_or(0.0);
203 let include_avg_px = should_use_avg_fill_price(average_fill_price, &instrument_id);
204 let avg_px_value = if include_avg_px {
205 average_fill_price * price_magnifier
206 } else {
207 0.0
208 };
209
210 if order_status.filled > 0.0
211 && (order_status.remaining > 0.0
212 || order.is_some_and(|order| order.total_quantity > order_status.filled))
213 {
214 nautilus_status = NautilusOrderStatus::PartiallyFilled;
215 }
216
217 let venue_order_id = ib_venue_order_id(order_status.order_id, order_status.perm_id);
218
219 let client_order_id = order
220 .and_then(|order| normalized_order_ref(&order.order_ref))
221 .map(ClientOrderId::new);
222
223 let order_type = order
225 .map(|order| map_ib_order_type(&order.order_type))
226 .unwrap_or(OrderType::Market);
227
228 let time_in_force = if let Some(order) = order {
230 let ib_time_in_force = IbTimeInForce::from(order.tif.clone());
231 if ib_time_in_force == IbTimeInForce::GoodTilDate || !order.good_till_date.is_empty() {
232 TimeInForce::Gtd
233 } else {
234 ib_time_in_force.nautilus_time_in_force()
235 }
236 } else {
237 TimeInForce::Day };
239
240 let mut report = OrderStatusReport::new(
242 account_id,
243 instrument_id,
244 client_order_id,
245 venue_order_id,
246 order_side,
247 order_type,
248 time_in_force,
249 nautilus_status,
250 quantity,
251 filled_qty,
252 ts_init, ts_init, ts_init,
255 Some(nautilus_core::UUID4::new()), );
257
258 if let Some(order) = order {
260 if let Some(limit_price) = order.limit_price {
261 let converted = limit_price * price_magnifier;
262 report = report.with_price(Price::new(converted, price_precision));
263 }
264
265 let (trigger_price, limit_offset, trailing_offset, trailing_offset_type) =
266 parse_ib_order_pricing_fields(order, order_type, price_magnifier, price_precision)?;
267
268 if let Some(trigger_price) = trigger_price {
269 report = report.with_trigger_price(trigger_price);
270 }
271
272 if let Some(limit_offset) = limit_offset {
273 report = report.with_limit_offset(limit_offset);
274 }
275
276 if let Some(trailing_offset) = trailing_offset {
277 report = report.with_trailing_offset(trailing_offset);
278 }
279
280 if let Some(trailing_offset_type) = trailing_offset_type {
281 report = report.with_trailing_offset_type(trailing_offset_type);
282 }
283 }
284
285 if include_avg_px {
286 report = report.with_avg_px(avg_px_value)?;
287 }
288
289 Ok(report)
290}
291
292fn map_ib_order_type(order_type: &str) -> OrderType {
293 IbOrderType::from_str(order_type).map_or(OrderType::Market, IbOrderType::nautilus_order_type)
294}
295
296fn parse_ib_order_pricing_fields(
297 order: &ibapi::orders::Order,
298 order_type: OrderType,
299 price_magnifier: f64,
300 price_precision: u8,
301) -> anyhow::Result<(
302 Option<Price>,
303 Option<Decimal>,
304 Option<Decimal>,
305 Option<TrailingOffsetType>,
306)> {
307 let mut trigger_price = None;
308 let mut limit_offset = None;
309 let mut trailing_offset = None;
310 let mut trailing_offset_type = None;
311
312 if matches!(
313 order_type,
314 OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
315 ) {
316 if let Some(trail_stop_price) = order.trail_stop_price {
317 trigger_price = Some(Price::new(
318 trail_stop_price * price_magnifier,
319 price_precision,
320 ));
321 }
322
323 if let Some(aux_price) = order.aux_price {
324 trailing_offset = Some(decimal_from_f64(aux_price)?);
325 trailing_offset_type = Some(TrailingOffsetType::Price);
326 } else if let Some(trailing_percent) = order.trailing_percent {
327 trailing_offset = Some(decimal_from_f64(trailing_percent)? * Decimal::from(100));
328 trailing_offset_type = Some(TrailingOffsetType::BasisPoints);
329 }
330
331 if order_type == OrderType::TrailingStopLimit
332 && let Some(limit_price_offset) = order.limit_price_offset
333 {
334 limit_offset = Some(decimal_from_f64(limit_price_offset)?);
335 trailing_offset_type = Some(trailing_offset_type.unwrap_or(TrailingOffsetType::Price));
336 }
337
338 return Ok((
339 trigger_price,
340 limit_offset,
341 trailing_offset,
342 trailing_offset_type,
343 ));
344 }
345
346 if let Some(aux_price) = order.aux_price {
347 trigger_price = Some(Price::new(aux_price * price_magnifier, price_precision));
348 }
349
350 Ok((
351 trigger_price,
352 limit_offset,
353 trailing_offset,
354 trailing_offset_type,
355 ))
356}
357
358fn decimal_from_f64(value: f64) -> anyhow::Result<Decimal> {
359 Decimal::from_str(&value.to_string())
360 .with_context(|| format!("Failed to convert IB floating-point value {value} to Decimal"))
361}
362
363pub fn parse_execution_time(time_str: &str) -> anyhow::Result<UnixNanos> {
377 fn parse_utc(
378 time_str: &str,
379 format: &[time::format_description::FormatItem<'_>],
380 ) -> anyhow::Result<UnixNanos> {
381 let dt = PrimitiveDateTime::parse(time_str, format).map_err(|e| {
382 anyhow::anyhow!("Failed to parse execution timestamp '{time_str}': {e}")
383 })?;
384 let nanos: u64 = dt
385 .assume_utc()
386 .unix_timestamp_nanos()
387 .try_into()
388 .map_err(|_| {
389 anyhow::anyhow!("Execution timestamp '{time_str}' was before Unix epoch")
390 })?;
391 Ok(UnixNanos::new(nanos))
392 }
393
394 if time_str.contains('-') && !time_str.contains(' ') {
395 let format = format_description!("[year][month][day]-[hour]:[minute]:[second]");
396 return parse_utc(time_str, format);
397 }
398
399 let parts: Vec<&str> = time_str.split(' ').collect();
400
401 if parts.len() < 2 {
402 anyhow::bail!("Invalid execution time format: {time_str}");
403 }
404
405 let format = format_description!("[year][month][day] [hour]:[minute]:[second]");
406 let date_str = format!("{} {}", parts[0], parts[1]);
407
408 if parts.len() == 2 {
409 return parse_utc(&date_str, format);
410 }
411
412 let timezone = parts[2];
413 if !matches!(timezone, "Universal" | "UTC" | "Etc/UTC" | "GMT" | "Z") {
414 anyhow::bail!(
415 "Unsupported non-UTC execution timezone '{timezone}' in '{time_str}'. Configure TWS / IB Gateway to emit UTC timestamps"
416 );
417 }
418
419 parse_utc(&date_str, format)
420}
421
422#[cfg(test)]
423mod tests {
424 use ibapi::{
425 contracts::Contract,
426 orders::{Action, ExecutionSide, Liquidity, Order, OrderStatusKind},
427 };
428 use nautilus_model::{
429 enums::TrailingOffsetType,
430 identifiers::{Symbol, Venue},
431 instruments::{InstrumentAny, stubs::equity_aapl},
432 };
433 use rust_decimal::Decimal;
434
435 use super::*;
436 use crate::{
437 config::InteractiveBrokersInstrumentProviderConfig,
438 providers::instruments::InteractiveBrokersInstrumentProvider,
439 };
440
441 fn create_test_instrument_provider() -> InteractiveBrokersInstrumentProvider {
442 let config = InteractiveBrokersInstrumentProviderConfig::default();
443 InteractiveBrokersInstrumentProvider::new(config)
444 }
445
446 fn create_test_instrument_id() -> InstrumentId {
447 InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"))
448 }
449
450 use rstest::rstest;
451
452 #[rstest]
453 fn test_parse_execution_time_hyphenated_format() {
454 let time_str = "20250225-15:15:00";
455 let result = parse_execution_time(time_str);
456 assert!(result.is_ok());
457 let timestamp = result.unwrap();
458 assert!(timestamp.as_i64() > 0);
459 }
460
461 #[rstest]
462 fn test_parse_execution_time_with_unsupported_non_utc_timezone() {
463 let time_str = "20230223 00:43:36 America/New_York";
464 let result = parse_execution_time(time_str);
465 assert!(result.is_err());
466 }
467
468 #[rstest]
469 fn test_parse_execution_time_utc() {
470 let time_str = "20230223 00:43:36 Universal";
471 let result = parse_execution_time(time_str);
472 assert!(result.is_ok());
473 let timestamp = result.unwrap();
474 assert!(timestamp.as_i64() > 0);
475 }
476
477 #[rstest]
478 fn test_parse_execution_time_no_timezone_assumes_utc() {
479 let time_str = "20230223 00:43:36";
480 let result = parse_execution_time(time_str);
481 assert!(result.is_ok());
482 let timestamp = result.unwrap();
483 assert!(timestamp.as_i64() > 0);
484 }
485
486 #[rstest]
487 fn test_parse_execution_time_invalid_format() {
488 let time_str = "invalid format";
489 let result = parse_execution_time(time_str);
490 assert!(result.is_err());
491 }
492
493 #[rstest]
494 fn test_parse_execution_time_short_format() {
495 let time_str = "20230223 00:43";
496 let result = parse_execution_time(time_str);
497 assert!(result.is_err());
498 }
499
500 #[rstest]
501 fn test_parse_order_status_to_report_submitted() {
502 let instrument_provider = create_test_instrument_provider();
503 let instrument_id = create_test_instrument_id();
504 let account_id = AccountId::from("IB-001");
505
506 let order_status = OrderStatus {
507 order_id: 12345,
508 status: OrderStatusKind::Submitted,
509 filled: 0.0,
510 remaining: 100.0,
511 average_fill_price: Some(0.0),
512 perm_id: 0,
513 parent_id: 0,
514 last_fill_price: Some(0.0),
515 client_id: 0,
516 why_held: String::new(),
517 market_cap_price: Some(0.0),
518 };
519
520 let result = parse_order_status_to_report(
521 &order_status,
522 None,
523 instrument_id,
524 account_id,
525 &instrument_provider,
526 UnixNanos::new(0),
527 );
528
529 if let Err(e) = result {
531 let error_msg = e.to_string();
532 assert!(
533 error_msg.contains("not found") || error_msg.contains("instrument"),
534 "Unexpected error: {}",
535 error_msg
536 );
537 }
538 }
539
540 #[rstest]
541 fn test_parse_order_status_to_report_filled() {
542 let instrument_provider = create_test_instrument_provider();
543 let instrument_id = create_test_instrument_id();
544 let account_id = AccountId::from("IB-001");
545
546 let order_status = OrderStatus {
547 order_id: 12345,
548 status: OrderStatusKind::Filled,
549 filled: 100.0,
550 remaining: 0.0,
551 average_fill_price: Some(150.25),
552 perm_id: 0,
553 parent_id: 0,
554 last_fill_price: Some(150.25),
555 client_id: 0,
556 why_held: String::new(),
557 market_cap_price: Some(0.0),
558 };
559
560 let result = parse_order_status_to_report(
561 &order_status,
562 None,
563 instrument_id,
564 account_id,
565 &instrument_provider,
566 UnixNanos::new(0),
567 );
568
569 if let Err(e) = result {
571 let error_msg = e.to_string();
572 assert!(
573 error_msg.contains("not found") || error_msg.contains("instrument"),
574 "Unexpected error: {}",
575 error_msg
576 );
577 }
578 }
579
580 #[rstest]
581 fn test_parse_order_status_to_report_spread_allows_negative_avg_fill_price() {
582 let instrument_provider = create_test_instrument_provider();
583 let instrument_id = InstrumentId::new(
584 Symbol::from("(1)SPY C400_((1))SPY C410"),
585 Venue::from("SMART"),
586 );
587 let account_id = AccountId::from("IB-001");
588
589 let order_status = OrderStatus {
590 order_id: 12345,
591 status: OrderStatusKind::Filled,
592 filled: 1.0,
593 remaining: 0.0,
594 average_fill_price: Some(-2.25),
595 perm_id: 0,
596 parent_id: 0,
597 last_fill_price: Some(-2.25),
598 client_id: 0,
599 why_held: String::new(),
600 market_cap_price: Some(0.0),
601 };
602
603 let report = parse_order_status_to_report(
604 &order_status,
605 None,
606 instrument_id,
607 account_id,
608 &instrument_provider,
609 UnixNanos::new(0),
610 )
611 .unwrap();
612
613 assert_eq!(report.avg_px, Some(Decimal::from_str("-2.25").unwrap()));
614 }
615
616 #[rstest]
617 fn test_parse_order_status_to_report_inactive_maps_to_rejected() {
618 let instrument_provider = create_test_instrument_provider();
619 let instrument_id = create_test_instrument_id();
620 let account_id = AccountId::from("IB-001");
621
622 let order_status = OrderStatus {
623 order_id: 12345,
624 status: OrderStatusKind::Inactive,
625 filled: 0.0,
626 remaining: 100.0,
627 average_fill_price: Some(0.0),
628 perm_id: 0,
629 parent_id: 0,
630 last_fill_price: Some(0.0),
631 client_id: 0,
632 why_held: String::new(),
633 market_cap_price: Some(0.0),
634 };
635
636 let report = parse_order_status_to_report(
637 &order_status,
638 None,
639 instrument_id,
640 account_id,
641 &instrument_provider,
642 UnixNanos::new(0),
643 )
644 .unwrap();
645
646 assert_eq!(report.order_status, NautilusOrderStatus::Rejected);
647 }
648
649 #[rstest]
650 fn test_parse_order_status_to_report_partial_fill_and_perm_fallback() {
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: 0,
657 status: OrderStatusKind::Submitted,
658 filled: 3.0,
659 remaining: 7.0,
660 average_fill_price: Some(150.25),
661 perm_id: 123_456,
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 let order = Order {
669 action: Action::Buy,
670 total_quantity: 10.0,
671 order_type: "LMT".to_string(),
672 limit_price: Some(150.25),
673 order_ref: "O-20260527-001:123".to_string(),
674 ..Default::default()
675 };
676
677 let report = parse_order_status_to_report(
678 &order_status,
679 Some(&order),
680 instrument_id,
681 account_id,
682 &instrument_provider,
683 UnixNanos::new(0),
684 )
685 .unwrap();
686
687 assert_eq!(report.order_status, NautilusOrderStatus::PartiallyFilled);
688 assert_eq!(report.venue_order_id.to_string(), "PERM-123456");
689 assert_eq!(
690 report.client_order_id,
691 Some(ClientOrderId::from("O-20260527-001"))
692 );
693 }
694
695 #[rstest]
696 fn test_ib_venue_order_id_prefers_perm_id_and_falls_back_to_order_id() {
697 assert_eq!(ib_venue_order_id(123, 456).to_string(), "PERM-456");
698 assert_eq!(ib_venue_order_id(123, 0).to_string(), "123");
699 }
700
701 #[rstest]
702 fn test_normalized_order_ref_strips_ib_suffix() {
703 assert_eq!(normalized_order_ref("O-001:123"), Some("O-001"));
704 assert_eq!(normalized_order_ref("O-001"), Some("O-001"));
705 assert_eq!(normalized_order_ref(""), None);
706 }
707
708 #[rstest]
709 #[case(
710 "MKT",
711 None,
712 None,
713 None,
714 None,
715 OrderType::Market,
716 None,
717 None,
718 None,
719 None,
720 TrailingOffsetType::NoTrailingOffset
721 )]
722 #[case(
723 "LMT",
724 Some(185.0),
725 None,
726 None,
727 None,
728 OrderType::Limit,
729 Some(Price::new(185.0, 0)),
730 None,
731 None,
732 None,
733 TrailingOffsetType::NoTrailingOffset
734 )]
735 #[case(
736 "MIT",
737 None,
738 Some(180.0),
739 None,
740 None,
741 OrderType::MarketIfTouched,
742 None,
743 Some(Price::new(180.0, 0)),
744 None,
745 None,
746 TrailingOffsetType::NoTrailingOffset
747 )]
748 #[case(
749 "LIT",
750 Some(179.0),
751 Some(180.0),
752 None,
753 None,
754 OrderType::LimitIfTouched,
755 Some(Price::new(179.0, 0)),
756 Some(Price::new(180.0, 0)),
757 None,
758 None,
759 TrailingOffsetType::NoTrailingOffset
760 )]
761 #[case(
762 "STP",
763 None,
764 Some(180.0),
765 None,
766 None,
767 OrderType::StopMarket,
768 None,
769 Some(Price::new(180.0, 0)),
770 None,
771 None,
772 TrailingOffsetType::NoTrailingOffset
773 )]
774 #[case(
775 "STP LMT",
776 Some(179.0),
777 Some(180.0),
778 None,
779 None,
780 OrderType::StopLimit,
781 Some(Price::new(179.0, 0)),
782 Some(Price::new(180.0, 0)),
783 None,
784 None,
785 TrailingOffsetType::NoTrailingOffset
786 )]
787 #[case(
788 "TRAIL LIMIT",
789 None,
790 Some(2.5),
791 Some(185.0),
792 Some(0.25),
793 OrderType::TrailingStopLimit,
794 None,
795 Some(Price::new(185.0, 0)),
796 Some(Decimal::from_str("0.25").unwrap()),
797 Some(Decimal::from_str("2.5").unwrap()),
798 TrailingOffsetType::Price,
799 )]
800 fn test_parse_order_status_to_report_maps_pricing_fields_by_order_type(
801 #[case] ib_order_type: &str,
802 #[case] limit_price: Option<f64>,
803 #[case] aux_price: Option<f64>,
804 #[case] trail_stop_price: Option<f64>,
805 #[case] limit_price_offset: Option<f64>,
806 #[case] expected_order_type: OrderType,
807 #[case] expected_price: Option<Price>,
808 #[case] expected_trigger_price: Option<Price>,
809 #[case] expected_limit_offset: Option<Decimal>,
810 #[case] expected_trailing_offset: Option<Decimal>,
811 #[case] expected_trailing_offset_type: TrailingOffsetType,
812 ) {
813 let instrument_provider = create_test_instrument_provider();
814 let instrument_id = create_test_instrument_id();
815 let account_id = AccountId::from("IB-001");
816
817 let order_status = OrderStatus {
818 order_id: 12345,
819 status: OrderStatusKind::Submitted,
820 filled: 0.0,
821 remaining: 5.0,
822 average_fill_price: Some(0.0),
823 perm_id: 0,
824 parent_id: 0,
825 last_fill_price: Some(0.0),
826 client_id: 0,
827 why_held: String::new(),
828 market_cap_price: Some(0.0),
829 };
830
831 let order = Order {
832 action: Action::Buy,
833 total_quantity: 5.0,
834 order_type: ib_order_type.to_string(),
835 limit_price,
836 aux_price,
837 trail_stop_price,
838 limit_price_offset,
839 tif: ibapi::orders::TimeInForce::GoodTilCanceled,
840 ..Default::default()
841 };
842
843 let report = parse_order_status_to_report(
844 &order_status,
845 Some(&order),
846 instrument_id,
847 account_id,
848 &instrument_provider,
849 UnixNanos::new(0),
850 )
851 .unwrap();
852
853 assert_eq!(report.order_type, expected_order_type);
854 assert_eq!(report.price, expected_price);
855 assert_eq!(report.trigger_price, expected_trigger_price);
856 assert_eq!(report.limit_offset, expected_limit_offset);
857 assert_eq!(report.trailing_offset, expected_trailing_offset);
858 assert_eq!(report.trailing_offset_type, expected_trailing_offset_type);
859 }
860
861 #[rstest]
862 fn test_parse_order_status_to_report_maps_trailing_percent_to_basis_points() {
863 let instrument_provider = create_test_instrument_provider();
864 let instrument_id = create_test_instrument_id();
865 let account_id = AccountId::from("IB-001");
866
867 let order_status = OrderStatus {
868 order_id: 12345,
869 status: OrderStatusKind::Submitted,
870 filled: 0.0,
871 remaining: 5.0,
872 average_fill_price: Some(0.0),
873 perm_id: 0,
874 parent_id: 0,
875 last_fill_price: Some(0.0),
876 client_id: 0,
877 why_held: String::new(),
878 market_cap_price: Some(0.0),
879 };
880
881 let order = Order {
882 action: Action::Buy,
883 total_quantity: 5.0,
884 order_type: "TRAIL".to_string(),
885 trail_stop_price: Some(185.0),
886 trailing_percent: Some(2.5),
887 tif: ibapi::orders::TimeInForce::GoodTilCanceled,
888 ..Default::default()
889 };
890
891 let report = parse_order_status_to_report(
892 &order_status,
893 Some(&order),
894 instrument_id,
895 account_id,
896 &instrument_provider,
897 UnixNanos::new(0),
898 )
899 .unwrap();
900
901 assert_eq!(report.order_type, OrderType::TrailingStopMarket);
902 assert_eq!(report.trigger_price, Some(Price::new(185.0, 0)));
903 assert_eq!(
904 report.trailing_offset,
905 Some(Decimal::from_str("250").unwrap())
906 );
907 assert_eq!(report.trailing_offset_type, TrailingOffsetType::BasisPoints);
908 assert_eq!(report.limit_offset, None);
909 }
910
911 #[rstest]
912 fn test_parse_execution_to_fill_report_buy() {
913 let instrument_provider = create_test_instrument_provider();
914 let instrument_id = create_test_instrument_id();
915 let account_id = AccountId::from("IB-001");
916
917 let execution = Execution {
918 order_id: 12345,
919 client_id: 0,
920 execution_id: String::from("EXEC-001"),
921 time: String::from("20230223 00:43:36 Universal"),
922 account_number: String::new(),
923 exchange: String::new(),
924 side: ExecutionSide::Bought,
925 shares: 100.0,
926 price: 150.25,
927 perm_id: 0,
928 liquidation: 0,
929 cumulative_quantity: 100.0,
930 average_price: 150.25,
931 order_reference: String::from("ORDER-REF-001"),
932 ev_rule: String::new(),
933 ev_multiplier: None,
934 model_code: String::new(),
935 last_liquidity: Liquidity::None,
936 pending_price_revision: false,
937 submitter: String::new(),
938 };
939
940 let contract = Contract::default();
941 let result = parse_execution_to_fill_report(
942 &execution,
943 &contract,
944 1.0,
945 "USD",
946 instrument_id,
947 account_id,
948 &instrument_provider,
949 UnixNanos::new(0),
950 None, );
952
953 match result {
955 Err(e) => {
956 let error_msg = e.to_string();
957 assert!(
958 error_msg.contains("not found") || error_msg.contains("instrument"),
959 "Unexpected error: {}",
960 error_msg
961 );
962 }
963 Ok(fill) => {
964 assert_eq!(fill.order_side, OrderSide::Buy);
965 assert_eq!(fill.trade_id.to_string(), "EXEC-001");
966 }
967 }
968 }
969
970 #[rstest]
971 fn test_parse_execution_to_fill_report_clamps_only_pending_commission_sentinel() {
972 let instrument_provider = create_test_instrument_provider();
973 let instrument = equity_aapl();
974 let instrument_id = instrument.id();
975 instrument_provider.insert_test_instrument(InstrumentAny::from(instrument), 265598, 1);
976 let account_id = AccountId::from("IB-001");
977 let contract = Contract::default();
978
979 for (commission, expected) in [(-1.0, 0.0), (-0.25, -0.25)] {
980 let execution = Execution {
981 order_id: 12345,
982 client_id: 0,
983 execution_id: format!("EXEC-{commission}"),
984 time: String::from("20230223 00:43:36 Universal"),
985 account_number: String::new(),
986 exchange: String::new(),
987 side: ExecutionSide::Bought,
988 shares: 100.0,
989 price: 150.25,
990 perm_id: 0,
991 liquidation: 0,
992 cumulative_quantity: 100.0,
993 average_price: 150.25,
994 order_reference: String::from("ORDER-REF-001"),
995 ev_rule: String::new(),
996 ev_multiplier: None,
997 model_code: String::new(),
998 last_liquidity: Liquidity::None,
999 pending_price_revision: false,
1000 submitter: String::new(),
1001 };
1002
1003 let report = parse_execution_to_fill_report(
1004 &execution,
1005 &contract,
1006 commission,
1007 "USD",
1008 instrument_id,
1009 account_id,
1010 &instrument_provider,
1011 UnixNanos::new(0),
1012 None,
1013 )
1014 .unwrap();
1015
1016 assert_eq!(report.commission, Money::new(expected, Currency::USD()));
1017 }
1018 }
1019
1020 #[rstest]
1021 fn test_parse_execution_to_fill_report_sell() {
1022 let instrument_provider = create_test_instrument_provider();
1023 let instrument_id = create_test_instrument_id();
1024 let account_id = AccountId::from("IB-001");
1025
1026 let execution = Execution {
1027 order_id: 12345,
1028 client_id: 0,
1029 execution_id: String::from("EXEC-002"),
1030 time: String::from("20230223 00:43:36 Universal"),
1031 account_number: String::new(),
1032 exchange: String::new(),
1033 side: ExecutionSide::Sold,
1034 shares: 50.0,
1035 price: 151.0,
1036 perm_id: 0,
1037 liquidation: 0,
1038 cumulative_quantity: 50.0,
1039 average_price: 151.0,
1040 order_reference: String::new(),
1041 ev_rule: String::new(),
1042 ev_multiplier: None,
1043 model_code: String::new(),
1044 last_liquidity: Liquidity::None,
1045 pending_price_revision: false,
1046 submitter: String::new(),
1047 };
1048
1049 let contract = Contract::default();
1050 let result = parse_execution_to_fill_report(
1051 &execution,
1052 &contract,
1053 0.5,
1054 "USD",
1055 instrument_id,
1056 account_id,
1057 &instrument_provider,
1058 UnixNanos::new(0),
1059 None, );
1061
1062 match result {
1064 Err(e) => {
1065 let error_msg = e.to_string();
1066 assert!(
1067 error_msg.contains("not found") || error_msg.contains("instrument"),
1068 "Unexpected error: {}",
1069 error_msg
1070 );
1071 }
1072 Ok(fill) => {
1073 assert_eq!(fill.order_side, OrderSide::Sell);
1074 }
1075 }
1076 }
1077}