1use std::str::FromStr;
19
20use ibapi::orders::{Action, Order as IBOrder, TimeInForce};
21use nautilus_core::UnixNanos;
22use nautilus_model::{
23 enums::{
24 OrderSide, OrderType as NautilusOrderType, TimeInForce as NautilusTimeInForce, TriggerType,
25 },
26 orders::{Order as NautilusOrder, any::OrderAny},
27 types::Price,
28};
29use rust_decimal::Decimal;
30
31use crate::{
32 common::enums::{IbAction, IbOrderType, IbTimeInForce, IbTriggerMethod},
33 providers::instruments::InteractiveBrokersInstrumentProvider,
34};
35
36mod policy;
37mod tags;
38
39use self::{
40 policy::{
41 apply_display_quantity_policy, apply_expire_time_policy, apply_quantity_policy,
42 apply_trailing_order_policy,
43 },
44 tags::apply_ib_order_tags,
45};
46
47pub fn nautilus_order_to_ib_order(
53 order: &OrderAny,
54 instrument_provider: &InteractiveBrokersInstrumentProvider,
55 order_id: i32,
56 order_ref: &str,
57) -> anyhow::Result<IBOrder> {
58 let action = match order.order_side() {
59 OrderSide::Buy => Action::Buy,
60 OrderSide::Sell => Action::Sell,
61 };
62
63 let quantity = order.quantity().as_f64();
64 let price_magnifier = instrument_provider.get_price_magnifier(&order.instrument_id());
65
66 let (order_type, limit_price, aux_price) = transform_order_type(
67 order.order_type(),
68 order.time_in_force(),
69 order.price(),
70 order.trigger_price(),
71 price_magnifier,
72 );
73 let tif = transform_time_in_force(order.time_in_force());
74
75 let mut ib_order = IBOrder {
76 order_id,
77 action,
78 total_quantity: quantity,
79 order_type: order_type.to_string(),
80 limit_price,
81 aux_price,
82 tif,
83 order_ref: order_ref.to_string(),
84 account: String::new(),
85 ..Default::default()
86 };
87
88 apply_expire_time_policy(&mut ib_order, order);
89 apply_trailing_order_policy(&mut ib_order, order, price_magnifier)?;
90 apply_display_quantity_policy(&mut ib_order, order);
91
92 apply_ib_order_tags(&mut ib_order, order.tags())?;
93
94 let tagged_action = IbAction::from(ib_order.action);
97 if tagged_action.order_side() != order.order_side() {
98 anyhow::bail!(
99 "IBOrderTags action {tagged_action} conflicts with the {} order side",
100 order.order_side(),
101 );
102 }
103
104 apply_quantity_policy(&mut ib_order, order, instrument_provider)?;
106
107 Ok(ib_order)
108}
109
110fn transform_order_type(
112 order_type: NautilusOrderType,
113 time_in_force: NautilusTimeInForce,
114 price: Option<Price>,
115 trigger_price: Option<Price>,
116 price_magnifier: i32,
117) -> (&'static str, Option<f64>, Option<f64>) {
118 let ib_order_type = IbOrderType::from_nautilus(order_type, time_in_force);
119
120 let (limit_price, aux_price) = match order_type {
121 NautilusOrderType::Market | NautilusOrderType::MarketToLimit => (None, None),
122 NautilusOrderType::Limit => (convert_price_opt(price, price_magnifier), None),
123 NautilusOrderType::StopMarket | NautilusOrderType::MarketIfTouched => {
124 (None, convert_price_opt(trigger_price, price_magnifier))
125 }
126 NautilusOrderType::StopLimit | NautilusOrderType::LimitIfTouched => (
127 convert_price_opt(price, price_magnifier),
128 convert_price_opt(trigger_price, price_magnifier),
129 ),
130 NautilusOrderType::TrailingStopMarket => (None, None),
131 NautilusOrderType::TrailingStopLimit => (convert_price_opt(price, price_magnifier), None),
132 };
133
134 (ib_order_type.as_str(), limit_price, aux_price)
135}
136
137fn transform_time_in_force(tif: NautilusTimeInForce) -> TimeInForce {
139 IbTimeInForce::from_nautilus(tif).ibapi_time_in_force()
140}
141
142pub(super) fn format_ib_datetime(value: UnixNanos) -> String {
143 value
144 .to_datetime_utc()
145 .strftime("%Y%m%d %H:%M:%S UTC")
146 .to_string()
147}
148
149pub(super) fn convert_price(price: Price, magnifier: i32) -> f64 {
155 if magnifier <= 1 {
156 return price.as_f64();
157 }
158
159 convert_decimal(price.as_decimal(), magnifier)
160 .unwrap_or_else(|_| price.as_f64() * f64::from(magnifier))
161}
162
163pub(super) fn convert_decimal(amount: Decimal, magnifier: i32) -> anyhow::Result<f64> {
165 let scaled = amount
166 .checked_mul(Decimal::from(magnifier.max(1)))
167 .ok_or_else(|| anyhow::anyhow!("Price amount {amount} times {magnifier} overflows"))?;
168 scaled
169 .to_string()
170 .parse::<f64>()
171 .map_err(|e| anyhow::anyhow!("Failed to convert {scaled} to f64: {e}"))
172}
173
174pub(super) fn convert_f64(amount: f64, magnifier: i32) -> f64 {
176 if magnifier <= 1 {
177 return amount;
178 }
179
180 Decimal::from_str(&amount.to_string())
181 .map_err(anyhow::Error::from)
182 .and_then(|amount| convert_decimal(amount, magnifier))
183 .unwrap_or_else(|_| amount * f64::from(magnifier))
184}
185
186pub(super) fn from_ib_price(price: f64, magnifier: i32) -> f64 {
189 price / f64::from(magnifier.max(1))
190}
191
192fn convert_price_opt(price: Option<Price>, magnifier: i32) -> Option<f64> {
193 price.map(|p| convert_price(p, magnifier))
194}
195
196pub(super) fn trigger_type_to_ib_trigger_method(
197 trigger_type: TriggerType,
198) -> ibapi::orders::conditions::TriggerMethod {
199 let value = match trigger_type {
200 TriggerType::Default => IbTriggerMethod::Default,
201 TriggerType::DoubleBidAsk => IbTriggerMethod::DoubleBidAsk,
202 TriggerType::LastPrice => IbTriggerMethod::Last,
203 TriggerType::DoubleLast => IbTriggerMethod::DoubleLast,
204 TriggerType::BidAsk => IbTriggerMethod::BidAsk,
205 TriggerType::LastOrBidAsk => IbTriggerMethod::LastOrBidAsk,
206 TriggerType::MidPoint => IbTriggerMethod::Midpoint,
207 _ => IbTriggerMethod::Default,
208 };
209
210 value.ibapi_trigger_method()
211}
212
213pub(super) const fn ib_trigger_method_to_trigger_type(
214 trigger_method: ibapi::orders::conditions::TriggerMethod,
215) -> TriggerType {
216 match trigger_method {
217 ibapi::orders::conditions::TriggerMethod::Default => TriggerType::Default,
218 ibapi::orders::conditions::TriggerMethod::DoubleBidAsk => TriggerType::DoubleBidAsk,
219 ibapi::orders::conditions::TriggerMethod::Last => TriggerType::LastPrice,
220 ibapi::orders::conditions::TriggerMethod::DoubleLast => TriggerType::DoubleLast,
221 ibapi::orders::conditions::TriggerMethod::BidAsk => TriggerType::BidAsk,
222 ibapi::orders::conditions::TriggerMethod::LastOrBidAsk => TriggerType::LastOrBidAsk,
223 ibapi::orders::conditions::TriggerMethod::Midpoint => TriggerType::MidPoint,
224 ibapi::orders::conditions::TriggerMethod::Unknown(_) => TriggerType::Default,
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use ibapi::orders::OrderCondition;
231 use nautilus_model::{
232 enums::{OrderSide, OrderType, TimeInForce as NautilusTimeInForce, TrailingOffsetType},
233 identifiers::{InstrumentId, OrderListId, Symbol as NautilusSymbol, Venue},
234 instruments::{
235 InstrumentAny,
236 stubs::{btcusd_inverse_perp, equity_aapl},
237 },
238 orders::OrderTestBuilder,
239 types::{Price, Quantity},
240 };
241 use rstest::rstest;
242 use rust_decimal_macros::dec;
243 use ustr::Ustr;
244
245 use super::*;
246 use crate::config::InteractiveBrokersInstrumentProviderConfig;
247
248 fn create_quote_quantity_order(
249 side: OrderSide,
250 order_type: OrderType,
251 time_in_force: NautilusTimeInForce,
252 tags_json: Option<&str>,
253 ) -> (OrderAny, InteractiveBrokersInstrumentProvider) {
254 let instrument = btcusd_inverse_perp(1, 0, Price::from("0.5"), Quantity::from("1"));
255 let instrument_id = instrument.id;
256 let provider = InteractiveBrokersInstrumentProvider::new(
257 InteractiveBrokersInstrumentProviderConfig::default(),
258 )
259 .unwrap();
260 provider.insert_test_instrument(InstrumentAny::CryptoPerpetual(instrument), 4242, 1);
261
262 let mut builder = OrderTestBuilder::new(order_type);
263 builder
264 .instrument_id(instrument_id)
265 .side(side)
266 .quantity(Quantity::from("20"))
267 .time_in_force(time_in_force)
268 .quote_quantity(true);
269
270 if order_type == OrderType::Limit {
271 builder.price(Price::from("87306.5"));
272 }
273
274 if let Some(tags_json) = tags_json {
275 builder.tags(vec![Ustr::from(&format!("IBOrderTags:{tags_json}"))]);
276 }
277
278 (builder.build(), provider)
279 }
280
281 #[rstest]
282 fn test_quote_quantity_inverse_market_buy_maps_to_cash_qty() {
283 let (order, provider) = create_quote_quantity_order(
284 OrderSide::Buy,
285 OrderType::Market,
286 NautilusTimeInForce::Gtc,
287 None,
288 );
289
290 let ib_order = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
291 .expect("cash quantity is valid for an inverse MARKET BUY");
292
293 assert_eq!(ib_order.cash_qty, Some(20.0));
294 assert_eq!(ib_order.total_quantity, 0.0);
295 }
296
297 #[rstest]
298 #[case::limit(
300 OrderSide::Buy,
301 OrderType::Limit,
302 NautilusTimeInForce::Gtc,
303 None,
304 "UNSUPPORTED_QUOTE_QUANTITY: Interactive Brokers only accepts a quote quantity (`cash_qty`) for MARKET orders (IB error 10244 for LIMIT sent as LMT); use the base quantity"
305 )]
306 #[case::market_at_the_close(
307 OrderSide::Buy,
308 OrderType::Market,
309 NautilusTimeInForce::AtTheClose,
310 None,
311 "UNSUPPORTED_QUOTE_QUANTITY: Interactive Brokers only accepts a quote quantity (`cash_qty`) for MARKET orders (IB error 10244 for MARKET sent as MOC); use the base quantity"
312 )]
313 #[case::market_tagged_limit(
314 OrderSide::Buy,
315 OrderType::Market,
316 NautilusTimeInForce::Gtc,
317 Some(r#"{"orderType":"LMT"}"#),
318 "UNSUPPORTED_QUOTE_QUANTITY: Interactive Brokers only accepts a quote quantity (`cash_qty`) for MARKET orders (IB error 10244 for MARKET sent as LMT); use the base quantity"
319 )]
320 #[case::sell(
321 OrderSide::Sell,
322 OrderType::Market,
323 NautilusTimeInForce::Gtc,
324 None,
325 "UNSUPPORTED_QUOTE_QUANTITY: Interactive Brokers only accepts a quote quantity (`cash_qty`) for BUY orders; a SELL must use the base quantity"
326 )]
327 #[case::sell_tagged_buy(
328 OrderSide::Sell,
329 OrderType::Market,
330 NautilusTimeInForce::Gtc,
331 Some(r#"{"action":"BUY"}"#),
332 "IBOrderTags action BUY conflicts with the SELL order side"
333 )]
334 #[case::buy_tagged_sell(
335 OrderSide::Buy,
336 OrderType::Market,
337 NautilusTimeInForce::Gtc,
338 Some(r#"{"action":"SELL"}"#),
339 "IBOrderTags action SELL conflicts with the BUY order side"
340 )]
341 #[case::tagged_cash_qty(
342 OrderSide::Buy,
343 OrderType::Market,
344 NautilusTimeInForce::Gtc,
345 Some(r#"{"cashQty":5.0}"#),
346 "IBOrderTags cannot set cash_qty, which the adapter sets from the order"
347 )]
348 fn test_quote_quantity_order_is_denied_before_submission(
349 #[case] side: OrderSide,
350 #[case] order_type: OrderType,
351 #[case] time_in_force: NautilusTimeInForce,
352 #[case] tags_json: Option<&str>,
353 #[case] expected: &str,
354 ) {
355 let (order, provider) =
356 create_quote_quantity_order(side, order_type, time_in_force, tags_json);
357
358 let err = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
359 .expect_err("the quote quantity order must be denied");
360
361 let message = format!("{err:#}");
362 assert_eq!(message, expected);
363 }
364
365 #[rstest]
366 fn test_quote_quantity_without_inverse_instrument_is_rejected() {
367 let instrument_id = InstrumentId::new(NautilusSymbol::from("AAPL"), Venue::from("NASDAQ"));
368 let provider = InteractiveBrokersInstrumentProvider::new(
369 InteractiveBrokersInstrumentProviderConfig::default(),
370 )
371 .unwrap();
372 let order = OrderTestBuilder::new(OrderType::Market)
373 .instrument_id(instrument_id)
374 .side(OrderSide::Buy)
375 .quantity(Quantity::from("1000"))
376 .quote_quantity(true)
377 .build();
378
379 let err = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
380 .expect_err("quote quantity needs an inverse instrument");
381
382 assert_eq!(
383 format!("{err:#}"),
384 "UNSUPPORTED_QUOTE_QUANTITY: Interactive Brokers only accepts a quote quantity (`cash_qty`) for inverse instruments; AAPL.NASDAQ must use the base quantity"
385 );
386 }
387
388 #[rstest]
389 #[case::sell_short(OrderSide::Sell, r#"{"action":"SSHORT"}"#, Action::SellShort)]
390 #[case::sell_long(OrderSide::Sell, r#"{"action":"SLONG"}"#, Action::SellLong)]
391 #[case::same_side(OrderSide::Buy, r#"{"action":"BUY"}"#, Action::Buy)]
392 fn test_tagged_action_within_order_side_is_applied(
393 #[case] side: OrderSide,
394 #[case] tags_json: &str,
395 #[case] expected: Action,
396 ) {
397 let order = create_test_order_with_side_and_tags(side, tags_json);
398 let provider = InteractiveBrokersInstrumentProvider::new(
399 InteractiveBrokersInstrumentProviderConfig::default(),
400 )
401 .unwrap();
402
403 let ib_order = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
404 .expect("an action within the order side is valid");
405
406 assert_eq!(ib_order.action, expected);
407 }
408
409 #[rstest]
410 #[case::sell_tagged_buy(
411 OrderSide::Sell,
412 r#"{"action":"BUY"}"#,
413 "IBOrderTags action BUY conflicts with the SELL order side"
414 )]
415 #[case::buy_tagged_sell_short(
416 OrderSide::Buy,
417 r#"{"action":"SSHORT"}"#,
418 "IBOrderTags action SSHORT conflicts with the BUY order side"
419 )]
420 #[case::order_id(OrderSide::Buy, r#"{"orderId":99}"#, "IBOrderTags cannot set order_id")]
421 #[case::client_id(
422 OrderSide::Buy,
423 r#"{"clientId":7}"#,
424 "IBOrderTags cannot set client_id"
425 )]
426 #[case::perm_id(
427 OrderSide::Buy,
428 r#"{"permId":12345}"#,
429 "IBOrderTags cannot set perm_id"
430 )]
431 #[case::order_ref(
432 OrderSide::Buy,
433 r#"{"orderRef":"OTHER"}"#,
434 "IBOrderTags cannot set order_ref"
435 )]
436 #[case::total_quantity(
437 OrderSide::Buy,
438 r#"{"totalQuantity":500}"#,
439 "IBOrderTags cannot set total_quantity"
440 )]
441 #[case::parent_id(
442 OrderSide::Buy,
443 r#"{"parentId":3}"#,
444 "IBOrderTags cannot set parent_id"
445 )]
446 #[case::transmit(
447 OrderSide::Buy,
448 r#"{"transmit":false}"#,
449 "IBOrderTags cannot set transmit"
450 )]
451 fn test_tags_that_break_order_tracking_are_rejected(
452 #[case] side: OrderSide,
453 #[case] tags_json: &str,
454 #[case] expected: &str,
455 ) {
456 let order = create_test_order_with_side_and_tags(side, tags_json);
457 let provider = InteractiveBrokersInstrumentProvider::new(
458 InteractiveBrokersInstrumentProviderConfig::default(),
459 )
460 .unwrap();
461
462 let err = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
463 .expect_err("the tag must be rejected");
464
465 let message = format!("{err:#}");
466 assert!(message.contains(expected), "unexpected reason: {message}");
467 }
468
469 fn create_test_order_with_side_and_tags(side: OrderSide, tags_json: &str) -> OrderAny {
470 let instrument_id = InstrumentId::new(NautilusSymbol::from("AAPL"), Venue::from("NASDAQ"));
471
472 OrderTestBuilder::new(OrderType::Limit)
473 .instrument_id(instrument_id)
474 .side(side)
475 .quantity(Quantity::from(100))
476 .price(Price::from("150.00"))
477 .tags(vec![Ustr::from(&format!("IBOrderTags:{tags_json}"))])
478 .build()
479 }
480
481 fn create_test_order_with_tags(tags_json: &str) -> OrderAny {
482 let instrument_id = InstrumentId::new(NautilusSymbol::from("AAPL"), Venue::from("NASDAQ"));
483
484 let tag = Ustr::from(&format!("IBOrderTags:{tags_json}"));
485 OrderTestBuilder::new(OrderType::Limit)
486 .instrument_id(instrument_id)
487 .side(OrderSide::Buy)
488 .quantity(Quantity::from(100))
489 .price(Price::from("150.00"))
490 .tags(vec![tag])
491 .build()
492 }
493
494 #[rstest]
495 fn test_active_start_time_encoding() {
496 let tags_json = r#"{"activeStartTime": "20250101 09:30:00 UTC"}"#;
497 let order = create_test_order_with_tags(tags_json);
498 let config = InteractiveBrokersInstrumentProviderConfig::default();
499 let provider = InteractiveBrokersInstrumentProvider::new(config).unwrap();
500
501 let result = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001");
502 assert!(result.is_ok());
503 let ib_order = result.unwrap();
504
505 assert_eq!(ib_order.active_start_time, "20250101 09:30:00 UTC");
506 }
507
508 #[rstest]
509 fn test_active_stop_time_encoding() {
510 let tags_json = r#"{"activeStopTime": "20250101 16:00:00 UTC"}"#;
511 let order = create_test_order_with_tags(tags_json);
512 let config = InteractiveBrokersInstrumentProviderConfig::default();
513 let provider = InteractiveBrokersInstrumentProvider::new(config).unwrap();
514
515 let result = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001");
516 assert!(result.is_ok());
517 let ib_order = result.unwrap();
518
519 assert_eq!(ib_order.active_stop_time, "20250101 16:00:00 UTC");
520 }
521
522 #[rstest]
523 fn test_both_active_times_encoding() {
524 let tags_json = r#"{"activeStartTime": "20250101 09:30:00 UTC", "activeStopTime": "20250101 16:00:00 UTC"}"#;
525 let order = create_test_order_with_tags(tags_json);
526 let config = InteractiveBrokersInstrumentProviderConfig::default();
527 let provider = InteractiveBrokersInstrumentProvider::new(config).unwrap();
528
529 let result = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001");
530 assert!(result.is_ok());
531 let ib_order = result.unwrap();
532
533 assert_eq!(ib_order.active_start_time, "20250101 09:30:00 UTC");
534 assert_eq!(ib_order.active_stop_time, "20250101 16:00:00 UTC");
535 }
536
537 #[rstest]
538 fn test_at_the_open_maps_to_ib_opg() {
539 let order = OrderTestBuilder::new(OrderType::Market)
540 .instrument_id(InstrumentId::new(
541 NautilusSymbol::from("AAPL"),
542 Venue::from("NASDAQ"),
543 ))
544 .side(OrderSide::Buy)
545 .quantity(Quantity::from(100))
546 .time_in_force(NautilusTimeInForce::AtTheOpen)
547 .build();
548
549 let provider = InteractiveBrokersInstrumentProvider::new(
550 InteractiveBrokersInstrumentProviderConfig::default(),
551 )
552 .unwrap();
553
554 let ib_order = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
555 .expect("order transform should succeed");
556
557 assert_eq!(ib_order.tif, TimeInForce::OnOpen);
558 }
559
560 #[rstest]
561 fn test_tags_apply_market_on_open_alias() {
562 let tags_json = r#"{"orderType":"MarketOnOpen"}"#;
563 let order = create_test_order_with_tags(tags_json);
564
565 let provider = InteractiveBrokersInstrumentProvider::new(
566 InteractiveBrokersInstrumentProviderConfig::default(),
567 )
568 .unwrap();
569
570 let ib_order = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
571 .expect("order transform should succeed");
572
573 assert_eq!(ib_order.order_type, "MKT");
574 assert_eq!(ib_order.tif, TimeInForce::OnOpen);
575 }
576
577 #[rstest]
578 #[case::gtd(r#"{"timeInForce":"GTD"}"#, TimeInForce::GoodTillDate)]
579 #[case::lowercase(r#"{"tif":"ioc"}"#, TimeInForce::ImmediateOrCancel)]
580 #[case::snake_key(r#"{"time_in_force":"DTC"}"#, TimeInForce::DayTillCanceled)]
581 fn test_tags_apply_time_in_force(#[case] tags_json: &str, #[case] expected: TimeInForce) {
582 let order = create_test_order_with_tags(tags_json);
583
584 let provider = InteractiveBrokersInstrumentProvider::new(
585 InteractiveBrokersInstrumentProviderConfig::default(),
586 )
587 .unwrap();
588
589 let ib_order = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
590 .expect("order transform should succeed");
591
592 assert_eq!(ib_order.tif, expected);
593 }
594
595 #[rstest]
596 fn test_tags_apply_at_auction_alias() {
597 let tags_json = r#"{"orderType":"AtAuction","limitPrice":150.0}"#;
598 let order = create_test_order_with_tags(tags_json);
599
600 let provider = InteractiveBrokersInstrumentProvider::new(
601 InteractiveBrokersInstrumentProviderConfig::default(),
602 )
603 .unwrap();
604
605 let ib_order = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
606 .expect("order transform should succeed");
607
608 assert_eq!(ib_order.order_type, "MTL");
609 assert_eq!(ib_order.tif, TimeInForce::Auction);
610 assert_eq!(ib_order.limit_price, Some(150.0));
611 }
612
613 #[rstest]
614 fn test_tags_apply_auction_limit_fields() {
615 let tags_json = r#"{
616 "orderType": "AuctionLimit",
617 "startingPrice": 1.25,
618 "stockRefPrice": 150.25,
619 "delta": 0.5,
620 "stockRangeLower": 145.0,
621 "stockRangeUpper": 155.0
622 }"#;
623 let order = create_test_order_with_tags(tags_json);
624
625 let provider = InteractiveBrokersInstrumentProvider::new(
626 InteractiveBrokersInstrumentProviderConfig::default(),
627 )
628 .unwrap();
629
630 let ib_order = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
631 .expect("order transform should succeed");
632
633 assert_eq!(ib_order.order_type, "LMT");
634 assert_eq!(ib_order.starting_price, Some(1.25));
635 assert_eq!(ib_order.stock_ref_price, Some(150.25));
636 assert_eq!(ib_order.delta, Some(0.5));
637 assert_eq!(ib_order.stock_range_lower, Some(145.0));
638 assert_eq!(ib_order.stock_range_upper, Some(155.0));
639 }
640
641 #[rstest]
642 fn test_tags_apply_auction_relative_fields() {
643 let tags_json = r#"{"orderType":"AuctionRelative","auxPrice":0.01}"#;
644 let order = create_test_order_with_tags(tags_json);
645
646 let provider = InteractiveBrokersInstrumentProvider::new(
647 InteractiveBrokersInstrumentProviderConfig::default(),
648 )
649 .unwrap();
650
651 let ib_order = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
652 .expect("order transform should succeed");
653
654 assert_eq!(ib_order.order_type, "REL");
655 assert_eq!(ib_order.aux_price, Some(0.01));
656 }
657
658 #[rstest]
659 fn test_tags_apply_generic_ib_order_fields() {
660 let tags_json = r#"{
661 "displaySize": 25,
662 "triggerMethod": 2,
663 "overridePercentageConstraints": true,
664 "rule80A": "A",
665 "openClose": "O",
666 "origin": 1,
667 "shortSaleSlot": 2,
668 "designatedLocation": "SLB",
669 "discretionaryAmt": 0.12,
670 "optOutSmartRouting": true,
671 "volatility": 23.5,
672 "volatilityType": 2,
673 "continuousUpdate": true,
674 "referencePriceType": 2,
675 "deltaNeutralOrderType": "MKT",
676 "deltaNeutralAuxPrice": 1.25,
677 "scaleInitLevelSize": 10,
678 "scaleAutoReset": true,
679 "hedgeType": "D",
680 "hedgeParam": "0.5",
681 "algoStrategy": "Adaptive",
682 "algoParams": [{"tag": "adaptivePriority", "value": "Normal"}],
683 "notHeld": true,
684 "mifid2DecisionMaker": "maker",
685 "autoCancelParent": true,
686 "minTradeQty": 5,
687 "competeAgainstBestOffset": 0.01,
688 "midOffsetAtWhole": 0.02,
689 "referenceContractId": 123,
690 "referenceExchange": "SMART",
691 "adjustedOrderType": "STP",
692 "triggerPrice": 149.0,
693 "conditionsIgnoreRth": true,
694 "usePriceMgmtAlgo": true,
695 "duration": 30,
696 "postToAts": 10,
697 "includeOvernight": true,
698 "manualOrderIndicator": 1,
699 "submitter": "SUB",
700 "NonGuaranteed": true,
701 "orderComboLegs": [{"price": 1.23}],
702 "softDollarTier": {"name": "tier", "value": "val", "display_name": "display"}
703 }"#;
704 let order = create_test_order_with_tags(tags_json);
705
706 let provider = InteractiveBrokersInstrumentProvider::new(
707 InteractiveBrokersInstrumentProviderConfig::default(),
708 )
709 .unwrap();
710
711 let ib_order = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
712 .expect("order transform should succeed");
713
714 assert_eq!(ib_order.display_size, Some(25));
715 assert_eq!(
716 ib_order.trigger_method,
717 ibapi::orders::conditions::TriggerMethod::Last
718 );
719 assert!(ib_order.override_percentage_constraints);
720 assert_eq!(ib_order.rule_80_a, Some(ibapi::orders::Rule80A::Agency));
721 assert_eq!(
722 ib_order.open_close,
723 Some(ibapi::orders::OrderOpenClose::Open)
724 );
725 assert_eq!(ib_order.origin, ibapi::orders::OrderOrigin::Firm);
726 assert_eq!(
727 ib_order.short_sale_slot,
728 ibapi::orders::ShortSaleSlot::ThirdParty
729 );
730 assert_eq!(ib_order.designated_location, "SLB");
731 assert_eq!(ib_order.discretionary_amt, 0.12);
732 assert!(ib_order.opt_out_smart_routing);
733 assert_eq!(ib_order.volatility, Some(23.5));
734 assert_eq!(
735 ib_order.volatility_type,
736 Some(ibapi::orders::VolatilityType::Annual)
737 );
738 assert!(ib_order.continuous_update);
739 assert_eq!(
740 ib_order.reference_price_type,
741 Some(ibapi::orders::ReferencePriceType::NBBO)
742 );
743 assert_eq!(ib_order.delta_neutral_order_type, "MKT");
744 assert_eq!(ib_order.delta_neutral_aux_price, Some(1.25));
745 assert_eq!(ib_order.scale_init_level_size, Some(10));
746 assert!(ib_order.scale_auto_reset);
747 assert_eq!(ib_order.hedge_type, "D");
748 assert_eq!(ib_order.hedge_param, "0.5");
749 assert_eq!(ib_order.algo_strategy, "Adaptive");
750 assert_eq!(ib_order.algo_params[0].tag, "adaptivePriority");
751 assert_eq!(ib_order.algo_params[0].value, "Normal");
752 assert!(ib_order.not_held);
753 assert_eq!(ib_order.mifid2_decision_maker, "maker");
754 assert!(ib_order.auto_cancel_parent);
755 assert_eq!(ib_order.min_trade_qty, Some(5));
756 assert_eq!(ib_order.compete_against_best_offset, Some(0.01));
757 assert_eq!(ib_order.mid_offset_at_whole, Some(0.02));
758 assert_eq!(ib_order.reference_contract_id, 123);
759 assert_eq!(ib_order.reference_exchange, "SMART");
760 assert_eq!(ib_order.adjusted_order_type, "STP");
761 assert_eq!(ib_order.trigger_price, Some(149.0));
762 assert!(ib_order.conditions_ignore_rth);
763 assert!(ib_order.use_price_mgmt_algo);
764 assert_eq!(ib_order.duration, Some(30));
765 assert_eq!(ib_order.post_to_ats, Some(10));
766 assert!(ib_order.include_overnight);
767 assert_eq!(ib_order.manual_order_indicator, Some(1));
768 assert_eq!(ib_order.submitter, "SUB");
769 assert_eq!(ib_order.order_combo_legs[0].price, Some(1.23));
770 assert_eq!(ib_order.soft_dollar_tier.name, "tier");
771 assert_eq!(ib_order.soft_dollar_tier.value, "val");
772 assert_eq!(ib_order.soft_dollar_tier.display_name, "display");
773 assert!(
774 ib_order
775 .smart_combo_routing_params
776 .iter()
777 .any(|tag| tag.tag == "NonGuaranteed" && tag.value == "1")
778 );
779 }
780
781 #[rstest]
782 fn test_invalid_tag_set_rejects_order_transform() {
783 let tags_json = r#"{"whatIf": true, "displaySize": "invalid"}"#;
784 let order = create_test_order_with_tags(tags_json);
785
786 let provider = InteractiveBrokersInstrumentProvider::new(
787 InteractiveBrokersInstrumentProviderConfig::default(),
788 )
789 .unwrap();
790
791 let result = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001");
792
793 assert!(result.is_err());
794 assert!(
795 result
796 .expect_err("invalid tag set should reject the order")
797 .to_string()
798 .contains("Invalid IBOrderTags field display_size")
799 );
800 }
801
802 #[rstest]
803 fn test_non_utc_datetime_tag_rejects_order_transform() {
804 let tags_json = r#"{"activeStartTime": "20250101 09:30:00 EST"}"#;
805 let order = create_test_order_with_tags(tags_json);
806
807 let provider = InteractiveBrokersInstrumentProvider::new(
808 InteractiveBrokersInstrumentProviderConfig::default(),
809 )
810 .unwrap();
811
812 let result = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001");
813
814 assert!(result.is_err());
815 assert!(
816 result
817 .expect_err("non-UTC datetime tag should reject the order")
818 .to_string()
819 .contains("Invalid IBOrderTags field active_start_time")
820 );
821 }
822
823 #[rstest]
824 fn test_gtd_orders_encode_ib_timestamp_string() {
825 let expire_time = UnixNanos::from(
826 "2025-01-15T14:30:00Z"
827 .parse::<jiff::Timestamp>()
828 .expect("valid datetime"),
829 );
830 let order = OrderTestBuilder::new(OrderType::Limit)
831 .instrument_id(InstrumentId::new(
832 NautilusSymbol::from("AAPL"),
833 Venue::from("NASDAQ"),
834 ))
835 .side(OrderSide::Buy)
836 .quantity(Quantity::from(100))
837 .price(Price::from("150.00"))
838 .time_in_force(NautilusTimeInForce::Gtd)
839 .expire_time(expire_time)
840 .build();
841
842 let provider = InteractiveBrokersInstrumentProvider::new(
843 InteractiveBrokersInstrumentProviderConfig::default(),
844 )
845 .unwrap();
846
847 let ib_order = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
848 .expect("order transform should succeed");
849
850 assert_eq!(ib_order.tif, TimeInForce::GoodTillDate);
851 assert_eq!(ib_order.good_till_date, "20250115 14:30:00 UTC");
852 }
853
854 #[rstest]
855 fn test_trailing_stop_market_uses_aux_price_not_trailing_percent() {
856 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
857 .instrument_id(InstrumentId::new(
858 NautilusSymbol::from("AAPL"),
859 Venue::from("NASDAQ"),
860 ))
861 .side(OrderSide::Sell)
862 .quantity(Quantity::from(100))
863 .trigger_price(Price::from("149.50"))
864 .trailing_offset(dec!(0.5))
865 .trailing_offset_type(TrailingOffsetType::Price)
866 .build();
867
868 let provider = InteractiveBrokersInstrumentProvider::new(
869 InteractiveBrokersInstrumentProviderConfig::default(),
870 )
871 .unwrap();
872
873 let ib_order = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
874 .expect("order transform should succeed");
875
876 assert_eq!(ib_order.aux_price, Some(0.5));
877 assert_eq!(ib_order.trail_stop_price, Some(149.5));
878 assert_eq!(ib_order.trailing_percent, None);
879 }
880
881 #[rstest]
882 fn test_trailing_stop_market_uses_trailing_percent_for_basis_points() {
883 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
884 .instrument_id(InstrumentId::new(
885 NautilusSymbol::from("AAPL"),
886 Venue::from("NASDAQ"),
887 ))
888 .side(OrderSide::Sell)
889 .quantity(Quantity::from(100))
890 .trigger_price(Price::from("149.50"))
891 .trailing_offset(dec!(25))
892 .trailing_offset_type(TrailingOffsetType::BasisPoints)
893 .build();
894
895 let provider = InteractiveBrokersInstrumentProvider::new(
896 InteractiveBrokersInstrumentProviderConfig::default(),
897 )
898 .unwrap();
899
900 let ib_order = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
901 .expect("order transform should succeed");
902
903 assert_eq!(ib_order.aux_price, None);
904 assert_eq!(ib_order.trailing_percent, Some(0.25));
905 assert_eq!(ib_order.trail_stop_price, Some(149.5));
906 }
907
908 #[rstest]
909 fn test_trailing_stop_market_rejects_unsupported_trailing_offset_type() {
910 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
911 .instrument_id(InstrumentId::new(
912 NautilusSymbol::from("AAPL"),
913 Venue::from("NASDAQ"),
914 ))
915 .side(OrderSide::Sell)
916 .quantity(Quantity::from(100))
917 .trigger_price(Price::from("149.50"))
918 .trailing_offset(dec!(5))
919 .trailing_offset_type(TrailingOffsetType::Ticks)
920 .build();
921
922 let provider = InteractiveBrokersInstrumentProvider::new(
923 InteractiveBrokersInstrumentProviderConfig::default(),
924 )
925 .unwrap();
926
927 let err = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
928 .expect_err("unsupported trailing offset type should fail");
929
930 assert_eq!(
931 err.to_string(),
932 "`TrailingOffsetType` Ticks is not supported"
933 );
934 }
935
936 #[rstest]
937 fn test_tags_apply_conditions_and_cancel_order_policy() {
938 let tags_json = r#"{
939 "outsideRth": true,
940 "whatIf": true,
941 "conditionsCancelOrder": true,
942 "conditions": [
943 {
944 "type": "price",
945 "conId": 265598,
946 "exchange": "SMART",
947 "price": 150.0,
948 "isMore": true,
949 "triggerMethod": 2,
950 "conjunction": "and"
951 },
952 {
953 "type": "time",
954 "time": "20251230 14:30:00 US/Eastern",
955 "isMore": false,
956 "conjunction": "or"
957 }
958 ]
959 }"#;
960 let order = create_test_order_with_tags(tags_json);
961
962 let provider = InteractiveBrokersInstrumentProvider::new(
963 InteractiveBrokersInstrumentProviderConfig::default(),
964 )
965 .unwrap();
966
967 let ib_order = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
968 .expect("order transform should succeed");
969
970 assert!(ib_order.outside_rth);
971 assert!(ib_order.what_if);
972 assert!(ib_order.conditions_cancel_order);
973 assert_eq!(ib_order.conditions.len(), 2);
974
975 match &ib_order.conditions[0] {
976 OrderCondition::Price(condition) => {
977 assert_eq!(condition.contract_id, 265598);
978 assert_eq!(condition.exchange, "SMART");
979 assert_eq!(condition.price, 150.0);
980 assert!(condition.is_more);
981 assert!(condition.is_conjunction);
982 }
983 other => panic!("unexpected first condition: {other:?}"),
984 }
985
986 match &ib_order.conditions[1] {
987 OrderCondition::Time(condition) => {
988 assert_eq!(condition.time, "20251230 14:30:00 US/Eastern");
989 assert!(!condition.is_more);
990 assert!(!condition.is_conjunction);
991 }
992 other => panic!("unexpected second condition: {other:?}"),
993 }
994 }
995
996 #[rstest]
997 fn test_order_list_id_does_not_set_oca_group() {
998 let order = OrderTestBuilder::new(OrderType::Limit)
999 .instrument_id(InstrumentId::new(
1000 NautilusSymbol::from("AAPL"),
1001 Venue::from("NASDAQ"),
1002 ))
1003 .side(OrderSide::Buy)
1004 .quantity(Quantity::from(100))
1005 .price(Price::from("150.00"))
1006 .order_list_id(OrderListId::from("OL-001"))
1007 .build();
1008
1009 let provider = InteractiveBrokersInstrumentProvider::new(
1010 InteractiveBrokersInstrumentProviderConfig::default(),
1011 )
1012 .unwrap();
1013
1014 let ib_order = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
1015 .expect("order transform should succeed");
1016
1017 assert!(ib_order.oca_group.is_empty());
1018 }
1019
1020 #[rstest]
1021 fn test_explicit_oca_group_tag_overrides_order_list_default() {
1022 let order = OrderTestBuilder::new(OrderType::Limit)
1023 .instrument_id(InstrumentId::new(
1024 NautilusSymbol::from("AAPL"),
1025 Venue::from("NASDAQ"),
1026 ))
1027 .side(OrderSide::Buy)
1028 .quantity(Quantity::from(100))
1029 .price(Price::from("150.00"))
1030 .order_list_id(OrderListId::from("OL-001"))
1031 .tags(vec![Ustr::from(
1032 r#"IBOrderTags:{"ocaGroup":"CUSTOM-GROUP","ocaType":1}"#,
1033 )])
1034 .build();
1035
1036 let provider = InteractiveBrokersInstrumentProvider::new(
1037 InteractiveBrokersInstrumentProviderConfig::default(),
1038 )
1039 .unwrap();
1040
1041 let ib_order = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
1042 .expect("order transform should succeed");
1043
1044 assert_eq!(ib_order.oca_group, "CUSTOM-GROUP");
1045 assert_eq!(ib_order.oca_type, ibapi::orders::OcaType::from(1));
1046 }
1047
1048 fn magnified_provider(price_magnifier: i32) -> InteractiveBrokersInstrumentProvider {
1049 let provider = InteractiveBrokersInstrumentProvider::new(
1050 InteractiveBrokersInstrumentProviderConfig::default(),
1051 )
1052 .unwrap();
1053 provider.insert_test_instrument(
1054 InstrumentAny::Equity(equity_aapl()),
1055 4242,
1056 price_magnifier,
1057 );
1058 provider
1059 }
1060
1061 #[rstest]
1062 #[case::magnified(100, "4.2525", 425.25)]
1063 #[case::on_grid(100, "4.8625", 486.25)]
1064 #[case::unmagnified(1, "4.2525", 4.2525)]
1065 fn test_convert_price_multiplies_by_magnifier_exactly(
1066 #[case] magnifier: i32,
1067 #[case] price: &str,
1068 #[case] expected: f64,
1069 ) {
1070 assert_eq!(convert_price(Price::from(price), magnifier), expected);
1071 }
1072
1073 #[rstest]
1074 fn test_convert_price_avoids_binary_float_product_error() {
1075 assert_ne!(4.2525_f64 * 100.0, 425.25);
1077 assert_eq!(convert_price(Price::from("4.2525"), 100), 425.25);
1078 }
1079
1080 #[rstest]
1081 fn test_convert_decimal_returns_error_on_overflow() {
1082 assert!(convert_decimal(Decimal::MAX, 100).is_err());
1083 assert!(convert_decimal(Decimal::MAX, 1).is_ok());
1084 }
1085
1086 #[rstest]
1087 fn test_from_ib_price_divides_by_magnifier() {
1088 assert_eq!(from_ib_price(425.25, 100), 4.2525);
1089 assert_eq!(from_ib_price(425.25, 1), 425.25);
1090 assert_eq!(from_ib_price(425.25, 0), 425.25);
1091 }
1092
1093 #[rstest]
1094 fn test_magnified_limit_order_sends_ib_unit_price() {
1095 let order = OrderTestBuilder::new(OrderType::Limit)
1096 .instrument_id(InstrumentId::from("AAPL.XNAS"))
1097 .side(OrderSide::Buy)
1098 .quantity(Quantity::from(1))
1099 .price(Price::from("4.2525"))
1100 .build();
1101
1102 let ib_order = nautilus_order_to_ib_order(&order, &magnified_provider(100), 1, "TEST-001")
1103 .expect("order transform should succeed");
1104
1105 assert_eq!(ib_order.limit_price, Some(425.25));
1106 }
1107
1108 #[rstest]
1109 fn test_magnified_stop_limit_order_sends_ib_unit_prices() {
1110 let order = OrderTestBuilder::new(OrderType::StopLimit)
1111 .instrument_id(InstrumentId::from("AAPL.XNAS"))
1112 .side(OrderSide::Sell)
1113 .quantity(Quantity::from(1))
1114 .price(Price::from("4.2525"))
1115 .trigger_price(Price::from("4.3025"))
1116 .build();
1117
1118 let ib_order = nautilus_order_to_ib_order(&order, &magnified_provider(100), 1, "TEST-001")
1119 .expect("order transform should succeed");
1120
1121 assert_eq!(ib_order.limit_price, Some(425.25));
1122 assert_eq!(ib_order.aux_price, Some(430.25));
1123 }
1124
1125 #[rstest]
1126 #[case::price_amount(TrailingOffsetType::Price, dec!(0.0525), Some(5.25), None)]
1127 #[case::basis_points(TrailingOffsetType::BasisPoints, dec!(50), None, Some(0.5))]
1128 fn test_magnified_trailing_stop_scales_price_amounts_only(
1129 #[case] offset_type: TrailingOffsetType,
1130 #[case] offset: rust_decimal::Decimal,
1131 #[case] expected_aux: Option<f64>,
1132 #[case] expected_percent: Option<f64>,
1133 ) {
1134 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
1135 .instrument_id(InstrumentId::from("AAPL.XNAS"))
1136 .side(OrderSide::Sell)
1137 .quantity(Quantity::from(1))
1138 .trigger_price(Price::from("4.8125"))
1139 .trailing_offset(offset)
1140 .trailing_offset_type(offset_type)
1141 .build();
1142
1143 let ib_order = nautilus_order_to_ib_order(&order, &magnified_provider(100), 1, "TEST-001")
1144 .expect("order transform should succeed");
1145
1146 assert_eq!(ib_order.aux_price, expected_aux);
1147 assert_eq!(ib_order.trailing_percent, expected_percent);
1148 assert_eq!(ib_order.trail_stop_price, Some(481.25));
1149 }
1150
1151 #[rstest]
1152 fn test_ib_bar_close_round_trips_through_a_limit_order() {
1153 let provider = magnified_provider(100);
1154 let instrument_id = InstrumentId::from("AAPL.XNAS");
1155 let prices = crate::data::convert::IbPriceConverter::new(4)
1156 .with_price_magnifier(provider.get_price_magnifier(&instrument_id));
1157
1158 let bar_close = prices.price(425.25).unwrap();
1160 let order = OrderTestBuilder::new(OrderType::Limit)
1161 .instrument_id(instrument_id)
1162 .side(OrderSide::Buy)
1163 .quantity(Quantity::from(1))
1164 .price(bar_close)
1165 .build();
1166
1167 let ib_order = nautilus_order_to_ib_order(&order, &provider, 1, "TEST-001")
1168 .expect("order transform should succeed");
1169
1170 assert_eq!(bar_close, Price::from("4.2525"));
1171 assert_eq!(ib_order.limit_price, Some(425.25));
1172 }
1173}