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