Skip to main content

nautilus_interactive_brokers/execution/
parse.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//! Parsing utilities for converting IB execution data to Nautilus reports.
17
18use std::str::FromStr;
19
20use anyhow::Context;
21use ibapi::orders::{Execution, OrderStatus};
22use jiff::{
23    Timestamp,
24    civil::DateTime,
25    tz::{AmbiguousOffset, Offset},
26};
27use nautilus_core::{UnixNanos, datetime::get_timezone};
28use nautilus_model::{
29    enums::{
30        LiquiditySide, OrderSide, OrderStatus as NautilusOrderStatus, OrderType, TimeInForce,
31        TrailingOffsetType,
32    },
33    identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
34    instruments::Instrument,
35    reports::{FillReport, OrderStatusReport},
36    types::{Currency, Money, Price, Quantity},
37};
38use rust_decimal::Decimal;
39
40use crate::{
41    common::{
42        enums::{IbAction, IbOrderStatus, IbOrderType, IbTimeInForce},
43        parse::is_spread_instrument_id,
44    },
45    providers::instruments::InteractiveBrokersInstrumentProvider,
46};
47
48pub(crate) fn should_use_avg_fill_price(avg_fill_price: f64, instrument_id: &InstrumentId) -> bool {
49    avg_fill_price.is_finite()
50        && avg_fill_price != f64::MAX
51        && avg_fill_price != 0.0
52        && (avg_fill_price > 0.0 || is_spread_instrument_id(instrument_id))
53}
54
55pub(crate) fn ib_venue_order_id(order_id: i32, perm_id: i64) -> VenueOrderId {
56    if perm_id != 0 {
57        VenueOrderId::new(format!("PERM-{perm_id}"))
58    } else {
59        VenueOrderId::new(order_id.to_string())
60    }
61}
62
63pub(crate) fn normalized_order_ref(order_ref: &str) -> Option<&str> {
64    if order_ref.is_empty() {
65        return None;
66    }
67
68    Some(
69        order_ref
70            .rsplit_once(':')
71            .map_or(order_ref, |(base, _)| base),
72    )
73}
74
75/// Parse an IB execution to a Nautilus FillReport.
76///
77/// # Errors
78///
79/// Returns an error if parsing fails.
80///
81/// # Note
82///
83/// The `avg_px` parameter is stored from order status updates and is available for
84/// future use when FillReport supports additional metadata fields.
85#[allow(clippy::too_many_arguments)]
86pub fn parse_execution_to_fill_report(
87    execution: &Execution,
88    _contract: &ibapi::contracts::Contract,
89    commission: f64,
90    commission_currency: &str,
91    instrument_id: InstrumentId,
92    account_id: AccountId,
93    instrument_provider: &InteractiveBrokersInstrumentProvider,
94    ts_init: UnixNanos,
95    avg_px: Option<Price>,
96) -> anyhow::Result<FillReport> {
97    // Get price magnifier from instrument provider
98    let price_magnifier = instrument_provider.get_price_magnifier(&instrument_id) as f64;
99
100    // Convert execution price
101    let execution_price = execution.price * price_magnifier;
102
103    // Determine order side
104    let order_side = IbAction::from_str(execution.side.as_str())?.order_side();
105
106    // Get instrument for precision
107    let instrument = instrument_provider
108        .find(&instrument_id)
109        .context("Instrument not found")?;
110
111    // Create quantities and prices
112    let last_qty = Quantity::new(execution.shares, instrument.size_precision());
113    let last_px = Price::new(execution_price, instrument.price_precision());
114
115    // Clamp only IB's -1 pending sentinel to 0.0 to preserve rebates
116    let commission_clamped = if commission == -1.0 { 0.0 } else { commission };
117    let commission_money = Money::new(commission_clamped, Currency::from_str(commission_currency)?);
118
119    // Parse execution time
120    let ts_event = parse_execution_time(&execution.time)?;
121
122    // Create trade ID
123    let trade_id = TradeId::new(&execution.execution_id);
124
125    let venue_order_id = ib_venue_order_id(execution.order_id, execution.perm_id);
126
127    let client_order_id = normalized_order_ref(&execution.order_reference).map(ClientOrderId::new);
128
129    let mut report = FillReport::new(
130        account_id,
131        instrument_id,
132        venue_order_id,
133        trade_id,
134        order_side,
135        last_qty,
136        last_px,
137        commission_money,
138        LiquiditySide::NoLiquiditySide,
139        client_order_id,
140        None, // venue_position_id
141        ts_event,
142        ts_init,
143        Some(nautilus_core::UUID4::new()),
144    );
145    report.avg_px = avg_px.map(|price: Price| price.as_decimal());
146
147    Ok(report)
148}
149
150/// Parse an IB order status to a Nautilus OrderStatusReport.
151///
152/// # Errors
153///
154/// Returns an error if parsing fails.
155pub fn parse_order_status_to_report(
156    order_status: &OrderStatus,
157    order: Option<&ibapi::orders::Order>,
158    instrument_id: InstrumentId,
159    account_id: AccountId,
160    instrument_provider: &InteractiveBrokersInstrumentProvider,
161    ts_init: UnixNanos,
162) -> anyhow::Result<OrderStatusReport> {
163    // Get price magnifier from instrument provider
164    let price_magnifier = instrument_provider.get_price_magnifier(&instrument_id) as f64;
165
166    let mut nautilus_status = match IbOrderStatus::from_str(order_status.status.as_str()) {
167        Ok(status) => status.nautilus_status(),
168        _ => {
169            tracing::warn!(
170                "Unknown order status: {}, defaulting to SUBMITTED",
171                order_status.status.as_str()
172            );
173            NautilusOrderStatus::Submitted
174        }
175    };
176
177    // Get order side
178    let order_side = if let Some(order) = order {
179        IbAction::from(order.action).order_side()
180    } else {
181        // Default to Buy if order not available
182        OrderSide::Buy
183    };
184
185    let instrument = instrument_provider.find(&instrument_id);
186
187    // Get instrument for precision (use 0 as default if not available)
188    let size_precision = instrument
189        .as_ref()
190        .map_or(0, |instr| instr.size_precision());
191    let price_precision = instrument
192        .as_ref()
193        .map_or(0, |instr| instr.price_precision());
194
195    // Get quantity
196    let quantity = if let Some(order) = order {
197        Quantity::new(order.total_quantity, size_precision)
198    } else {
199        Quantity::zero(size_precision)
200    };
201
202    // Get filled quantity
203    let filled_qty = Quantity::new(order_status.filled, size_precision);
204
205    // Get average price
206    let average_fill_price = order_status.average_fill_price.unwrap_or(0.0);
207    let include_avg_px = should_use_avg_fill_price(average_fill_price, &instrument_id);
208    let avg_px_value = if include_avg_px {
209        average_fill_price * price_magnifier
210    } else {
211        0.0
212    };
213
214    if order_status.filled > 0.0
215        && (order_status.remaining > 0.0
216            || order.is_some_and(|order| order.total_quantity > order_status.filled))
217    {
218        nautilus_status = NautilusOrderStatus::PartiallyFilled;
219    }
220
221    let venue_order_id = ib_venue_order_id(order_status.order_id, order_status.perm_id);
222
223    let client_order_id = order
224        .and_then(|order| normalized_order_ref(&order.order_ref))
225        .map(ClientOrderId::new);
226
227    // Map order type from IB order if available
228    let order_type = order
229        .map(|order| map_ib_order_type(&order.order_type, order.limit_price))
230        .unwrap_or(OrderType::Market);
231
232    // Map time in force from IB order if available
233    let time_in_force = if let Some(order) = order {
234        let ib_time_in_force = IbTimeInForce::from(order.tif.clone());
235        if ib_time_in_force == IbTimeInForce::GoodTilDate || !order.good_till_date.is_empty() {
236            TimeInForce::Gtd
237        } else {
238            ib_time_in_force.nautilus_time_in_force()
239        }
240    } else {
241        TimeInForce::Day // Default when order not available
242    };
243
244    // Parse limit price if available
245    let mut report = OrderStatusReport::new(
246        account_id,
247        instrument_id,
248        client_order_id,
249        venue_order_id,
250        order_side.into(),
251        order_type,
252        time_in_force,
253        nautilus_status,
254        quantity,
255        filled_qty,
256        ts_init, // ts_accepted
257        ts_init, // ts_last
258        ts_init,
259        Some(nautilus_core::UUID4::new()), // report_id
260    );
261
262    // Set optional fields
263    if let Some(order) = order {
264        if let Some(limit_price) = order.limit_price {
265            let converted = limit_price * price_magnifier;
266            report = report.with_price(Price::new(converted, price_precision));
267        }
268
269        let (trigger_price, limit_offset, trailing_offset, trailing_offset_type) =
270            parse_ib_order_pricing_fields(order, order_type, price_magnifier, price_precision)?;
271
272        if let Some(trigger_price) = trigger_price {
273            report = report.with_trigger_price(trigger_price);
274        }
275
276        if let Some(limit_offset) = limit_offset {
277            report = report.with_limit_offset(limit_offset);
278        }
279
280        if let Some(trailing_offset) = trailing_offset {
281            report = report.with_trailing_offset(trailing_offset);
282        }
283
284        if let Some(trailing_offset_type) = trailing_offset_type {
285            report = report.with_trailing_offset_type(trailing_offset_type);
286        }
287    }
288
289    if include_avg_px {
290        report = report.with_avg_px(decimal_from_f64(avg_px_value)?);
291    }
292
293    Ok(report)
294}
295
296fn map_ib_order_type(order_type: &str, limit_price: Option<f64>) -> OrderType {
297    if order_type == "IBALGO" && limit_price.is_some_and(|price| price != 0.0) {
298        OrderType::Limit
299    } else {
300        IbOrderType::from_str(order_type)
301            .map_or(OrderType::Market, IbOrderType::nautilus_order_type)
302    }
303}
304
305fn parse_ib_order_pricing_fields(
306    order: &ibapi::orders::Order,
307    order_type: OrderType,
308    price_magnifier: f64,
309    price_precision: u8,
310) -> anyhow::Result<(
311    Option<Price>,
312    Option<Decimal>,
313    Option<Decimal>,
314    Option<TrailingOffsetType>,
315)> {
316    let mut trigger_price = None;
317    let mut limit_offset = None;
318    let mut trailing_offset = None;
319    let mut trailing_offset_type = None;
320
321    if matches!(
322        order_type,
323        OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
324    ) {
325        if let Some(trail_stop_price) = order.trail_stop_price {
326            trigger_price = Some(Price::new(
327                trail_stop_price * price_magnifier,
328                price_precision,
329            ));
330        }
331
332        if let Some(aux_price) = order.aux_price {
333            trailing_offset = Some(decimal_from_f64(aux_price)?);
334            trailing_offset_type = Some(TrailingOffsetType::Price);
335        } else if let Some(trailing_percent) = order.trailing_percent {
336            trailing_offset = Some(decimal_from_f64(trailing_percent)? * Decimal::from(100));
337            trailing_offset_type = Some(TrailingOffsetType::BasisPoints);
338        }
339
340        if order_type == OrderType::TrailingStopLimit
341            && let Some(limit_price_offset) = order.limit_price_offset
342        {
343            limit_offset = Some(decimal_from_f64(limit_price_offset)?);
344            trailing_offset_type = Some(trailing_offset_type.unwrap_or(TrailingOffsetType::Price));
345        }
346
347        return Ok((
348            trigger_price,
349            limit_offset,
350            trailing_offset,
351            trailing_offset_type,
352        ));
353    }
354
355    if let Some(aux_price) = order.aux_price {
356        trigger_price = Some(Price::new(aux_price * price_magnifier, price_precision));
357    }
358
359    Ok((
360        trigger_price,
361        limit_offset,
362        trailing_offset,
363        trailing_offset_type,
364    ))
365}
366
367fn decimal_from_f64(value: f64) -> anyhow::Result<Decimal> {
368    Decimal::from_str(&value.to_string())
369        .with_context(|| format!("Failed to convert IB floating-point value {value} to Decimal"))
370}
371
372/// Parse execution time string to UnixNanos.
373///
374/// Parse IB execution time to UnixNanos.
375///
376/// Supported IB formats:
377/// - "20230223 00:43:36 Universal"
378/// - "20230223 00:43:36 UTC"
379/// - "20230223 00:43:36 MET"
380/// - "20230223 00:43:36 America/New_York"
381/// - "20230223 00:43:36" (assumed UTC)
382/// - "20250225-15:15:00" (assumed UTC)
383///
384/// Timezones are resolved through Jiff's bundled IANA tz database, so any
385/// region abbreviation or name that IB stamps the execution with (e.g. `MET`,
386/// `EST`, `America/New_York`) is honored, matching the v1 pandas-based parser.
387/// This matters because some IB accounts (e.g. European paper accounts) report a
388/// server timezone such as `MET` that the gateway cannot be coerced out of.
389///
390/// # Errors
391///
392/// Returns an error if the timestamp is malformed, the timezone is
393/// unrecognized, or the local time is non-existent (a DST spring-forward gap).
394/// DST fall-back folds resolve to the earliest matching instant.
395pub fn parse_execution_time(time_str: &str) -> anyhow::Result<UnixNanos> {
396    const NAIVE_FORMAT: &str = "%Y%m%d %H:%M:%S";
397
398    // Hyphenated, space-less form (e.g. "20250225-15:15:00") is always UTC.
399    if !time_str.contains(' ') {
400        let normalized = time_str.replace('-', " ");
401        let dt = DateTime::strptime(NAIVE_FORMAT, &normalized).map_err(|e| {
402            anyhow::anyhow!("Failed to parse execution timestamp '{time_str}': {e}")
403        })?;
404        return datetime_to_unix_nanos(Offset::UTC.to_timestamp(dt)?, time_str);
405    }
406
407    // Split into at most three parts: date, time, and optional timezone token.
408    // The timezone token itself never contains a space, so `splitn(3, ' ')`
409    // correctly groups IANA names such as "America/New_York".
410    let mut parts = time_str.splitn(3, ' ');
411    let (Some(date), Some(time)) = (parts.next(), parts.next()) else {
412        anyhow::bail!("Invalid execution time format: {time_str}");
413    };
414    let tz_str = parts.next().unwrap_or("").trim();
415
416    let naive_str = format!("{date} {time}");
417    let dt = DateTime::strptime(NAIVE_FORMAT, &naive_str)
418        .map_err(|e| anyhow::anyhow!("Failed to parse execution timestamp '{time_str}': {e}"))?;
419
420    let utc = if tz_str.is_empty() {
421        Offset::UTC.to_timestamp(dt)?
422    } else {
423        localize_with_zone(dt, tz_str, time_str)?
424    };
425
426    datetime_to_unix_nanos(utc, time_str)
427}
428
429/// Localize a naive timestamp against an IB timezone token and convert to UTC.
430///
431/// `Z` is normalized to `UTC`; everything else is resolved through the IANA tz
432/// database. Error and fold behavior is documented on [`parse_execution_time`].
433fn localize_with_zone(dt: DateTime, tz_str: &str, time_str: &str) -> anyhow::Result<Timestamp> {
434    let tz_name = if tz_str.eq_ignore_ascii_case("Z") {
435        "UTC"
436    } else {
437        tz_str
438    };
439
440    let zone = get_timezone(tz_name).map_err(|_| {
441        anyhow::anyhow!(
442            "Unrecognised execution timezone '{tz_str}' in '{time_str}'. Configure TWS / IB Gateway to emit a standard timezone (e.g. UTC)"
443        )
444    })?;
445    let ambiguous = zone.to_ambiguous_timestamp(dt);
446    match ambiguous.offset() {
447        AmbiguousOffset::Unambiguous { .. } => Ok(ambiguous.unambiguous()?),
448        // Fall-back fold: take the earliest instant (worst case ~1h skew).
449        AmbiguousOffset::Fold { .. } => Ok(ambiguous.earlier()?),
450        AmbiguousOffset::Gap { .. } => {
451            anyhow::bail!("Execution timestamp '{time_str}' is non-existent in timezone '{tz_str}'")
452        }
453    }
454}
455
456fn datetime_to_unix_nanos(dt: Timestamp, time_str: &str) -> anyhow::Result<UnixNanos> {
457    let nanos: u64 = dt
458        .as_nanosecond()
459        .try_into()
460        .map_err(|_| anyhow::anyhow!("Execution timestamp '{time_str}' was before Unix epoch"))?;
461    Ok(UnixNanos::new(nanos))
462}
463
464#[cfg(test)]
465mod tests {
466    use ibapi::{
467        contracts::Contract,
468        orders::{Action, ExecutionSide, Liquidity, Order, OrderStatusKind},
469    };
470    use nautilus_model::{
471        enums::TrailingOffsetType,
472        identifiers::{Symbol, Venue},
473        instruments::{InstrumentAny, stubs::equity_aapl},
474    };
475    use rust_decimal::Decimal;
476
477    use super::*;
478    use crate::{
479        config::InteractiveBrokersInstrumentProviderConfig,
480        providers::instruments::InteractiveBrokersInstrumentProvider,
481    };
482
483    fn create_test_instrument_provider() -> InteractiveBrokersInstrumentProvider {
484        let config = InteractiveBrokersInstrumentProviderConfig::default();
485        InteractiveBrokersInstrumentProvider::new(config)
486    }
487
488    fn create_test_instrument_id() -> InstrumentId {
489        InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"))
490    }
491
492    use rstest::rstest;
493
494    #[rstest]
495    fn test_ibalgo_with_zero_limit_price_maps_to_market() {
496        assert_eq!(map_ib_order_type("IBALGO", Some(0.0)), OrderType::Market);
497    }
498
499    #[rstest]
500    fn test_parse_execution_time_hyphenated_format() {
501        let time_str = "20250225-15:15:00";
502        let result = parse_execution_time(time_str);
503        assert!(result.is_ok());
504        let timestamp = result.unwrap();
505        assert!(timestamp.as_i64() > 0);
506    }
507
508    #[rstest]
509    fn test_parse_execution_time_with_met_timezone() {
510        // Regression for European paper accounts that IB stamps with `MET`.
511        // MET (CET) in February observes standard time (UTC+1).
512        let met = parse_execution_time("20230223 00:43:36 MET").unwrap();
513        let utc = parse_execution_time("20230223 00:43:36 Universal").unwrap();
514        // Local 00:43:36 MET == 2023-02-22 23:43:36 UTC, i.e. 1 hour before UTC.
515        assert_eq!(
516            met.as_i64(),
517            utc.as_i64() - 3_600_000_000_000,
518            "MET (CET) should be 1h ahead of UTC in February"
519        );
520        assert!(met.as_i64() > 0);
521    }
522
523    #[rstest]
524    fn test_parse_execution_time_applies_dst_for_regional_timezone() {
525        // Same zone, two seasons: EST (UTC-5) in winter vs EDT (UTC-4) in summer.
526        // Equal offsets would mean DST is NOT being applied - a real regression.
527        let winter = parse_execution_time("20230223 00:43:36 America/New_York").unwrap();
528        let summer = parse_execution_time("20230715 00:43:36 America/New_York").unwrap();
529        let winter_utc = parse_execution_time("20230223 00:43:36 Universal").unwrap();
530        let summer_utc = parse_execution_time("20230715 00:43:36 Universal").unwrap();
531        assert_eq!(winter.as_i64(), winter_utc.as_i64() + 5 * 3_600_000_000_000); // EST
532        assert_eq!(summer.as_i64(), summer_utc.as_i64() + 4 * 3_600_000_000_000); // EDT
533    }
534
535    #[rstest]
536    fn test_parse_execution_time_dst_fall_back_fold_resolves_to_earliest() {
537        // CME US/Central account (bebop23's case): on 2023-11-05 fall-back night
538        // 01:30 America/Chicago occurs twice. Resolve to earliest (CDT, 06:30 UTC),
539        // don't drop the fill.
540        let fold = parse_execution_time("20231105 01:30:00 America/Chicago").unwrap();
541        assert_eq!(
542            fold.as_i64(),
543            parse_execution_time("20231105 06:30:00 Universal")
544                .unwrap()
545                .as_i64()
546        );
547        assert_ne!(
548            fold.as_i64(),
549            parse_execution_time("20231105 07:30:00 Universal")
550                .unwrap()
551                .as_i64()
552        );
553    }
554
555    #[rstest]
556    fn test_parse_execution_time_dst_spring_forward_gap_errors() {
557        // 02:30 America/Chicago never exists on 2023-03-12 spring-forward night.
558        let gap = parse_execution_time("20230312 02:30:00 America/Chicago");
559        assert!(gap.is_err());
560    }
561
562    #[rstest]
563    fn test_parse_execution_time_fixed_offset_zone_without_dst() {
564        // Asia/Tokyo is JST (UTC+9) year-round - guards the no-DST path.
565        let tokyo = parse_execution_time("20230223 00:43:36 Asia/Tokyo").unwrap();
566        let utc = parse_execution_time("20230223 00:43:36 Universal").unwrap();
567        assert_eq!(tokyo.as_i64(), utc.as_i64() - 9 * 3_600_000_000_000);
568    }
569
570    #[rstest]
571    fn test_parse_execution_time_with_unrecognised_timezone_errors() {
572        let time_str = "20230223 00:43:36 Mars/Olympus";
573        let result = parse_execution_time(time_str);
574        assert!(result.is_err());
575    }
576
577    #[rstest]
578    fn test_parse_execution_time_utc() {
579        let time_str = "20230223 00:43:36 Universal";
580        let result = parse_execution_time(time_str);
581        assert!(result.is_ok());
582        let timestamp = result.unwrap();
583        assert!(timestamp.as_i64() > 0);
584    }
585
586    #[rstest]
587    fn test_parse_execution_time_no_timezone_assumes_utc() {
588        let time_str = "20230223 00:43:36";
589        let result = parse_execution_time(time_str);
590        assert!(result.is_ok());
591        let timestamp = result.unwrap();
592        assert!(timestamp.as_i64() > 0);
593    }
594
595    #[rstest]
596    fn test_parse_execution_time_invalid_format() {
597        let time_str = "invalid format";
598        let result = parse_execution_time(time_str);
599        assert!(result.is_err());
600    }
601
602    #[rstest]
603    fn test_parse_execution_time_short_format() {
604        let time_str = "20230223 00:43";
605        let result = parse_execution_time(time_str);
606        assert!(result.is_err());
607    }
608
609    #[rstest]
610    fn test_parse_order_status_to_report_submitted() {
611        let instrument_provider = create_test_instrument_provider();
612        let instrument_id = create_test_instrument_id();
613        let account_id = AccountId::from("IB-001");
614
615        let order_status = OrderStatus {
616            order_id: 12345,
617            status: OrderStatusKind::Submitted,
618            filled: 0.0,
619            remaining: 100.0,
620            average_fill_price: Some(0.0),
621            perm_id: 0,
622            parent_id: 0,
623            last_fill_price: Some(0.0),
624            client_id: 0,
625            why_held: String::new(),
626            market_cap_price: Some(0.0),
627        };
628
629        let result = parse_order_status_to_report(
630            &order_status,
631            None,
632            instrument_id,
633            account_id,
634            &instrument_provider,
635            UnixNanos::new(0),
636        );
637
638        // May fail if instrument not in provider, but that's expected
639        if let Err(e) = result {
640            let error_msg = e.to_string();
641            assert!(
642                error_msg.contains("not found") || error_msg.contains("instrument"),
643                "Unexpected error: {}",
644                error_msg
645            );
646        }
647    }
648
649    #[rstest]
650    fn test_parse_order_status_to_report_filled() {
651        let instrument_provider = create_test_instrument_provider();
652        let instrument_id = create_test_instrument_id();
653        let account_id = AccountId::from("IB-001");
654
655        let order_status = OrderStatus {
656            order_id: 12345,
657            status: OrderStatusKind::Filled,
658            filled: 100.0,
659            remaining: 0.0,
660            average_fill_price: Some(150.25),
661            perm_id: 0,
662            parent_id: 0,
663            last_fill_price: Some(150.25),
664            client_id: 0,
665            why_held: String::new(),
666            market_cap_price: Some(0.0),
667        };
668
669        let result = parse_order_status_to_report(
670            &order_status,
671            None,
672            instrument_id,
673            account_id,
674            &instrument_provider,
675            UnixNanos::new(0),
676        );
677
678        // May fail if instrument not in provider, but that's expected
679        if let Err(e) = result {
680            let error_msg = e.to_string();
681            assert!(
682                error_msg.contains("not found") || error_msg.contains("instrument"),
683                "Unexpected error: {}",
684                error_msg
685            );
686        }
687    }
688
689    #[rstest]
690    fn test_parse_order_status_to_report_spread_allows_negative_avg_fill_price() {
691        let instrument_provider = create_test_instrument_provider();
692        let instrument_id = InstrumentId::new(
693            Symbol::from("(1)SPY C400_((1))SPY C410"),
694            Venue::from("SMART"),
695        );
696        let account_id = AccountId::from("IB-001");
697
698        let order_status = OrderStatus {
699            order_id: 12345,
700            status: OrderStatusKind::Filled,
701            filled: 1.0,
702            remaining: 0.0,
703            average_fill_price: Some(-2.25),
704            perm_id: 0,
705            parent_id: 0,
706            last_fill_price: Some(-2.25),
707            client_id: 0,
708            why_held: String::new(),
709            market_cap_price: Some(0.0),
710        };
711
712        let report = parse_order_status_to_report(
713            &order_status,
714            None,
715            instrument_id,
716            account_id,
717            &instrument_provider,
718            UnixNanos::new(0),
719        )
720        .unwrap();
721
722        assert_eq!(report.avg_px, Some(Decimal::from_str("-2.25").unwrap()));
723    }
724
725    #[rstest]
726    fn test_parse_order_status_to_report_inactive_maps_to_rejected() {
727        let instrument_provider = create_test_instrument_provider();
728        let instrument_id = create_test_instrument_id();
729        let account_id = AccountId::from("IB-001");
730
731        let order_status = OrderStatus {
732            order_id: 12345,
733            status: OrderStatusKind::Inactive,
734            filled: 0.0,
735            remaining: 100.0,
736            average_fill_price: Some(0.0),
737            perm_id: 0,
738            parent_id: 0,
739            last_fill_price: Some(0.0),
740            client_id: 0,
741            why_held: String::new(),
742            market_cap_price: Some(0.0),
743        };
744
745        let report = parse_order_status_to_report(
746            &order_status,
747            None,
748            instrument_id,
749            account_id,
750            &instrument_provider,
751            UnixNanos::new(0),
752        )
753        .unwrap();
754
755        assert_eq!(report.order_status, NautilusOrderStatus::Rejected);
756    }
757
758    #[rstest]
759    fn test_parse_order_status_to_report_partial_fill_and_perm_fallback() {
760        let instrument_provider = create_test_instrument_provider();
761        let instrument_id = create_test_instrument_id();
762        let account_id = AccountId::from("IB-001");
763
764        let order_status = OrderStatus {
765            order_id: 0,
766            status: OrderStatusKind::Submitted,
767            filled: 3.0,
768            remaining: 7.0,
769            average_fill_price: Some(150.25),
770            perm_id: 123_456,
771            parent_id: 0,
772            last_fill_price: Some(150.25),
773            client_id: 0,
774            why_held: String::new(),
775            market_cap_price: Some(0.0),
776        };
777        let order = Order {
778            action: Action::Buy,
779            total_quantity: 10.0,
780            order_type: "LMT".to_string(),
781            limit_price: Some(150.25),
782            order_ref: "O-20260527-001:123".to_string(),
783            ..Default::default()
784        };
785
786        let report = parse_order_status_to_report(
787            &order_status,
788            Some(&order),
789            instrument_id,
790            account_id,
791            &instrument_provider,
792            UnixNanos::new(0),
793        )
794        .unwrap();
795
796        assert_eq!(report.order_status, NautilusOrderStatus::PartiallyFilled);
797        assert_eq!(report.venue_order_id.to_string(), "PERM-123456");
798        assert_eq!(
799            report.client_order_id,
800            Some(ClientOrderId::from("O-20260527-001"))
801        );
802    }
803
804    #[rstest]
805    fn test_ib_venue_order_id_prefers_perm_id_and_falls_back_to_order_id() {
806        assert_eq!(ib_venue_order_id(123, 456).to_string(), "PERM-456");
807        assert_eq!(ib_venue_order_id(123, 0).to_string(), "123");
808    }
809
810    #[rstest]
811    fn test_normalized_order_ref_strips_ib_suffix() {
812        assert_eq!(normalized_order_ref("O-001:123"), Some("O-001"));
813        assert_eq!(normalized_order_ref("O-001"), Some("O-001"));
814        assert_eq!(normalized_order_ref(""), None);
815    }
816
817    #[rstest]
818    #[case(
819        "MKT",
820        None,
821        None,
822        None,
823        None,
824        OrderType::Market,
825        None,
826        None,
827        None,
828        None,
829        None
830    )]
831    #[case(
832        "LMT",
833        Some(185.0),
834        None,
835        None,
836        None,
837        OrderType::Limit,
838        Some(Price::new(185.0, 0)),
839        None,
840        None,
841        None,
842        None
843    )]
844    #[case(
845        "IBALGO",
846        Some(185.0),
847        None,
848        None,
849        None,
850        OrderType::Limit,
851        Some(Price::new(185.0, 0)),
852        None,
853        None,
854        None,
855        None
856    )]
857    #[case(
858        "IBALGO",
859        None,
860        None,
861        None,
862        None,
863        OrderType::Market,
864        None,
865        None,
866        None,
867        None,
868        None
869    )]
870    #[case(
871        "MIT",
872        None,
873        Some(180.0),
874        None,
875        None,
876        OrderType::MarketIfTouched,
877        None,
878        Some(Price::new(180.0, 0)),
879        None,
880        None,
881        None
882    )]
883    #[case(
884        "LIT",
885        Some(179.0),
886        Some(180.0),
887        None,
888        None,
889        OrderType::LimitIfTouched,
890        Some(Price::new(179.0, 0)),
891        Some(Price::new(180.0, 0)),
892        None,
893        None,
894        None
895    )]
896    #[case(
897        "STP",
898        None,
899        Some(180.0),
900        None,
901        None,
902        OrderType::StopMarket,
903        None,
904        Some(Price::new(180.0, 0)),
905        None,
906        None,
907        None
908    )]
909    #[case(
910        "STP LMT",
911        Some(179.0),
912        Some(180.0),
913        None,
914        None,
915        OrderType::StopLimit,
916        Some(Price::new(179.0, 0)),
917        Some(Price::new(180.0, 0)),
918        None,
919        None,
920        None
921    )]
922    #[case(
923        "TRAIL LIMIT",
924        None,
925        Some(2.5),
926        Some(185.0),
927        Some(0.25),
928        OrderType::TrailingStopLimit,
929        None,
930        Some(Price::new(185.0, 0)),
931        Some(Decimal::from_str("0.25").unwrap()),
932        Some(Decimal::from_str("2.5").unwrap()),
933        Some(TrailingOffsetType::Price),
934    )]
935    fn test_parse_order_status_to_report_maps_pricing_fields_by_order_type(
936        #[case] ib_order_type: &str,
937        #[case] limit_price: Option<f64>,
938        #[case] aux_price: Option<f64>,
939        #[case] trail_stop_price: Option<f64>,
940        #[case] limit_price_offset: Option<f64>,
941        #[case] expected_order_type: OrderType,
942        #[case] expected_price: Option<Price>,
943        #[case] expected_trigger_price: Option<Price>,
944        #[case] expected_limit_offset: Option<Decimal>,
945        #[case] expected_trailing_offset: Option<Decimal>,
946        #[case] expected_trailing_offset_type: Option<TrailingOffsetType>,
947    ) {
948        let instrument_provider = create_test_instrument_provider();
949        let instrument_id = create_test_instrument_id();
950        let account_id = AccountId::from("IB-001");
951
952        let order_status = OrderStatus {
953            order_id: 12345,
954            status: OrderStatusKind::Submitted,
955            filled: 0.0,
956            remaining: 5.0,
957            average_fill_price: Some(0.0),
958            perm_id: 0,
959            parent_id: 0,
960            last_fill_price: Some(0.0),
961            client_id: 0,
962            why_held: String::new(),
963            market_cap_price: Some(0.0),
964        };
965
966        let order = Order {
967            action: Action::Buy,
968            total_quantity: 5.0,
969            order_type: ib_order_type.to_string(),
970            limit_price,
971            aux_price,
972            trail_stop_price,
973            limit_price_offset,
974            tif: ibapi::orders::TimeInForce::GoodTilCanceled,
975            ..Default::default()
976        };
977
978        let report = parse_order_status_to_report(
979            &order_status,
980            Some(&order),
981            instrument_id,
982            account_id,
983            &instrument_provider,
984            UnixNanos::new(0),
985        )
986        .unwrap();
987
988        assert_eq!(report.order_type, expected_order_type);
989        assert_eq!(report.price, expected_price);
990        assert_eq!(report.trigger_price, expected_trigger_price);
991        assert_eq!(report.limit_offset, expected_limit_offset);
992        assert_eq!(report.trailing_offset, expected_trailing_offset);
993        assert_eq!(report.trailing_offset_type, expected_trailing_offset_type);
994    }
995
996    #[rstest]
997    fn test_parse_order_status_to_report_maps_trailing_percent_to_basis_points() {
998        let instrument_provider = create_test_instrument_provider();
999        let instrument_id = create_test_instrument_id();
1000        let account_id = AccountId::from("IB-001");
1001
1002        let order_status = OrderStatus {
1003            order_id: 12345,
1004            status: OrderStatusKind::Submitted,
1005            filled: 0.0,
1006            remaining: 5.0,
1007            average_fill_price: Some(0.0),
1008            perm_id: 0,
1009            parent_id: 0,
1010            last_fill_price: Some(0.0),
1011            client_id: 0,
1012            why_held: String::new(),
1013            market_cap_price: Some(0.0),
1014        };
1015
1016        let order = Order {
1017            action: Action::Buy,
1018            total_quantity: 5.0,
1019            order_type: "TRAIL".to_string(),
1020            trail_stop_price: Some(185.0),
1021            trailing_percent: Some(2.5),
1022            tif: ibapi::orders::TimeInForce::GoodTilCanceled,
1023            ..Default::default()
1024        };
1025
1026        let report = parse_order_status_to_report(
1027            &order_status,
1028            Some(&order),
1029            instrument_id,
1030            account_id,
1031            &instrument_provider,
1032            UnixNanos::new(0),
1033        )
1034        .unwrap();
1035
1036        assert_eq!(report.order_type, OrderType::TrailingStopMarket);
1037        assert_eq!(report.trigger_price, Some(Price::new(185.0, 0)));
1038        assert_eq!(
1039            report.trailing_offset,
1040            Some(Decimal::from_str("250").unwrap())
1041        );
1042        assert_eq!(
1043            report.trailing_offset_type,
1044            Some(TrailingOffsetType::BasisPoints),
1045        );
1046        assert_eq!(report.limit_offset, None);
1047    }
1048
1049    #[rstest]
1050    fn test_parse_execution_to_fill_report_buy() {
1051        let instrument_provider = create_test_instrument_provider();
1052        let instrument_id = create_test_instrument_id();
1053        let account_id = AccountId::from("IB-001");
1054
1055        let execution = Execution {
1056            order_id: 12345,
1057            client_id: 0,
1058            execution_id: String::from("EXEC-001"),
1059            time: String::from("20230223 00:43:36 Universal"),
1060            account_number: String::new(),
1061            exchange: String::new(),
1062            side: ExecutionSide::Bought,
1063            shares: 100.0,
1064            price: 150.25,
1065            perm_id: 0,
1066            liquidation: 0,
1067            cumulative_quantity: 100.0,
1068            average_price: 150.25,
1069            order_reference: String::from("ORDER-REF-001"),
1070            ev_rule: String::new(),
1071            ev_multiplier: None,
1072            model_code: String::new(),
1073            last_liquidity: Liquidity::None,
1074            pending_price_revision: false,
1075            submitter: String::new(),
1076        };
1077
1078        let contract = Contract::default();
1079        let result = parse_execution_to_fill_report(
1080            &execution,
1081            &contract,
1082            1.0,
1083            "USD",
1084            instrument_id,
1085            account_id,
1086            &instrument_provider,
1087            UnixNanos::new(0),
1088            None, // avg_px
1089        );
1090
1091        // May fail if instrument not in provider, but that's expected
1092        match result {
1093            Err(e) => {
1094                let error_msg = e.to_string();
1095                assert!(
1096                    error_msg.contains("not found") || error_msg.contains("instrument"),
1097                    "Unexpected error: {}",
1098                    error_msg
1099                );
1100            }
1101            Ok(fill) => {
1102                assert_eq!(fill.order_side, OrderSide::Buy);
1103                assert_eq!(fill.trade_id.to_string(), "EXEC-001");
1104            }
1105        }
1106    }
1107
1108    #[rstest]
1109    fn test_parse_execution_to_fill_report_clamps_only_pending_commission_sentinel() {
1110        let instrument_provider = create_test_instrument_provider();
1111        let instrument = equity_aapl();
1112        let instrument_id = instrument.id();
1113        instrument_provider.insert_test_instrument(InstrumentAny::from(instrument), 265598, 1);
1114        let account_id = AccountId::from("IB-001");
1115        let contract = Contract::default();
1116
1117        for (commission, expected) in [(-1.0, 0.0), (-0.25, -0.25)] {
1118            let execution = Execution {
1119                order_id: 12345,
1120                client_id: 0,
1121                execution_id: format!("EXEC-{commission}"),
1122                time: String::from("20230223 00:43:36 Universal"),
1123                account_number: String::new(),
1124                exchange: String::new(),
1125                side: ExecutionSide::Bought,
1126                shares: 100.0,
1127                price: 150.25,
1128                perm_id: 0,
1129                liquidation: 0,
1130                cumulative_quantity: 100.0,
1131                average_price: 150.25,
1132                order_reference: String::from("ORDER-REF-001"),
1133                ev_rule: String::new(),
1134                ev_multiplier: None,
1135                model_code: String::new(),
1136                last_liquidity: Liquidity::None,
1137                pending_price_revision: false,
1138                submitter: String::new(),
1139            };
1140
1141            let report = parse_execution_to_fill_report(
1142                &execution,
1143                &contract,
1144                commission,
1145                "USD",
1146                instrument_id,
1147                account_id,
1148                &instrument_provider,
1149                UnixNanos::new(0),
1150                None,
1151            )
1152            .unwrap();
1153
1154            assert_eq!(report.commission, Money::new(expected, Currency::USD()));
1155        }
1156    }
1157
1158    #[rstest]
1159    fn test_parse_execution_to_fill_report_sell() {
1160        let instrument_provider = create_test_instrument_provider();
1161        let instrument_id = create_test_instrument_id();
1162        let account_id = AccountId::from("IB-001");
1163
1164        let execution = Execution {
1165            order_id: 12345,
1166            client_id: 0,
1167            execution_id: String::from("EXEC-002"),
1168            time: String::from("20230223 00:43:36 Universal"),
1169            account_number: String::new(),
1170            exchange: String::new(),
1171            side: ExecutionSide::Sold,
1172            shares: 50.0,
1173            price: 151.0,
1174            perm_id: 0,
1175            liquidation: 0,
1176            cumulative_quantity: 50.0,
1177            average_price: 151.0,
1178            order_reference: String::new(),
1179            ev_rule: String::new(),
1180            ev_multiplier: None,
1181            model_code: String::new(),
1182            last_liquidity: Liquidity::None,
1183            pending_price_revision: false,
1184            submitter: String::new(),
1185        };
1186
1187        let contract = Contract::default();
1188        let result = parse_execution_to_fill_report(
1189            &execution,
1190            &contract,
1191            0.5,
1192            "USD",
1193            instrument_id,
1194            account_id,
1195            &instrument_provider,
1196            UnixNanos::new(0),
1197            None, // avg_px
1198        );
1199
1200        // May fail if instrument not in provider, but that's expected
1201        match result {
1202            Err(e) => {
1203                let error_msg = e.to_string();
1204                assert!(
1205                    error_msg.contains("not found") || error_msg.contains("instrument"),
1206                    "Unexpected error: {}",
1207                    error_msg
1208                );
1209            }
1210            Ok(fill) => {
1211                assert_eq!(fill.order_side, OrderSide::Sell);
1212            }
1213        }
1214    }
1215}