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