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::providers::instruments::InteractiveBrokersInstrumentProvider;
33
34mod policy;
35mod tags;
36
37use self::{
38 policy::{
39 apply_account_policy, apply_display_quantity_policy, apply_expire_time_policy,
40 apply_order_list_policy, apply_quantity_policy, apply_trailing_order_policy,
41 },
42 tags::apply_ib_order_tags,
43};
44
45pub fn nautilus_order_to_ib_order(
59 order: &OrderAny,
60 _contract: &Contract,
61 instrument_provider: &InteractiveBrokersInstrumentProvider,
62 order_id: i32,
63 order_ref: &str,
64) -> anyhow::Result<IBOrder> {
65 let action = match order.order_side() {
66 OrderSide::Buy => Action::Buy,
67 OrderSide::Sell => Action::Sell,
68 _ => anyhow::bail!("Unsupported order side: {:?}", order.order_side()),
69 };
70
71 let quantity = order.quantity().as_f64();
72 let price_magnifier = instrument_provider.get_price_magnifier(&order.instrument_id()) as f64;
73
74 let (order_type, limit_price, aux_price) = transform_order_type(
75 order.order_type(),
76 order.time_in_force(),
77 order.price(),
78 order.trigger_price(),
79 price_magnifier,
80 );
81 let tif = transform_time_in_force(order.time_in_force(), order.expire_time());
82
83 let mut ib_order = IBOrder {
84 order_id,
85 action,
86 total_quantity: quantity,
87 order_type: order_type.to_string(),
88 limit_price,
89 aux_price,
90 tif,
91 order_ref: order_ref.to_string(),
92 account: String::new(),
93 ..Default::default()
94 };
95
96 apply_expire_time_policy(&mut ib_order, order);
97 apply_account_policy(&mut ib_order, order);
98 apply_quantity_policy(&mut ib_order, order, instrument_provider);
99 apply_trailing_order_policy(&mut ib_order, order, price_magnifier)?;
100 apply_display_quantity_policy(&mut ib_order, order);
101
102 let _parent_order_id = order.parent_order_id();
106
107 apply_ib_order_tags(&mut ib_order, order.tags());
108 apply_order_list_policy(&mut ib_order, order);
109
110 Ok(ib_order)
111}
112
113fn transform_order_type(
115 order_type: NautilusOrderType,
116 time_in_force: NautilusTimeInForce,
117 price: Option<Price>,
118 trigger_price: Option<Price>,
119 price_magnifier: f64,
120) -> (&'static str, Option<f64>, Option<f64>) {
121 let (order_type_str, limit_price, aux_price) = match order_type {
122 NautilusOrderType::Market => {
123 if time_in_force == NautilusTimeInForce::AtTheClose {
124 ("MOC", None, None)
125 } else {
126 ("MKT", None, None)
127 }
128 }
129 NautilusOrderType::Limit => {
130 if time_in_force == NautilusTimeInForce::AtTheClose {
131 ("LOC", convert_price_opt(price, price_magnifier), None)
132 } else {
133 ("LMT", convert_price_opt(price, price_magnifier), None)
134 }
135 }
136 NautilusOrderType::StopMarket => (
137 "STP",
138 None,
139 convert_price_opt(trigger_price, price_magnifier),
140 ),
141 NautilusOrderType::StopLimit => (
142 "STP LMT",
143 convert_price_opt(price, price_magnifier),
144 convert_price_opt(trigger_price, price_magnifier),
145 ),
146 NautilusOrderType::MarketIfTouched => (
147 "MIT",
148 None,
149 convert_price_opt(trigger_price, price_magnifier),
150 ),
151 NautilusOrderType::LimitIfTouched => (
152 "LIT",
153 convert_price_opt(price, price_magnifier),
154 convert_price_opt(trigger_price, price_magnifier),
155 ),
156 NautilusOrderType::TrailingStopMarket => ("TRAIL", None, None),
157 NautilusOrderType::TrailingStopLimit => (
158 "TRAIL LIMIT",
159 convert_price_opt(price, price_magnifier),
160 None,
161 ),
162 NautilusOrderType::MarketToLimit => ("MTL", None, None),
163 };
164
165 (order_type_str, limit_price, aux_price)
166}
167
168fn transform_time_in_force(
170 tif: NautilusTimeInForce,
171 _expire_time: Option<nautilus_core::UnixNanos>,
172) -> TimeInForce {
173 match tif {
174 NautilusTimeInForce::Day => TimeInForce::Day,
175 NautilusTimeInForce::Gtc => TimeInForce::GoodTilCanceled,
176 NautilusTimeInForce::Ioc => TimeInForce::ImmediateOrCancel,
177 NautilusTimeInForce::Fok => TimeInForce::FillOrKill,
178 NautilusTimeInForce::Gtd => TimeInForce::GoodTilDate,
179 NautilusTimeInForce::AtTheOpen => TimeInForce::OnOpen,
180 NautilusTimeInForce::AtTheClose => TimeInForce::Day,
181 }
182}
183
184pub(super) fn format_ib_datetime(value: UnixNanos) -> String {
185 let dt = DateTime::<Utc>::from(value);
186 dt.format("%Y%m%d %H:%M:%S UTC").to_string()
187}
188
189pub(super) fn convert_price(price: Price, magnifier: f64) -> f64 {
190 price.as_f64() / magnifier
191}
192
193fn convert_price_opt(price: Option<Price>, magnifier: f64) -> Option<f64> {
194 price.map(|p| convert_price(p, magnifier))
195}
196
197pub(super) fn trigger_type_to_ib_trigger_method(
198 trigger_type: TriggerType,
199) -> ibapi::orders::conditions::TriggerMethod {
200 let value = match trigger_type {
201 TriggerType::Default => 0,
202 TriggerType::DoubleBidAsk => 1,
203 TriggerType::LastPrice => 2,
204 TriggerType::DoubleLast => 3,
205 TriggerType::BidAsk => 4,
206 TriggerType::LastOrBidAsk => 7,
207 TriggerType::MidPoint => 8,
208 _ => 0,
209 };
210
211 ibapi::orders::conditions::TriggerMethod::from(value)
212}
213
214#[cfg(test)]
215mod tests {
216 use chrono::TimeZone;
217 use ibapi::{
218 contracts::{Contract, Currency, Exchange, SecurityType, Symbol},
219 orders::OrderCondition,
220 };
221 use nautilus_model::{
222 enums::{OrderSide, OrderType, TimeInForce as NautilusTimeInForce, TrailingOffsetType},
223 identifiers::{InstrumentId, OrderListId, Symbol as NautilusSymbol, Venue},
224 orders::OrderTestBuilder,
225 types::{Price, Quantity},
226 };
227 use rstest::rstest;
228 use rust_decimal_macros::dec;
229 use ustr::Ustr;
230
231 use super::*;
232 use crate::config::InteractiveBrokersInstrumentProviderConfig;
233
234 fn create_test_order_with_tags(tags_json: &str) -> OrderAny {
235 let instrument_id = InstrumentId::new(NautilusSymbol::from("AAPL"), Venue::from("NASDAQ"));
236
237 let tag = Ustr::from(&format!("IBOrderTags:{}", tags_json));
238 OrderTestBuilder::new(OrderType::Limit)
239 .instrument_id(instrument_id)
240 .side(OrderSide::Buy)
241 .quantity(Quantity::from(100))
242 .price(Price::from("150.00"))
243 .tags(vec![tag])
244 .build()
245 }
246
247 #[rstest]
248 fn test_active_start_time_encoding() {
249 let tags_json = r#"{"activeStartTime": "20250101 09:30:00 EST"}"#;
250 let order = create_test_order_with_tags(tags_json);
251 let contract = Contract {
252 contract_id: 0,
253 symbol: Symbol::from("AAPL"),
254 security_type: SecurityType::Stock,
255 exchange: Exchange::from("NASDAQ"),
256 currency: Currency::from("USD"),
257 ..Default::default()
258 };
259 let config = InteractiveBrokersInstrumentProviderConfig::default();
260 let provider = InteractiveBrokersInstrumentProvider::new(config);
261
262 let result = nautilus_order_to_ib_order(&order, &contract, &provider, 1, "TEST-001");
263 assert!(result.is_ok());
264 let ib_order = result.unwrap();
265
266 assert!(!ib_order.order_misc_options.is_empty());
267 let has_active_start = ib_order
268 .order_misc_options
269 .iter()
270 .any(|tv| tv.tag == "activeStartTime" && tv.value == "20250101 09:30:00 EST");
271 assert!(
272 has_active_start,
273 "activeStartTime should be encoded in order_misc_options"
274 );
275 }
276
277 #[rstest]
278 fn test_active_stop_time_encoding() {
279 let tags_json = r#"{"activeStopTime": "20250101 16:00:00 EST"}"#;
280 let order = create_test_order_with_tags(tags_json);
281 let contract = Contract {
282 contract_id: 0,
283 symbol: Symbol::from("AAPL"),
284 security_type: SecurityType::Stock,
285 exchange: Exchange::from("NASDAQ"),
286 currency: Currency::from("USD"),
287 ..Default::default()
288 };
289 let config = InteractiveBrokersInstrumentProviderConfig::default();
290 let provider = InteractiveBrokersInstrumentProvider::new(config);
291
292 let result = nautilus_order_to_ib_order(&order, &contract, &provider, 1, "TEST-001");
293 assert!(result.is_ok());
294 let ib_order = result.unwrap();
295
296 assert!(!ib_order.order_misc_options.is_empty());
297 let has_active_stop = ib_order
298 .order_misc_options
299 .iter()
300 .any(|tv| tv.tag == "activeStopTime" && tv.value == "20250101 16:00:00 EST");
301 assert!(
302 has_active_stop,
303 "activeStopTime should be encoded in order_misc_options"
304 );
305 }
306
307 #[rstest]
308 fn test_both_active_times_encoding() {
309 let tags_json = r#"{"activeStartTime": "20250101 09:30:00 EST", "activeStopTime": "20250101 16:00:00 EST"}"#;
310 let order = create_test_order_with_tags(tags_json);
311 let contract = Contract {
312 contract_id: 0,
313 symbol: Symbol::from("AAPL"),
314 security_type: SecurityType::Stock,
315 exchange: Exchange::from("NASDAQ"),
316 currency: Currency::from("USD"),
317 ..Default::default()
318 };
319 let config = InteractiveBrokersInstrumentProviderConfig::default();
320 let provider = InteractiveBrokersInstrumentProvider::new(config);
321
322 let result = nautilus_order_to_ib_order(&order, &contract, &provider, 1, "TEST-001");
323 assert!(result.is_ok());
324 let ib_order = result.unwrap();
325
326 assert_eq!(ib_order.order_misc_options.len(), 2);
327 let has_active_start = ib_order
328 .order_misc_options
329 .iter()
330 .any(|tv| tv.tag == "activeStartTime");
331 let has_active_stop = ib_order
332 .order_misc_options
333 .iter()
334 .any(|tv| tv.tag == "activeStopTime");
335 assert!(
336 has_active_start && has_active_stop,
337 "Both activeStartTime and activeStopTime should be encoded"
338 );
339 }
340
341 #[rstest]
342 fn test_at_the_open_maps_to_ib_opg() {
343 let order = OrderTestBuilder::new(OrderType::Market)
344 .instrument_id(InstrumentId::new(
345 NautilusSymbol::from("AAPL"),
346 Venue::from("NASDAQ"),
347 ))
348 .side(OrderSide::Buy)
349 .quantity(Quantity::from(100))
350 .time_in_force(NautilusTimeInForce::AtTheOpen)
351 .build();
352 let contract = Contract {
353 contract_id: 0,
354 symbol: Symbol::from("AAPL"),
355 security_type: SecurityType::Stock,
356 exchange: Exchange::from("NASDAQ"),
357 currency: Currency::from("USD"),
358 ..Default::default()
359 };
360 let provider = InteractiveBrokersInstrumentProvider::new(
361 InteractiveBrokersInstrumentProviderConfig::default(),
362 );
363
364 let ib_order = nautilus_order_to_ib_order(&order, &contract, &provider, 1, "TEST-001")
365 .expect("order transform should succeed");
366
367 assert_eq!(ib_order.tif, TimeInForce::OnOpen);
368 }
369
370 #[rstest]
371 fn test_gtd_orders_encode_ib_timestamp_string() {
372 let expire_time = UnixNanos::from(
373 Utc.with_ymd_and_hms(2025, 1, 15, 14, 30, 0)
374 .single()
375 .expect("valid datetime"),
376 );
377 let order = OrderTestBuilder::new(OrderType::Limit)
378 .instrument_id(InstrumentId::new(
379 NautilusSymbol::from("AAPL"),
380 Venue::from("NASDAQ"),
381 ))
382 .side(OrderSide::Buy)
383 .quantity(Quantity::from(100))
384 .price(Price::from("150.00"))
385 .time_in_force(NautilusTimeInForce::Gtd)
386 .expire_time(expire_time)
387 .build();
388 let contract = Contract {
389 contract_id: 0,
390 symbol: Symbol::from("AAPL"),
391 security_type: SecurityType::Stock,
392 exchange: Exchange::from("NASDAQ"),
393 currency: Currency::from("USD"),
394 ..Default::default()
395 };
396 let provider = InteractiveBrokersInstrumentProvider::new(
397 InteractiveBrokersInstrumentProviderConfig::default(),
398 );
399
400 let ib_order = nautilus_order_to_ib_order(&order, &contract, &provider, 1, "TEST-001")
401 .expect("order transform should succeed");
402
403 assert_eq!(ib_order.tif, TimeInForce::GoodTilDate);
404 assert_eq!(ib_order.good_till_date, "20250115 14:30:00 UTC");
405 }
406
407 #[rstest]
408 fn test_trailing_stop_market_uses_aux_price_not_trailing_percent() {
409 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
410 .instrument_id(InstrumentId::new(
411 NautilusSymbol::from("AAPL"),
412 Venue::from("NASDAQ"),
413 ))
414 .side(OrderSide::Sell)
415 .quantity(Quantity::from(100))
416 .trigger_price(Price::from("149.50"))
417 .trailing_offset(dec!(0.5))
418 .trailing_offset_type(TrailingOffsetType::Price)
419 .build();
420 let contract = Contract {
421 contract_id: 0,
422 symbol: Symbol::from("AAPL"),
423 security_type: SecurityType::Stock,
424 exchange: Exchange::from("NASDAQ"),
425 currency: Currency::from("USD"),
426 ..Default::default()
427 };
428 let provider = InteractiveBrokersInstrumentProvider::new(
429 InteractiveBrokersInstrumentProviderConfig::default(),
430 );
431
432 let ib_order = nautilus_order_to_ib_order(&order, &contract, &provider, 1, "TEST-001")
433 .expect("order transform should succeed");
434
435 assert_eq!(ib_order.aux_price, Some(0.5));
436 assert_eq!(ib_order.trail_stop_price, Some(149.5));
437 assert_eq!(ib_order.trailing_percent, None);
438 }
439
440 #[rstest]
441 fn test_trailing_stop_rejects_non_price_offset() {
442 let order = OrderTestBuilder::new(OrderType::TrailingStopMarket)
443 .instrument_id(InstrumentId::new(
444 NautilusSymbol::from("AAPL"),
445 Venue::from("NASDAQ"),
446 ))
447 .side(OrderSide::Sell)
448 .quantity(Quantity::from(100))
449 .trigger_price(Price::from("149.50"))
450 .trailing_offset(dec!(0.5))
451 .trailing_offset_type(TrailingOffsetType::BasisPoints)
452 .build();
453 let contract = Contract {
454 contract_id: 0,
455 symbol: Symbol::from("AAPL"),
456 security_type: SecurityType::Stock,
457 exchange: Exchange::from("NASDAQ"),
458 currency: Currency::from("USD"),
459 ..Default::default()
460 };
461 let provider = InteractiveBrokersInstrumentProvider::new(
462 InteractiveBrokersInstrumentProviderConfig::default(),
463 );
464
465 let result = nautilus_order_to_ib_order(&order, &contract, &provider, 1, "TEST-001");
466
467 assert!(result.is_err());
468 assert!(
469 result
470 .expect_err("transform should reject unsupported trailing offset type")
471 .to_string()
472 .contains("only PRICE is supported")
473 );
474 }
475
476 #[rstest]
477 fn test_tags_apply_conditions_and_cancel_order_policy() {
478 let tags_json = r#"{
479 "outsideRth": true,
480 "whatIf": true,
481 "conditionsCancelOrder": true,
482 "conditions": [
483 {
484 "type": "price",
485 "conId": 265598,
486 "exchange": "SMART",
487 "price": 150.0,
488 "isMore": true,
489 "triggerMethod": 2,
490 "conjunction": "and"
491 },
492 {
493 "type": "time",
494 "time": "20251230 14:30:00 US/Eastern",
495 "isMore": false,
496 "conjunction": "or"
497 }
498 ]
499 }"#;
500 let order = create_test_order_with_tags(tags_json);
501 let contract = Contract {
502 contract_id: 0,
503 symbol: Symbol::from("AAPL"),
504 security_type: SecurityType::Stock,
505 exchange: Exchange::from("NASDAQ"),
506 currency: Currency::from("USD"),
507 ..Default::default()
508 };
509 let provider = InteractiveBrokersInstrumentProvider::new(
510 InteractiveBrokersInstrumentProviderConfig::default(),
511 );
512
513 let ib_order = nautilus_order_to_ib_order(&order, &contract, &provider, 1, "TEST-001")
514 .expect("order transform should succeed");
515
516 assert!(ib_order.outside_rth);
517 assert!(ib_order.what_if);
518 assert!(ib_order.conditions_cancel_order);
519 assert_eq!(ib_order.conditions.len(), 2);
520 match &ib_order.conditions[0] {
521 OrderCondition::Price(condition) => {
522 assert_eq!(condition.contract_id, 265598);
523 assert_eq!(condition.exchange, "SMART");
524 assert_eq!(condition.price, 150.0);
525 assert!(condition.is_more);
526 assert!(condition.is_conjunction);
527 }
528 other => panic!("unexpected first condition: {other:?}"),
529 }
530
531 match &ib_order.conditions[1] {
532 OrderCondition::Time(condition) => {
533 assert_eq!(condition.time, "20251230 14:30:00 US/Eastern");
534 assert!(!condition.is_more);
535 assert!(!condition.is_conjunction);
536 }
537 other => panic!("unexpected second condition: {other:?}"),
538 }
539 }
540
541 #[rstest]
542 fn test_order_list_id_sets_oca_group_when_missing() {
543 let order = OrderTestBuilder::new(OrderType::Limit)
544 .instrument_id(InstrumentId::new(
545 NautilusSymbol::from("AAPL"),
546 Venue::from("NASDAQ"),
547 ))
548 .side(OrderSide::Buy)
549 .quantity(Quantity::from(100))
550 .price(Price::from("150.00"))
551 .order_list_id(OrderListId::from("OL-001"))
552 .build();
553 let contract = Contract {
554 contract_id: 0,
555 symbol: Symbol::from("AAPL"),
556 security_type: SecurityType::Stock,
557 exchange: Exchange::from("NASDAQ"),
558 currency: Currency::from("USD"),
559 ..Default::default()
560 };
561 let provider = InteractiveBrokersInstrumentProvider::new(
562 InteractiveBrokersInstrumentProviderConfig::default(),
563 );
564
565 let ib_order = nautilus_order_to_ib_order(&order, &contract, &provider, 1, "TEST-001")
566 .expect("order transform should succeed");
567
568 assert_eq!(ib_order.oca_group, "OL-001");
569 }
570
571 #[rstest]
572 fn test_explicit_oca_group_tag_overrides_order_list_default() {
573 let order = OrderTestBuilder::new(OrderType::Limit)
574 .instrument_id(InstrumentId::new(
575 NautilusSymbol::from("AAPL"),
576 Venue::from("NASDAQ"),
577 ))
578 .side(OrderSide::Buy)
579 .quantity(Quantity::from(100))
580 .price(Price::from("150.00"))
581 .order_list_id(OrderListId::from("OL-001"))
582 .tags(vec![Ustr::from(
583 r#"IBOrderTags:{"ocaGroup":"CUSTOM-GROUP","ocaType":1}"#,
584 )])
585 .build();
586 let contract = Contract {
587 contract_id: 0,
588 symbol: Symbol::from("AAPL"),
589 security_type: SecurityType::Stock,
590 exchange: Exchange::from("NASDAQ"),
591 currency: Currency::from("USD"),
592 ..Default::default()
593 };
594 let provider = InteractiveBrokersInstrumentProvider::new(
595 InteractiveBrokersInstrumentProviderConfig::default(),
596 );
597
598 let ib_order = nautilus_order_to_ib_order(&order, &contract, &provider, 1, "TEST-001")
599 .expect("order transform should succeed");
600
601 assert_eq!(ib_order.oca_group, "CUSTOM-GROUP");
602 assert_eq!(ib_order.oca_type, ibapi::orders::OcaType::from(1));
603 }
604}