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