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