Skip to main content

nautilus_interactive_brokers/execution/
transform.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Order transformation utilities for converting Nautilus orders to IB orders.
17
18use 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
45/// Transform a Nautilus order to an IB order.
46///
47/// # Arguments
48///
49/// * `order` - The Nautilus order
50/// * `contract` - The IB contract for the instrument
51/// * `instrument_provider` - Instrument provider for price magnifier access
52/// * `order_id` - The IB order ID
53/// * `order_ref` - The order reference (client order ID)
54///
55/// # Errors
56///
57/// Returns an error if the transformation fails.
58pub 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    // Note: Parent ID in Nautilus is ClientOrderId, but IB expects order_id.
103    // Parent order ID mapping requires client_order_id -> IB order_id tracking,
104    // which is handled at the execution client layer.
105    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
113/// Transform Nautilus order type to IB order type string and prices.
114fn 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
168/// Transform Nautilus time in force to IB time in force.
169fn 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}