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::{
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
48/// Transform a Nautilus order to an IB order.
49///
50/// # Errors
51///
52/// Returns an error if the transformation fails.
53pub 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    // Note: Parent ID in Nautilus is ClientOrderId, but IB expects order_id.
98    // Parent order ID mapping requires client_order_id -> IB order_id tracking,
99    // which is handled at the execution client layer.
100    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
108/// Transform Nautilus order type to IB order type string and prices.
109fn 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
134/// Transform Nautilus time in force to IB time in force.
135fn 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}