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