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