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