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 nautilus_core::UnixNanos;
23use nautilus_model::{
24    enums::{
25        LiquiditySide, OrderSide, OrderStatus as NautilusOrderStatus, OrderType, TimeInForce,
26        TrailingOffsetType,
27    },
28    identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
29    instruments::Instrument,
30    reports::{FillReport, OrderStatusReport},
31    types::{Currency, Money, Price, Quantity},
32};
33use rust_decimal::Decimal;
34use time::{PrimitiveDateTime, macros::format_description};
35
36use crate::{
37    common::parse::is_spread_instrument_id,
38    providers::instruments::InteractiveBrokersInstrumentProvider,
39};
40
41pub(crate) fn should_use_avg_fill_price(avg_fill_price: f64, instrument_id: &InstrumentId) -> bool {
42    avg_fill_price.is_finite()
43        && avg_fill_price != f64::MAX
44        && avg_fill_price != 0.0
45        && (avg_fill_price > 0.0 || is_spread_instrument_id(instrument_id))
46}
47
48/// Parse an IB execution to a Nautilus FillReport.
49///
50/// # Arguments
51///
52/// * `execution` - The IB execution
53/// * `contract` - The IB contract
54/// * `commission` - Commission amount
55/// * `commission_currency` - Commission currency
56/// * `instrument_id` - The instrument ID
57/// * `account_id` - The account ID
58/// * `instrument_provider` - Instrument provider for price conversion
59/// * `ts_init` - Initial timestamp
60/// * `avg_px` - Optional average fill price (from order status tracking)
61///
62/// # Errors
63///
64/// Returns an error if parsing fails.
65///
66/// # Note
67///
68/// The `avg_px` parameter is stored from order status updates and is available for
69/// future use when FillReport supports additional metadata fields.
70#[allow(clippy::too_many_arguments)]
71pub fn parse_execution_to_fill_report(
72    execution: &Execution,
73    _contract: &ibapi::contracts::Contract,
74    commission: f64,
75    commission_currency: &str,
76    instrument_id: InstrumentId,
77    account_id: AccountId,
78    instrument_provider: &InteractiveBrokersInstrumentProvider,
79    ts_init: UnixNanos,
80    avg_px: Option<Price>,
81) -> anyhow::Result<FillReport> {
82    // Get price magnifier from instrument provider
83    let price_magnifier = instrument_provider.get_price_magnifier(&instrument_id) as f64;
84
85    // Convert execution price
86    let execution_price = execution.price * price_magnifier;
87
88    // Determine order side
89    let order_side = match execution.side.as_str() {
90        "BUY" | "BOT" => OrderSide::Buy,
91        "SELL" | "SLD" => OrderSide::Sell,
92        _ => anyhow::bail!("Unknown order side: {}", execution.side),
93    };
94
95    // Get instrument for precision
96    let instrument = instrument_provider
97        .find(&instrument_id)
98        .context("Instrument not found")?;
99
100    // Create quantities and prices
101    let last_qty = Quantity::new(execution.shares, instrument.size_precision());
102    let last_px = Price::new(execution_price, instrument.price_precision());
103
104    // Create commission
105    let commission_money = Money::new(commission, Currency::from_str(commission_currency)?);
106
107    // Parse execution time
108    let ts_event = parse_execution_time(&execution.time)?;
109
110    // Create trade ID
111    let trade_id = TradeId::new(&execution.execution_id);
112
113    // Create venue order ID
114    let venue_order_id = VenueOrderId::new(execution.order_id.to_string());
115
116    // Parse client order ID from order reference
117    let client_order_id = if !execution.order_reference.is_empty() {
118        Some(ClientOrderId::new(&execution.order_reference))
119    } else {
120        None
121    };
122
123    let mut report = FillReport::new(
124        account_id,
125        instrument_id,
126        venue_order_id,
127        trade_id,
128        order_side,
129        last_qty,
130        last_px,
131        commission_money,
132        LiquiditySide::NoLiquiditySide,
133        client_order_id,
134        None, // venue_position_id
135        ts_event,
136        ts_init,
137        Some(nautilus_core::UUID4::new()),
138    );
139    report.avg_px = avg_px.map(|price: Price| price.as_decimal());
140
141    Ok(report)
142}
143
144/// Parse an IB order status to a Nautilus OrderStatusReport.
145///
146/// # Arguments
147///
148/// * `order_status` - The IB order status
149/// * `order` - The IB order (if available)
150/// * `instrument_id` - The instrument ID
151/// * `account_id` - The account ID
152/// * `instrument_provider` - Instrument provider for price conversion
153/// * `ts_init` - Initial timestamp
154///
155/// # Errors
156///
157/// Returns an error if parsing fails.
158pub fn parse_order_status_to_report(
159    order_status: &OrderStatus,
160    order: Option<&ibapi::orders::Order>,
161    instrument_id: InstrumentId,
162    account_id: AccountId,
163    instrument_provider: &InteractiveBrokersInstrumentProvider,
164    ts_init: UnixNanos,
165) -> anyhow::Result<OrderStatusReport> {
166    // Get price magnifier from instrument provider
167    let price_magnifier = instrument_provider.get_price_magnifier(&instrument_id) as f64;
168
169    // Convert Nautilus order status
170    let nautilus_status = match order_status.status.as_str() {
171        "ApiPending" | "PendingSubmit" | "PreSubmitted" => NautilusOrderStatus::Submitted,
172        "Submitted" => NautilusOrderStatus::Accepted,
173        "PendingCancel" => NautilusOrderStatus::PendingCancel,
174        "ApiCancelled" | "Cancelled" => NautilusOrderStatus::Canceled,
175        "Filled" => NautilusOrderStatus::Filled,
176        "Inactive" => NautilusOrderStatus::Rejected,
177        _ => {
178            tracing::warn!(
179                "Unknown order status: {}, defaulting to SUBMITTED",
180                order_status.status
181            );
182            NautilusOrderStatus::Submitted
183        }
184    };
185
186    // Get order side
187    let order_side = if let Some(order) = order {
188        match order.action {
189            ibapi::orders::Action::Buy => OrderSide::Buy,
190            ibapi::orders::Action::Sell => OrderSide::Sell,
191            ibapi::orders::Action::SellShort => OrderSide::Sell,
192            ibapi::orders::Action::SellLong => OrderSide::Sell,
193        }
194    } else {
195        // Default to Buy if order not available
196        OrderSide::Buy
197    };
198
199    let instrument = instrument_provider.find(&instrument_id);
200
201    // Get instrument for precision (use 0 as default if not available)
202    let size_precision = instrument
203        .as_ref()
204        .map_or(0, |instr| instr.size_precision());
205    let price_precision = instrument
206        .as_ref()
207        .map_or(0, |instr| instr.price_precision());
208
209    // Get quantity
210    let quantity = if let Some(order) = order {
211        Quantity::new(order.total_quantity, size_precision)
212    } else {
213        Quantity::zero(size_precision)
214    };
215
216    // Get filled quantity
217    let filled_qty = Quantity::new(order_status.filled, size_precision);
218
219    // Get average price
220    let include_avg_px = should_use_avg_fill_price(order_status.average_fill_price, &instrument_id);
221    let avg_px_value = if include_avg_px {
222        order_status.average_fill_price * price_magnifier
223    } else {
224        0.0
225    };
226
227    // Extract venue order ID from order_status
228    let venue_order_id = VenueOrderId::new(order_status.order_id.to_string());
229
230    // Extract client order ID from order reference if available
231    let client_order_id = if let Some(order) = order {
232        if order.order_ref.is_empty() {
233            None
234        } else {
235            Some(ClientOrderId::new(&order.order_ref))
236        }
237    } else {
238        None
239    };
240
241    // Map order type from IB order if available
242    let order_type = order
243        .map(|order| map_ib_order_type(&order.order_type))
244        .unwrap_or(OrderType::Market);
245
246    // Map time in force from IB order if available
247    let time_in_force = if let Some(order) = order {
248        let tif_str = order.tif.to_string();
249        match tif_str.as_str() {
250            "DAY" => TimeInForce::Day,
251            "GTC" => TimeInForce::Gtc,
252            "IOC" => TimeInForce::Ioc,
253            "FOK" => TimeInForce::Fok,
254            _ => {
255                // Try to parse GTD date
256                if tif_str.starts_with("GTD") || !order.good_till_date.is_empty() {
257                    TimeInForce::Gtd
258                } else {
259                    TimeInForce::Day // Default fallback
260                }
261            }
262        }
263    } else {
264        TimeInForce::Day // Default when order not available
265    };
266
267    // Parse limit price if available
268    let mut report = OrderStatusReport::new(
269        account_id,
270        instrument_id,
271        client_order_id,
272        venue_order_id,
273        order_side,
274        order_type,
275        time_in_force,
276        nautilus_status,
277        quantity,
278        filled_qty,
279        ts_init, // ts_accepted
280        ts_init, // ts_last
281        ts_init,
282        Some(nautilus_core::UUID4::new()), // report_id
283    );
284
285    // Set optional fields
286    if let Some(order) = order {
287        if let Some(limit_price) = order.limit_price {
288            let converted = limit_price * price_magnifier;
289            report = report.with_price(Price::new(converted, price_precision));
290        }
291
292        let (trigger_price, limit_offset, trailing_offset, trailing_offset_type) =
293            parse_ib_order_pricing_fields(order, order_type, price_magnifier, price_precision)?;
294
295        if let Some(trigger_price) = trigger_price {
296            report = report.with_trigger_price(trigger_price);
297        }
298
299        if let Some(limit_offset) = limit_offset {
300            report = report.with_limit_offset(limit_offset);
301        }
302
303        if let Some(trailing_offset) = trailing_offset {
304            report = report.with_trailing_offset(trailing_offset);
305        }
306
307        if let Some(trailing_offset_type) = trailing_offset_type {
308            report = report.with_trailing_offset_type(trailing_offset_type);
309        }
310    }
311
312    if include_avg_px {
313        report = report.with_avg_px(avg_px_value)?;
314    }
315
316    Ok(report)
317}
318
319fn map_ib_order_type(order_type: &str) -> OrderType {
320    match order_type {
321        "MKT" | "MOC" => OrderType::Market,
322        "LMT" | "LOC" => OrderType::Limit,
323        "STP" => OrderType::StopMarket,
324        "STP LMT" => OrderType::StopLimit,
325        "TRAIL" => OrderType::TrailingStopMarket,
326        "TRAIL LIMIT" => OrderType::TrailingStopLimit,
327        "MIT" => OrderType::MarketIfTouched,
328        "LIT" => OrderType::LimitIfTouched,
329        "MTL" => OrderType::MarketToLimit,
330        _ => OrderType::Market,
331    }
332}
333
334fn parse_ib_order_pricing_fields(
335    order: &ibapi::orders::Order,
336    order_type: OrderType,
337    price_magnifier: f64,
338    price_precision: u8,
339) -> anyhow::Result<(
340    Option<Price>,
341    Option<Decimal>,
342    Option<Decimal>,
343    Option<TrailingOffsetType>,
344)> {
345    let mut trigger_price = None;
346    let mut limit_offset = None;
347    let mut trailing_offset = None;
348    let mut trailing_offset_type = None;
349
350    if matches!(
351        order_type,
352        OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
353    ) {
354        if let Some(trail_stop_price) = order.trail_stop_price {
355            trigger_price = Some(Price::new(
356                trail_stop_price * price_magnifier,
357                price_precision,
358            ));
359        }
360
361        if let Some(aux_price) = order.aux_price {
362            trailing_offset = Some(decimal_from_f64(aux_price)?);
363            trailing_offset_type = Some(TrailingOffsetType::Price);
364        } else if let Some(trailing_percent) = order.trailing_percent {
365            trailing_offset = Some(decimal_from_f64(trailing_percent)? * Decimal::from(100));
366            trailing_offset_type = Some(TrailingOffsetType::BasisPoints);
367        }
368
369        if order_type == OrderType::TrailingStopLimit
370            && let Some(limit_price_offset) = order.limit_price_offset
371        {
372            limit_offset = Some(decimal_from_f64(limit_price_offset)?);
373            trailing_offset_type = Some(trailing_offset_type.unwrap_or(TrailingOffsetType::Price));
374        }
375
376        return Ok((
377            trigger_price,
378            limit_offset,
379            trailing_offset,
380            trailing_offset_type,
381        ));
382    }
383
384    if let Some(aux_price) = order.aux_price {
385        trigger_price = Some(Price::new(aux_price * price_magnifier, price_precision));
386    }
387
388    Ok((
389        trigger_price,
390        limit_offset,
391        trailing_offset,
392        trailing_offset_type,
393    ))
394}
395
396fn decimal_from_f64(value: f64) -> anyhow::Result<Decimal> {
397    Decimal::from_str(&value.to_string())
398        .with_context(|| format!("Failed to convert IB floating-point value {value} to Decimal"))
399}
400
401/// Parse execution time string to UnixNanos.
402///
403/// Parse IB execution time to UnixNanos.
404///
405/// Supported IB formats:
406/// - "20230223 00:43:36 Universal"
407/// - "20230223 00:43:36 UTC"
408/// - "20230223 00:43:36" (assumed UTC)
409/// - "20250225-15:15:00" (assumed UTC)
410///
411/// # Arguments
412///
413/// * `time_str` - The execution time string from IB
414///
415/// # Errors
416///
417/// Returns an error if the execution timestamp is malformed or uses a non-UTC timezone.
418pub fn parse_execution_time(time_str: &str) -> anyhow::Result<UnixNanos> {
419    fn parse_utc(
420        time_str: &str,
421        format: &[time::format_description::FormatItem<'_>],
422    ) -> anyhow::Result<UnixNanos> {
423        let dt = PrimitiveDateTime::parse(time_str, format).map_err(|e| {
424            anyhow::anyhow!("Failed to parse execution timestamp '{time_str}': {e}")
425        })?;
426        let nanos: u64 = dt
427            .assume_utc()
428            .unix_timestamp_nanos()
429            .try_into()
430            .map_err(|_| {
431                anyhow::anyhow!("Execution timestamp '{time_str}' was before Unix epoch")
432            })?;
433        Ok(UnixNanos::new(nanos))
434    }
435
436    if time_str.contains('-') && !time_str.contains(' ') {
437        let format = format_description!("[year][month][day]-[hour]:[minute]:[second]");
438        return parse_utc(time_str, format);
439    }
440
441    let parts: Vec<&str> = time_str.split(' ').collect();
442
443    if parts.len() < 2 {
444        anyhow::bail!("Invalid execution time format: {time_str}");
445    }
446
447    let format = format_description!("[year][month][day] [hour]:[minute]:[second]");
448    let date_str = format!("{} {}", parts[0], parts[1]);
449
450    if parts.len() == 2 {
451        return parse_utc(&date_str, format);
452    }
453
454    let timezone = parts[2];
455    if !matches!(timezone, "Universal" | "UTC" | "Etc/UTC" | "GMT" | "Z") {
456        anyhow::bail!(
457            "Unsupported non-UTC execution timezone '{timezone}' in '{time_str}'. Configure TWS / IB Gateway to emit UTC timestamps"
458        );
459    }
460
461    parse_utc(&date_str, format)
462}
463
464#[cfg(test)]
465mod tests {
466    use ibapi::{
467        contracts::Contract,
468        orders::{Action, Liquidity, Order},
469    };
470    use nautilus_model::{
471        enums::TrailingOffsetType,
472        identifiers::{Symbol, Venue},
473    };
474    use rust_decimal::Decimal;
475
476    use super::*;
477    use crate::{
478        config::InteractiveBrokersInstrumentProviderConfig,
479        providers::instruments::InteractiveBrokersInstrumentProvider,
480    };
481
482    fn create_test_instrument_provider() -> InteractiveBrokersInstrumentProvider {
483        let config = InteractiveBrokersInstrumentProviderConfig::default();
484        InteractiveBrokersInstrumentProvider::new(config)
485    }
486
487    fn create_test_instrument_id() -> InstrumentId {
488        InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"))
489    }
490
491    use rstest::rstest;
492
493    #[rstest]
494    fn test_parse_execution_time_hyphenated_format() {
495        let time_str = "20250225-15:15:00";
496        let result = parse_execution_time(time_str);
497        assert!(result.is_ok());
498        let timestamp = result.unwrap();
499        assert!(timestamp.as_i64() > 0);
500    }
501
502    #[rstest]
503    fn test_parse_execution_time_with_unsupported_non_utc_timezone() {
504        let time_str = "20230223 00:43:36 America/New_York";
505        let result = parse_execution_time(time_str);
506        assert!(result.is_err());
507    }
508
509    #[rstest]
510    fn test_parse_execution_time_utc() {
511        let time_str = "20230223 00:43:36 Universal";
512        let result = parse_execution_time(time_str);
513        assert!(result.is_ok());
514        let timestamp = result.unwrap();
515        assert!(timestamp.as_i64() > 0);
516    }
517
518    #[rstest]
519    fn test_parse_execution_time_no_timezone_assumes_utc() {
520        let time_str = "20230223 00:43:36";
521        let result = parse_execution_time(time_str);
522        assert!(result.is_ok());
523        let timestamp = result.unwrap();
524        assert!(timestamp.as_i64() > 0);
525    }
526
527    #[rstest]
528    fn test_parse_execution_time_invalid_format() {
529        let time_str = "invalid format";
530        let result = parse_execution_time(time_str);
531        assert!(result.is_err());
532    }
533
534    #[rstest]
535    fn test_parse_execution_time_short_format() {
536        let time_str = "20230223 00:43";
537        let result = parse_execution_time(time_str);
538        assert!(result.is_err());
539    }
540
541    #[rstest]
542    fn test_parse_order_status_to_report_submitted() {
543        let instrument_provider = create_test_instrument_provider();
544        let instrument_id = create_test_instrument_id();
545        let account_id = AccountId::from("IB-001");
546
547        let order_status = OrderStatus {
548            order_id: 12345,
549            status: String::from("Submitted"),
550            filled: 0.0,
551            remaining: 100.0,
552            average_fill_price: 0.0,
553            perm_id: 0,
554            parent_id: 0,
555            last_fill_price: 0.0,
556            client_id: 0,
557            why_held: String::new(),
558            market_cap_price: 0.0,
559        };
560
561        let result = parse_order_status_to_report(
562            &order_status,
563            None,
564            instrument_id,
565            account_id,
566            &instrument_provider,
567            UnixNanos::new(0),
568        );
569
570        // May fail if instrument not in provider, but that's expected
571        if let Err(e) = result {
572            let error_msg = e.to_string();
573            assert!(
574                error_msg.contains("not found") || error_msg.contains("instrument"),
575                "Unexpected error: {}",
576                error_msg
577            );
578        }
579    }
580
581    #[rstest]
582    fn test_parse_order_status_to_report_filled() {
583        let instrument_provider = create_test_instrument_provider();
584        let instrument_id = create_test_instrument_id();
585        let account_id = AccountId::from("IB-001");
586
587        let order_status = OrderStatus {
588            order_id: 12345,
589            status: String::from("Filled"),
590            filled: 100.0,
591            remaining: 0.0,
592            average_fill_price: 150.25,
593            perm_id: 0,
594            parent_id: 0,
595            last_fill_price: 150.25,
596            client_id: 0,
597            why_held: String::new(),
598            market_cap_price: 0.0,
599        };
600
601        let result = parse_order_status_to_report(
602            &order_status,
603            None,
604            instrument_id,
605            account_id,
606            &instrument_provider,
607            UnixNanos::new(0),
608        );
609
610        // May fail if instrument not in provider, but that's expected
611        if let Err(e) = result {
612            let error_msg = e.to_string();
613            assert!(
614                error_msg.contains("not found") || error_msg.contains("instrument"),
615                "Unexpected error: {}",
616                error_msg
617            );
618        }
619    }
620
621    #[rstest]
622    fn test_parse_order_status_to_report_spread_allows_negative_avg_fill_price() {
623        let instrument_provider = create_test_instrument_provider();
624        let instrument_id = InstrumentId::new(
625            Symbol::from("(1)SPY C400_((1))SPY C410"),
626            Venue::from("SMART"),
627        );
628        let account_id = AccountId::from("IB-001");
629
630        let order_status = OrderStatus {
631            order_id: 12345,
632            status: String::from("Filled"),
633            filled: 1.0,
634            remaining: 0.0,
635            average_fill_price: -2.25,
636            perm_id: 0,
637            parent_id: 0,
638            last_fill_price: -2.25,
639            client_id: 0,
640            why_held: String::new(),
641            market_cap_price: 0.0,
642        };
643
644        let report = parse_order_status_to_report(
645            &order_status,
646            None,
647            instrument_id,
648            account_id,
649            &instrument_provider,
650            UnixNanos::new(0),
651        )
652        .unwrap();
653
654        assert_eq!(report.avg_px, Some(Decimal::from_str("-2.25").unwrap()));
655    }
656
657    #[rstest]
658    fn test_parse_order_status_to_report_inactive_maps_to_rejected() {
659        let instrument_provider = create_test_instrument_provider();
660        let instrument_id = create_test_instrument_id();
661        let account_id = AccountId::from("IB-001");
662
663        let order_status = OrderStatus {
664            order_id: 12345,
665            status: String::from("Inactive"),
666            filled: 0.0,
667            remaining: 100.0,
668            average_fill_price: 0.0,
669            perm_id: 0,
670            parent_id: 0,
671            last_fill_price: 0.0,
672            client_id: 0,
673            why_held: String::new(),
674            market_cap_price: 0.0,
675        };
676
677        let report = parse_order_status_to_report(
678            &order_status,
679            None,
680            instrument_id,
681            account_id,
682            &instrument_provider,
683            UnixNanos::new(0),
684        )
685        .unwrap();
686
687        assert_eq!(report.order_status, NautilusOrderStatus::Rejected);
688    }
689
690    #[rstest]
691    #[case(
692        "MKT",
693        None,
694        None,
695        None,
696        None,
697        OrderType::Market,
698        None,
699        None,
700        None,
701        None,
702        TrailingOffsetType::NoTrailingOffset
703    )]
704    #[case(
705        "LMT",
706        Some(185.0),
707        None,
708        None,
709        None,
710        OrderType::Limit,
711        Some(Price::new(185.0, 0)),
712        None,
713        None,
714        None,
715        TrailingOffsetType::NoTrailingOffset
716    )]
717    #[case(
718        "MIT",
719        None,
720        Some(180.0),
721        None,
722        None,
723        OrderType::MarketIfTouched,
724        None,
725        Some(Price::new(180.0, 0)),
726        None,
727        None,
728        TrailingOffsetType::NoTrailingOffset
729    )]
730    #[case(
731        "LIT",
732        Some(179.0),
733        Some(180.0),
734        None,
735        None,
736        OrderType::LimitIfTouched,
737        Some(Price::new(179.0, 0)),
738        Some(Price::new(180.0, 0)),
739        None,
740        None,
741        TrailingOffsetType::NoTrailingOffset
742    )]
743    #[case(
744        "STP",
745        None,
746        Some(180.0),
747        None,
748        None,
749        OrderType::StopMarket,
750        None,
751        Some(Price::new(180.0, 0)),
752        None,
753        None,
754        TrailingOffsetType::NoTrailingOffset
755    )]
756    #[case(
757        "STP LMT",
758        Some(179.0),
759        Some(180.0),
760        None,
761        None,
762        OrderType::StopLimit,
763        Some(Price::new(179.0, 0)),
764        Some(Price::new(180.0, 0)),
765        None,
766        None,
767        TrailingOffsetType::NoTrailingOffset
768    )]
769    #[case(
770        "TRAIL LIMIT",
771        None,
772        Some(2.5),
773        Some(185.0),
774        Some(0.25),
775        OrderType::TrailingStopLimit,
776        None,
777        Some(Price::new(185.0, 0)),
778        Some(Decimal::from_str("0.25").unwrap()),
779        Some(Decimal::from_str("2.5").unwrap()),
780        TrailingOffsetType::Price,
781    )]
782    fn test_parse_order_status_to_report_maps_pricing_fields_by_order_type(
783        #[case] ib_order_type: &str,
784        #[case] limit_price: Option<f64>,
785        #[case] aux_price: Option<f64>,
786        #[case] trail_stop_price: Option<f64>,
787        #[case] limit_price_offset: Option<f64>,
788        #[case] expected_order_type: OrderType,
789        #[case] expected_price: Option<Price>,
790        #[case] expected_trigger_price: Option<Price>,
791        #[case] expected_limit_offset: Option<Decimal>,
792        #[case] expected_trailing_offset: Option<Decimal>,
793        #[case] expected_trailing_offset_type: TrailingOffsetType,
794    ) {
795        let instrument_provider = create_test_instrument_provider();
796        let instrument_id = create_test_instrument_id();
797        let account_id = AccountId::from("IB-001");
798
799        let order_status = OrderStatus {
800            order_id: 12345,
801            status: String::from("Submitted"),
802            filled: 0.0,
803            remaining: 5.0,
804            average_fill_price: 0.0,
805            perm_id: 0,
806            parent_id: 0,
807            last_fill_price: 0.0,
808            client_id: 0,
809            why_held: String::new(),
810            market_cap_price: 0.0,
811        };
812
813        let order = Order {
814            action: Action::Buy,
815            total_quantity: 5.0,
816            order_type: ib_order_type.to_string(),
817            limit_price,
818            aux_price,
819            trail_stop_price,
820            limit_price_offset,
821            tif: ibapi::orders::TimeInForce::GoodTilCanceled,
822            ..Default::default()
823        };
824
825        let report = parse_order_status_to_report(
826            &order_status,
827            Some(&order),
828            instrument_id,
829            account_id,
830            &instrument_provider,
831            UnixNanos::new(0),
832        )
833        .unwrap();
834
835        assert_eq!(report.order_type, expected_order_type);
836        assert_eq!(report.price, expected_price);
837        assert_eq!(report.trigger_price, expected_trigger_price);
838        assert_eq!(report.limit_offset, expected_limit_offset);
839        assert_eq!(report.trailing_offset, expected_trailing_offset);
840        assert_eq!(report.trailing_offset_type, expected_trailing_offset_type);
841    }
842
843    #[rstest]
844    fn test_parse_order_status_to_report_maps_trailing_percent_to_basis_points() {
845        let instrument_provider = create_test_instrument_provider();
846        let instrument_id = create_test_instrument_id();
847        let account_id = AccountId::from("IB-001");
848
849        let order_status = OrderStatus {
850            order_id: 12345,
851            status: String::from("Submitted"),
852            filled: 0.0,
853            remaining: 5.0,
854            average_fill_price: 0.0,
855            perm_id: 0,
856            parent_id: 0,
857            last_fill_price: 0.0,
858            client_id: 0,
859            why_held: String::new(),
860            market_cap_price: 0.0,
861        };
862
863        let order = Order {
864            action: Action::Buy,
865            total_quantity: 5.0,
866            order_type: "TRAIL".to_string(),
867            trail_stop_price: Some(185.0),
868            trailing_percent: Some(2.5),
869            tif: ibapi::orders::TimeInForce::GoodTilCanceled,
870            ..Default::default()
871        };
872
873        let report = parse_order_status_to_report(
874            &order_status,
875            Some(&order),
876            instrument_id,
877            account_id,
878            &instrument_provider,
879            UnixNanos::new(0),
880        )
881        .unwrap();
882
883        assert_eq!(report.order_type, OrderType::TrailingStopMarket);
884        assert_eq!(report.trigger_price, Some(Price::new(185.0, 0)));
885        assert_eq!(
886            report.trailing_offset,
887            Some(Decimal::from_str("250").unwrap())
888        );
889        assert_eq!(report.trailing_offset_type, TrailingOffsetType::BasisPoints);
890        assert_eq!(report.limit_offset, None);
891    }
892
893    #[rstest]
894    fn test_parse_execution_to_fill_report_buy() {
895        let instrument_provider = create_test_instrument_provider();
896        let instrument_id = create_test_instrument_id();
897        let account_id = AccountId::from("IB-001");
898
899        let execution = Execution {
900            order_id: 12345,
901            client_id: 0,
902            execution_id: String::from("EXEC-001"),
903            time: String::from("20230223 00:43:36 Universal"),
904            account_number: String::new(),
905            exchange: String::new(),
906            side: String::from("BOT"),
907            shares: 100.0,
908            price: 150.25,
909            perm_id: 0,
910            liquidation: 0,
911            cumulative_quantity: 100.0,
912            average_price: 150.25,
913            order_reference: String::from("ORDER-REF-001"),
914            ev_rule: String::new(),
915            ev_multiplier: None,
916            model_code: String::new(),
917            last_liquidity: Liquidity::None,
918            pending_price_revision: false,
919            submitter: String::new(),
920        };
921
922        let contract = Contract::default();
923        let result = parse_execution_to_fill_report(
924            &execution,
925            &contract,
926            1.0,
927            "USD",
928            instrument_id,
929            account_id,
930            &instrument_provider,
931            UnixNanos::new(0),
932            None, // avg_px
933        );
934
935        // May fail if instrument not in provider, but that's expected
936        match result {
937            Err(e) => {
938                let error_msg = e.to_string();
939                assert!(
940                    error_msg.contains("not found") || error_msg.contains("instrument"),
941                    "Unexpected error: {}",
942                    error_msg
943                );
944            }
945            Ok(fill) => {
946                assert_eq!(fill.order_side, OrderSide::Buy);
947                assert_eq!(fill.trade_id.to_string(), "EXEC-001");
948            }
949        }
950    }
951
952    #[rstest]
953    fn test_parse_execution_to_fill_report_sell() {
954        let instrument_provider = create_test_instrument_provider();
955        let instrument_id = create_test_instrument_id();
956        let account_id = AccountId::from("IB-001");
957
958        let execution = Execution {
959            order_id: 12345,
960            client_id: 0,
961            execution_id: String::from("EXEC-002"),
962            time: String::from("20230223 00:43:36 Universal"),
963            account_number: String::new(),
964            exchange: String::new(),
965            side: String::from("SLD"),
966            shares: 50.0,
967            price: 151.0,
968            perm_id: 0,
969            liquidation: 0,
970            cumulative_quantity: 50.0,
971            average_price: 151.0,
972            order_reference: String::new(),
973            ev_rule: String::new(),
974            ev_multiplier: None,
975            model_code: String::new(),
976            last_liquidity: Liquidity::None,
977            pending_price_revision: false,
978            submitter: String::new(),
979        };
980
981        let contract = Contract::default();
982        let result = parse_execution_to_fill_report(
983            &execution,
984            &contract,
985            0.5,
986            "USD",
987            instrument_id,
988            account_id,
989            &instrument_provider,
990            UnixNanos::new(0),
991            None, // avg_px
992        );
993
994        // May fail if instrument not in provider, but that's expected
995        match result {
996            Err(e) => {
997                let error_msg = e.to_string();
998                assert!(
999                    error_msg.contains("not found") || error_msg.contains("instrument"),
1000                    "Unexpected error: {}",
1001                    error_msg
1002                );
1003            }
1004            Ok(fill) => {
1005                assert_eq!(fill.order_side, OrderSide::Sell);
1006            }
1007        }
1008    }
1009}