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 ahash::{AHashMap, AHashSet};
21use anyhow::Context;
22use ibapi::orders::{Execution, Liquidity, OrderData, OrderStatus};
23use jiff::{
24    Timestamp,
25    civil::DateTime,
26    tz::{AmbiguousOffset, Offset},
27};
28use nautilus_core::{UnixNanos, datetime::get_timezone};
29use nautilus_model::{
30    enums::{
31        ContingencyType, LiquiditySide, OrderSide, OrderStatus as NautilusOrderStatus, OrderType,
32        TimeInForce, TrailingOffsetType,
33    },
34    identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
35    instruments::Instrument,
36    reports::{FillReport, OrderStatusReport, PositionStatusReport},
37    types::{Currency, Money, Price, Quantity},
38};
39use rust_decimal::Decimal;
40
41use crate::{
42    common::{
43        enums::{IbAction, IbOrderStatus, IbOrderType, IbTimeInForce},
44        spreads::is_spread_instrument_id,
45    },
46    execution::transform::ib_trigger_method_to_trigger_type,
47    providers::instruments::InteractiveBrokersInstrumentProvider,
48};
49
50pub(super) fn parse_order_data_to_report(
51    data: &OrderData,
52    instrument_id: InstrumentId,
53    account_id: AccountId,
54    instrument_provider: &InteractiveBrokersInstrumentProvider,
55    ts_init: UnixNanos,
56) -> anyhow::Result<OrderStatusReport> {
57    anyhow::ensure!(
58        data.order.total_quantity.is_finite()
59            && data.order.total_quantity >= 0.0
60            && data.order.filled_quantity.is_finite()
61            && data.order.filled_quantity >= 0.0,
62        "IB order {} has invalid quantities",
63        data.order_id
64    );
65
66    let mut report = parse_order_status_to_report(
67        &OrderStatus {
68            order_id: data.order_id,
69            status: data.order_state.status.clone(),
70            filled: data.order.filled_quantity,
71            remaining: (data.order.total_quantity - data.order.filled_quantity).max(0.0),
72            average_fill_price: None,
73            perm_id: data.order.perm_id,
74            parent_id: 0,
75            last_fill_price: None,
76            client_id: data.order.client_id,
77            why_held: String::new(),
78            market_cap_price: None,
79        },
80        Some(&data.order),
81        instrument_id,
82        account_id,
83        instrument_provider,
84        ts_init,
85    )?;
86
87    // Open orders carry no venue time; a completed order reports when it finished
88    if !data.order_state.completed_time.is_empty() {
89        match parse_execution_time(&data.order_state.completed_time) {
90            Ok(completed) => report.ts_last = completed,
91            Err(e) => tracing::debug!(
92                "Cannot parse completed time of IB order {}: {e}",
93                data.order_id
94            ),
95        }
96    }
97
98    Ok(report)
99}
100
101/// Links the contingencies IB reports across one batch of order status reports.
102///
103/// IB keeps bracket parents (`parent_id`) and OCA groups on its side; a status report parsed on
104/// its own cannot name the related orders. Each report is matched to its IB order by index.
105/// Reports whose OCA type IB does not model keep no contingency, and a contingency is only set
106/// when at least one related order is present in the batch.
107/// Sets the average price of filled order reports that lack one from their fill reports.
108///
109/// IB completed orders carry no average fill price, which reconciliation needs to rebuild a
110/// filled order before applying its fills.
111pub(crate) fn fill_missing_avg_px(
112    order_reports: &mut [OrderStatusReport],
113    fill_reports: &[FillReport],
114) {
115    for report in order_reports
116        .iter_mut()
117        .filter(|report| report.avg_px.is_none() && !report.filled_qty.is_zero())
118    {
119        let (notional, quantity) = fill_reports
120            .iter()
121            .filter(|fill| fill.venue_order_id == report.venue_order_id)
122            .fold(
123                (Decimal::ZERO, Decimal::ZERO),
124                |(notional, quantity), fill| {
125                    (
126                        notional + fill.last_px.as_decimal() * fill.last_qty.as_decimal(),
127                        quantity + fill.last_qty.as_decimal(),
128                    )
129                },
130            );
131
132        if !quantity.is_zero() {
133            report.avg_px = Some(notional / quantity);
134        }
135    }
136}
137
138/// Fill reports for an execution window, with the evidence an entry price rebuild needs.
139#[derive(Debug, Default)]
140pub(crate) struct FillEvidence {
141    pub(crate) reports: Vec<FillReport>,
142    /// Leg executions of combo orders, which `reports` drops in favor of the spread fill. They
143    /// still change the leg positions.
144    pub(crate) combo_leg_reports: Vec<FillReport>,
145    /// Whether an execution of an unknown instrument could not be turned into a fill report.
146    pub(crate) missing_all: bool,
147    /// Instruments with an execution that could not be turned into a fill report.
148    pub(crate) missing_instruments: AHashSet<InstrumentId>,
149}
150
151impl FillEvidence {
152    fn is_complete_for(&self, instrument_id: &InstrumentId) -> bool {
153        !self.missing_all && !self.missing_instruments.contains(instrument_id)
154    }
155}
156
157/// Replaces IB's position average cost with the entry price of the fills that built the position.
158///
159/// IB's `avgCost` includes commissions, while a position rebuilt from fills has the average of
160/// their prices. The fills in the mass status cover only the current window, so the replay
161/// starts from the quantity the position must have held before the window: the reported
162/// quantity minus the window's net quantity. Once that replay passes through flat, or flips,
163/// every later fill is known, and the entry price is the netting average of the fills after the
164/// last flat crossing. Fill reports arrive in commission order, so fills sharing a timestamp
165/// have no known execution order: when they include opposite sides or flip the position, the
166/// entry price is unknown until a later fill opens from flat or flips.
167///
168/// The replay infers the pre-window quantity, so a missing execution would be read as part of
169/// it. The entry price therefore replaces IB's average cost only when every execution of the
170/// instrument was parsed and the entry price plus the opening commissions per unit reproduces
171/// IB's average cost. Otherwise the position keeps IB's average cost, which position recovery
172/// prices from.
173pub(crate) fn fill_position_avg_px_from_fills(
174    position_reports: &mut [PositionStatusReport],
175    evidence: &FillEvidence,
176    instrument_provider: &InteractiveBrokersInstrumentProvider,
177) {
178    for report in position_reports
179        .iter_mut()
180        .filter(|report| !report.signed_decimal_qty.is_zero())
181    {
182        let instrument_id = report.instrument_id;
183        let Some(venue_avg_px) = report.avg_px_open else {
184            continue;
185        };
186        let Some(instrument) = instrument_provider.find(&instrument_id) else {
187            continue;
188        };
189
190        if !evidence.is_complete_for(&instrument_id) {
191            continue;
192        }
193
194        let mut fills: Vec<_> = evidence
195            .reports
196            .iter()
197            .chain(&evidence.combo_leg_reports)
198            .filter(|fill| {
199                fill.account_id == report.account_id && fill.instrument_id == instrument_id
200            })
201            .collect();
202
203        // IB's average cost is in the instrument's quote currency
204        if fills
205            .iter()
206            .any(|fill| fill.commission.currency != instrument.quote_currency())
207        {
208            continue;
209        }
210
211        fills.sort_by_key(|fill| fill.ts_event);
212
213        let window_qty: Decimal = fills.iter().map(|fill| signed_fill_qty(fill)).sum();
214        let pre_window_qty = report.signed_decimal_qty - window_qty;
215
216        let Some(lot) = entry_after_window(pre_window_qty, &fills) else {
217            continue;
218        };
219
220        let units = report.signed_decimal_qty.abs()
221            * instrument.multiplier().as_decimal()
222            * Decimal::from(instrument_provider.get_price_magnifier(&instrument_id));
223        let commission_px = lot.commission / units;
224        let venue_avg_px_expected = if report.signed_decimal_qty.is_sign_positive() {
225            lot.avg_px + commission_px
226        } else {
227            lot.avg_px - commission_px
228        };
229
230        // IB's average cost is rounded to the price precision
231        let tolerance = Decimal::new(1, u32::from(instrument.price_precision()));
232
233        if (venue_avg_px - venue_avg_px_expected).abs() <= tolerance {
234            report.avg_px_open = Some(lot.avg_px);
235        } else {
236            tracing::debug!(
237                "Keeping IB average cost {venue_avg_px} for {instrument_id}: fills give entry \
238                 {} with average cost {venue_avg_px_expected}",
239                lot.avg_px,
240            );
241        }
242    }
243}
244
245fn signed_fill_qty(fill: &FillReport) -> Decimal {
246    let last_qty = fill.last_qty.as_decimal();
247
248    match fill.order_side {
249        OrderSide::Buy => last_qty,
250        OrderSide::Sell => -last_qty,
251    }
252}
253
254// The open lot left by the replay: its netting average price, and the commissions IB includes
255// in its average cost, scaled down as the lot is reduced
256struct EntryLot {
257    avg_px: Decimal,
258    commission: Decimal,
259}
260
261// Replays the window fills with netting semantics from `pre_window_qty` and returns the lot
262// left at the end, when that lot was opened within the window. Fills on the open side average
263// in, reducing fills keep the entry price, and a flip opens at the flipping fill's price; a lot
264// that predates the window is unknown until the replay passes through flat. IB leaves the
265// commission of a flipping fill out of the new lot's average cost, so that lot starts with none. Fills sharing a timestamp have no known order, so a
266// group of them that mixes sides or flips the position leaves the lot unknown until a later
267// fill opens from flat or flips.
268fn entry_after_window(pre_window_qty: Decimal, fills: &[&FillReport]) -> Option<EntryLot> {
269    let mut quantity = pre_window_qty;
270    let mut lot: Option<EntryLot> = None;
271
272    for group in fills.chunk_by(|a, b| a.ts_event == b.ts_event) {
273        if group.len() > 1 {
274            let group_qty: Decimal = group.iter().map(|fill| signed_fill_qty(fill)).sum();
275            let end_qty = quantity + group_qty;
276            let mixes_sides = group
277                .iter()
278                .any(|fill| fill.order_side != group[0].order_side);
279            let flips = !quantity.is_zero()
280                && !end_qty.is_zero()
281                && quantity.is_sign_negative() != end_qty.is_sign_negative();
282
283            if mixes_sides || flips {
284                quantity = end_qty;
285                lot = None;
286                continue;
287            }
288        }
289
290        for fill in group {
291            let last_qty = fill.last_qty.as_decimal();
292            let last_px = fill.last_px.as_decimal();
293            let commission = fill.commission.as_decimal();
294            let signed_qty = signed_fill_qty(fill);
295            let open_qty = quantity.abs();
296
297            if quantity.is_zero() {
298                lot = Some(EntryLot {
299                    avg_px: last_px,
300                    commission,
301                });
302            } else if quantity.is_sign_negative() == signed_qty.is_sign_negative() {
303                lot = lot.map(|lot| EntryLot {
304                    avg_px: (lot.avg_px * open_qty + last_px * last_qty) / (open_qty + last_qty),
305                    commission: lot.commission + commission,
306                });
307            } else if last_qty > open_qty {
308                lot = Some(EntryLot {
309                    avg_px: last_px,
310                    commission: Decimal::ZERO,
311                });
312            } else if last_qty < open_qty {
313                lot = lot.map(|lot| EntryLot {
314                    avg_px: lot.avg_px,
315                    commission: lot.commission * (open_qty - last_qty) / open_qty,
316                });
317            }
318
319            quantity += signed_qty;
320        }
321    }
322
323    if quantity.is_zero() {
324        return None;
325    }
326
327    lot
328}
329
330/// Returns whether an execution is the combo-level (BAG) execution of a spread order.
331///
332/// IB sends commission reports only for the leg executions of a combo; the combo-level
333/// execution carries none, so its fill has zero commission.
334pub(crate) fn is_combo_execution(exec_data: &ibapi::orders::ExecutionData) -> bool {
335    matches!(
336        exec_data.contract.security_type,
337        ibapi::contracts::SecurityType::Spread
338    )
339}
340
341pub(crate) fn link_order_contingencies(
342    reports: &mut [OrderStatusReport],
343    orders: &[ibapi::orders::Order],
344) {
345    debug_assert_eq!(reports.len(), orders.len());
346
347    let order_ids: Vec<ClientOrderId> = reports
348        .iter()
349        .map(|report| {
350            report
351                .client_order_id
352                .unwrap_or_else(|| ClientOrderId::from(report.venue_order_id.as_str()))
353        })
354        .collect();
355
356    let mut oca_groups: AHashMap<(&str, &str), Vec<usize>> = AHashMap::new();
357
358    for (index, order) in orders.iter().enumerate() {
359        if !order.oca_group.is_empty() {
360            oca_groups
361                .entry((order.account.as_str(), order.oca_group.as_str()))
362                .or_default()
363                .push(index);
364        }
365    }
366
367    let mut children: AHashMap<usize, Vec<usize>> = AHashMap::new();
368
369    for (index, order) in orders.iter().enumerate() {
370        let parent = orders.iter().position(|candidate| {
371            order
372                .parent_perm_id
373                .is_some_and(|perm_id| perm_id != 0 && candidate.perm_id == perm_id)
374                || (order.parent_id != 0
375                    && candidate.order_id == order.parent_id
376                    && candidate.client_id == order.client_id)
377        });
378
379        if let Some(parent) = parent.filter(|parent| *parent != index) {
380            reports[index].parent_order_id = Some(order_ids[parent]);
381            children.entry(parent).or_default().push(index);
382        }
383    }
384
385    for (index, order) in orders.iter().enumerate() {
386        if let Some(members) = oca_groups.get(&(order.account.as_str(), order.oca_group.as_str())) {
387            let contingency_type = match order.oca_type {
388                ibapi::orders::OcaType::CancelWithBlock => Some(ContingencyType::Oco),
389                ibapi::orders::OcaType::ReduceWithBlock
390                | ibapi::orders::OcaType::ReduceWithoutBlock => Some(ContingencyType::Ouo),
391                _ => None,
392            };
393
394            let linked: Vec<ClientOrderId> = members
395                .iter()
396                .filter(|member| **member != index)
397                .map(|member| order_ids[*member])
398                .collect();
399
400            if let Some(contingency_type) = contingency_type
401                && !linked.is_empty()
402            {
403                reports[index].contingency_type = Some(contingency_type);
404                reports[index].linked_order_ids = Some(linked);
405                continue;
406            }
407        }
408
409        if let Some(child_indices) = children.get(&index) {
410            reports[index].contingency_type = Some(ContingencyType::Oto);
411            reports[index].linked_order_ids = Some(
412                child_indices
413                    .iter()
414                    .map(|child| order_ids[*child])
415                    .collect(),
416            );
417        }
418    }
419}
420
421pub(crate) fn should_use_avg_fill_price(avg_fill_price: f64, instrument_id: &InstrumentId) -> bool {
422    avg_fill_price.is_finite()
423        && avg_fill_price != f64::MAX
424        && avg_fill_price != 0.0
425        && (avg_fill_price > 0.0 || is_spread_instrument_id(instrument_id))
426}
427
428pub(crate) fn ib_venue_order_id(order_id: i32, perm_id: i64) -> VenueOrderId {
429    if perm_id != 0 {
430        VenueOrderId::new(format!("PERM-{perm_id}"))
431    } else {
432        VenueOrderId::new(order_id.to_string())
433    }
434}
435
436pub(crate) fn normalized_order_ref(order_ref: &str) -> Option<&str> {
437    if order_ref.is_empty() {
438        return None;
439    }
440
441    Some(
442        order_ref
443            .rsplit_once(':')
444            .map_or(order_ref, |(base, _)| base),
445    )
446}
447
448/// Parse an IB execution to a Nautilus FillReport.
449///
450/// # Errors
451///
452/// Returns an error if parsing fails.
453///
454/// # Note
455///
456/// The `avg_px` parameter is stored from order status updates and is available for
457/// future use when FillReport supports additional metadata fields.
458#[allow(clippy::too_many_arguments)]
459pub fn parse_execution_to_fill_report(
460    execution: &Execution,
461    _contract: &ibapi::contracts::Contract,
462    commission: f64,
463    commission_currency: &str,
464    instrument_id: InstrumentId,
465    account_id: AccountId,
466    instrument_provider: &InteractiveBrokersInstrumentProvider,
467    ts_init: UnixNanos,
468    avg_px: Option<Price>,
469) -> anyhow::Result<FillReport> {
470    // Get price magnifier from instrument provider
471    let price_magnifier = instrument_provider.get_price_magnifier(&instrument_id) as f64;
472
473    // Convert execution price
474    let execution_price = execution.price * price_magnifier;
475
476    // Determine order side
477    let order_side = IbAction::from_str(execution.side.as_str())?.order_side();
478
479    // Get instrument for precision
480    let instrument = instrument_provider
481        .find(&instrument_id)
482        .context("Instrument not found")?;
483
484    // Create quantities and prices
485    let last_qty = Quantity::new(execution.shares, instrument.size_precision());
486    let last_px = Price::new(execution_price, instrument.price_precision());
487
488    // Clamp only IB's -1 pending sentinel to 0.0 to preserve rebates
489    let commission_clamped = if commission == -1.0 { 0.0 } else { commission };
490    let commission_money = Money::new(commission_clamped, Currency::from_str(commission_currency)?);
491
492    // Parse execution time
493    let ts_event = parse_execution_time(&execution.time)?;
494
495    // Create trade ID
496    let trade_id = TradeId::new(&execution.execution_id);
497
498    let venue_order_id = ib_venue_order_id(execution.order_id, execution.perm_id);
499
500    let client_order_id = normalized_order_ref(&execution.order_reference).map(ClientOrderId::new);
501    let liquidity_side = execution_liquidity_side(&execution.last_liquidity);
502
503    let mut report = FillReport::new(
504        account_id,
505        instrument_id,
506        venue_order_id,
507        trade_id,
508        order_side,
509        last_qty,
510        last_px,
511        commission_money,
512        liquidity_side,
513        client_order_id,
514        None, // venue_position_id
515        ts_event,
516        ts_init,
517        Some(nautilus_core::UUID4::new()),
518    );
519    report.avg_px = avg_px.map(|price: Price| price.as_decimal());
520
521    Ok(report)
522}
523
524fn execution_liquidity_side(liquidity: &Liquidity) -> LiquiditySide {
525    match liquidity {
526        Liquidity::AddedLiquidity => LiquiditySide::Maker,
527        Liquidity::RemovedLiquidity => LiquiditySide::Taker,
528        Liquidity::None => LiquiditySide::NoLiquiditySide,
529        Liquidity::LiquidityRoutedOut => {
530            tracing::warn!(
531                "IB execution liquidity was routed out and has no maker/taker representation"
532            );
533            LiquiditySide::NoLiquiditySide
534        }
535        Liquidity::Unknown(code) => {
536            tracing::warn!("IB execution used unknown liquidity code {code}");
537            LiquiditySide::NoLiquiditySide
538        }
539    }
540}
541
542/// Parse an IB order status to a Nautilus OrderStatusReport.
543///
544/// # Errors
545///
546/// Returns an error if parsing fails.
547pub fn parse_order_status_to_report(
548    order_status: &OrderStatus,
549    order: Option<&ibapi::orders::Order>,
550    instrument_id: InstrumentId,
551    account_id: AccountId,
552    instrument_provider: &InteractiveBrokersInstrumentProvider,
553    ts_init: UnixNanos,
554) -> anyhow::Result<OrderStatusReport> {
555    // Get price magnifier from instrument provider
556    let price_magnifier = instrument_provider.get_price_magnifier(&instrument_id) as f64;
557
558    let mut nautilus_status = match IbOrderStatus::from_str(order_status.status.as_str()) {
559        Ok(status) => status.nautilus_status(),
560        _ => {
561            tracing::warn!(
562                "Unknown order status: {}, defaulting to SUBMITTED",
563                order_status.status.as_str()
564            );
565            NautilusOrderStatus::Submitted
566        }
567    };
568
569    // Get order side
570    let order_side = if let Some(order) = order {
571        IbAction::from(order.action).order_side()
572    } else {
573        // Default to Buy if order not available
574        OrderSide::Buy
575    };
576
577    let instrument = instrument_provider.find(&instrument_id);
578
579    // Get instrument for precision (use 0 as default if not available)
580    let size_precision = instrument
581        .as_ref()
582        .map_or(0, |instr| instr.size_precision());
583    let price_precision = instrument
584        .as_ref()
585        .map_or(0, |instr| instr.price_precision());
586
587    // Get quantity
588    let total_quantity = order.map_or(0.0, |order| order.total_quantity);
589
590    let quantity = Quantity::new(
591        total_quantity.max(order_status.filled + order_status.remaining),
592        size_precision,
593    );
594
595    // Get filled quantity
596    let filled_qty = Quantity::new(order_status.filled, size_precision);
597
598    // Get average price
599    let average_fill_price = order_status.average_fill_price.unwrap_or(0.0);
600    let include_avg_px = should_use_avg_fill_price(average_fill_price, &instrument_id);
601    let avg_px_value = if include_avg_px {
602        average_fill_price * price_magnifier
603    } else {
604        0.0
605    };
606
607    if order_status.filled > 0.0
608        && (order_status.remaining > 0.0
609            || order.is_some_and(|order| order.total_quantity > order_status.filled))
610    {
611        nautilus_status = NautilusOrderStatus::PartiallyFilled;
612    }
613
614    let venue_order_id = ib_venue_order_id(order_status.order_id, order_status.perm_id);
615
616    let client_order_id = order
617        .and_then(|order| normalized_order_ref(&order.order_ref))
618        .map(ClientOrderId::new);
619
620    // Map order type from IB order if available
621    let order_type = order.map_or(OrderType::Market, |order| {
622        map_ib_order_type(&order.order_type, order.limit_price)
623    });
624
625    // A GTD order needs its expiry to be rebuilt from the report; IB still enforces the date,
626    // so an unparsable one degrades to GTC rather than failing the whole report set
627    let expire_time = order
628        .filter(|order| !order.good_till_date.is_empty())
629        .and_then(|order| match parse_execution_time(&order.good_till_date) {
630            Ok(expire_time) => Some(expire_time),
631            Err(e) => {
632                tracing::warn!(
633                    "Reporting IB order {} as GTC: cannot parse goodTillDate: {e}",
634                    order_status.order_id
635                );
636                None
637            }
638        });
639
640    // Map time in force from IB order if available
641    let time_in_force = if let Some(order) = order {
642        let ib_time_in_force = IbTimeInForce::from(order.tif.clone());
643
644        if expire_time.is_some() {
645            TimeInForce::Gtd
646        } else if ib_time_in_force == IbTimeInForce::GoodTilDate {
647            TimeInForce::Gtc
648        } else {
649            ib_time_in_force.nautilus_time_in_force()
650        }
651    } else {
652        TimeInForce::Day // Default when order not available
653    };
654
655    // Parse limit price if available
656    let mut report = OrderStatusReport::new(
657        account_id,
658        instrument_id,
659        client_order_id,
660        venue_order_id,
661        order_side.into(),
662        order_type,
663        time_in_force,
664        nautilus_status,
665        quantity,
666        filled_qty,
667        ts_init, // ts_accepted
668        ts_init, // ts_last
669        ts_init,
670        Some(nautilus_core::UUID4::new()), // report_id
671    );
672
673    if let Some(expire_time) = expire_time {
674        report = report.with_expire_time(expire_time);
675    }
676
677    // Set optional fields
678    if let Some(order) = order {
679        // IB reports a zero limit price on market-type orders, which reconciliation would
680        // otherwise take as the fill price of an order it has no fills for
681        if let Some(limit_price) = order.limit_price
682            && matches!(
683                order_type,
684                OrderType::Limit
685                    | OrderType::StopLimit
686                    | OrderType::LimitIfTouched
687                    | OrderType::TrailingStopLimit
688                    | OrderType::MarketToLimit
689            )
690        {
691            let converted = limit_price * price_magnifier;
692            report = report.with_price(Price::new(converted, price_precision));
693        }
694
695        let (trigger_price, limit_offset, trailing_offset, trailing_offset_type) =
696            parse_ib_order_pricing_fields(order, order_type, price_magnifier, price_precision)?;
697
698        if let Some(trigger_price) = trigger_price {
699            report = report.with_trigger_price(trigger_price);
700        }
701
702        if matches!(
703            order_type,
704            OrderType::LimitIfTouched
705                | OrderType::MarketIfTouched
706                | OrderType::StopLimit
707                | OrderType::StopMarket
708                | OrderType::TrailingStopLimit
709                | OrderType::TrailingStopMarket
710        ) {
711            report =
712                report.with_trigger_type(ib_trigger_method_to_trigger_type(order.trigger_method));
713        }
714
715        if let Some(limit_offset) = limit_offset {
716            report = report.with_limit_offset(limit_offset);
717        }
718
719        if let Some(trailing_offset) = trailing_offset {
720            report = report.with_trailing_offset(trailing_offset);
721        }
722
723        if let Some(trailing_offset_type) = trailing_offset_type {
724            report = report.with_trailing_offset_type(trailing_offset_type);
725        }
726    }
727
728    if include_avg_px {
729        report = report.with_avg_px(decimal_from_f64(avg_px_value)?);
730    }
731
732    if let Some(display_size) = order
733        .and_then(|order| order.display_size)
734        .filter(|size| *size > 0 && f64::from(*size) < quantity.as_f64())
735    {
736        report = report.with_display_qty(Quantity::new(f64::from(display_size), size_precision));
737    }
738
739    Ok(report)
740}
741
742fn map_ib_order_type(order_type: &str, limit_price: Option<f64>) -> OrderType {
743    if order_type == "IBALGO" && limit_price.is_some_and(|price| price != 0.0) {
744        OrderType::Limit
745    } else {
746        IbOrderType::from_str(order_type)
747            .map_or(OrderType::Market, IbOrderType::nautilus_order_type)
748    }
749}
750
751#[allow(clippy::type_complexity)] // The tuple mirrors the four optional IB pricing fields.
752fn parse_ib_order_pricing_fields(
753    order: &ibapi::orders::Order,
754    order_type: OrderType,
755    price_magnifier: f64,
756    price_precision: u8,
757) -> anyhow::Result<(
758    Option<Price>,
759    Option<Decimal>,
760    Option<Decimal>,
761    Option<TrailingOffsetType>,
762)> {
763    let mut trigger_price = None;
764    let mut limit_offset = None;
765    let mut trailing_offset = None;
766    let mut trailing_offset_type = None;
767
768    if matches!(
769        order_type,
770        OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
771    ) {
772        if let Some(trail_stop_price) = order.trail_stop_price {
773            trigger_price = Some(Price::new(
774                trail_stop_price * price_magnifier,
775                price_precision,
776            ));
777        }
778
779        if let Some(aux_price) = order.aux_price {
780            trailing_offset = Some(decimal_from_f64(aux_price)?);
781            trailing_offset_type = Some(TrailingOffsetType::Price);
782        } else if let Some(trailing_percent) = order.trailing_percent {
783            trailing_offset = Some(decimal_from_f64(trailing_percent)? * Decimal::from(100));
784            trailing_offset_type = Some(TrailingOffsetType::BasisPoints);
785        }
786
787        if order_type == OrderType::TrailingStopLimit
788            && let Some(limit_price_offset) = order.limit_price_offset
789        {
790            limit_offset = Some(decimal_from_f64(limit_price_offset)?);
791            trailing_offset_type = Some(trailing_offset_type.unwrap_or(TrailingOffsetType::Price));
792        }
793
794        return Ok((
795            trigger_price,
796            limit_offset,
797            trailing_offset,
798            trailing_offset_type,
799        ));
800    }
801
802    if let Some(aux_price) = order.aux_price {
803        trigger_price = Some(Price::new(aux_price * price_magnifier, price_precision));
804    }
805
806    Ok((
807        trigger_price,
808        limit_offset,
809        trailing_offset,
810        trailing_offset_type,
811    ))
812}
813
814fn decimal_from_f64(value: f64) -> anyhow::Result<Decimal> {
815    Decimal::from_str(&value.to_string())
816        .with_context(|| format!("Failed to convert IB floating-point value {value} to Decimal"))
817}
818
819/// Parse execution time string to UnixNanos.
820///
821/// Parse IB execution time to UnixNanos.
822///
823/// Supported IB formats:
824/// - "20230223 00:43:36 Universal"
825/// - "20230223 00:43:36 UTC"
826/// - "20230223 00:43:36 MET"
827/// - "20230223 00:43:36 America/New_York"
828/// - "20230223 00:43:36" (assumed UTC)
829/// - "20250225-15:15:00" (assumed UTC)
830///
831/// Timezones are resolved through Jiff's bundled IANA tz database, so any
832/// region abbreviation or name that IB stamps the execution with (e.g. `MET`,
833/// `EST`, `America/New_York`) is honored, matching the v1 pandas-based parser.
834/// This matters because some IB accounts (e.g. European paper accounts) report a
835/// server timezone such as `MET` that the gateway cannot be coerced out of.
836///
837/// # Errors
838///
839/// Returns an error if the timestamp is malformed, the timezone is
840/// unrecognized, or the local time is non-existent (a DST spring-forward gap).
841/// DST fall-back folds resolve to the earliest matching instant.
842pub fn parse_execution_time(time_str: &str) -> anyhow::Result<UnixNanos> {
843    const NAIVE_FORMAT: &str = "%Y%m%d %H:%M:%S";
844
845    // Hyphenated, space-less form (e.g. "20250225-15:15:00") is always UTC.
846    if !time_str.contains(' ') {
847        let normalized = time_str.replace('-', " ");
848
849        let dt = DateTime::strptime(NAIVE_FORMAT, &normalized).map_err(|e| {
850            anyhow::anyhow!("Failed to parse execution timestamp '{time_str}': {e}")
851        })?;
852
853        return datetime_to_unix_nanos(Offset::UTC.to_timestamp(dt)?, time_str);
854    }
855
856    // Split into at most three parts: date, time, and optional timezone token.
857    // The timezone token itself never contains a space, so `splitn(3, ' ')`
858    // correctly groups IANA names such as "America/New_York".
859    let mut parts = time_str.splitn(3, ' ');
860
861    let (Some(date), Some(time)) = (parts.next(), parts.next()) else {
862        anyhow::bail!("Invalid execution time format: {time_str}");
863    };
864
865    let tz_str = parts.next().unwrap_or("").trim();
866
867    let naive_str = format!("{date} {time}");
868    let dt = DateTime::strptime(NAIVE_FORMAT, &naive_str)
869        .map_err(|e| anyhow::anyhow!("Failed to parse execution timestamp '{time_str}': {e}"))?;
870
871    let utc = if tz_str.is_empty() {
872        Offset::UTC.to_timestamp(dt)?
873    } else {
874        localize_with_zone(dt, tz_str, time_str)?
875    };
876
877    datetime_to_unix_nanos(utc, time_str)
878}
879
880/// Localize a naive timestamp against an IB timezone token and convert to UTC.
881///
882/// `Z` is normalized to `UTC`; everything else is resolved through the IANA tz
883/// database. Error and fold behavior is documented on [`parse_execution_time`].
884fn localize_with_zone(dt: DateTime, tz_str: &str, time_str: &str) -> anyhow::Result<Timestamp> {
885    let tz_name = if tz_str.eq_ignore_ascii_case("Z") {
886        "UTC"
887    } else {
888        tz_str
889    };
890
891    let zone = get_timezone(tz_name).map_err(|_| {
892        anyhow::anyhow!(
893            "Unrecognized execution timezone '{tz_str}' in '{time_str}'. Configure TWS / IB Gateway to emit a standard timezone (e.g. UTC)"
894        )
895    })?;
896
897    let ambiguous = zone.to_ambiguous_timestamp(dt);
898    match ambiguous.offset() {
899        AmbiguousOffset::Unambiguous { .. } => Ok(ambiguous.unambiguous()?),
900        // Fall-back fold: take the earliest instant (worst case ~1h skew).
901        AmbiguousOffset::Fold { .. } => Ok(ambiguous.earlier()?),
902        AmbiguousOffset::Gap { .. } => {
903            anyhow::bail!("Execution timestamp '{time_str}' is non-existent in timezone '{tz_str}'")
904        }
905    }
906}
907
908fn datetime_to_unix_nanos(dt: Timestamp, time_str: &str) -> anyhow::Result<UnixNanos> {
909    let nanos: u64 = dt
910        .as_nanosecond()
911        .try_into()
912        .map_err(|_| anyhow::anyhow!("Execution timestamp '{time_str}' was before Unix epoch"))?;
913    Ok(UnixNanos::new(nanos))
914}
915
916#[cfg(test)]
917mod tests {
918    use ibapi::{
919        contracts::Contract,
920        orders::{Action, ExecutionSide, Liquidity, Order, OrderStatusKind},
921    };
922    use nautilus_model::{
923        enums::{PositionSide, TrailingOffsetType, TriggerType},
924        identifiers::{Symbol, Venue},
925        instruments::{InstrumentAny, stubs::equity_aapl},
926    };
927    use rust_decimal::Decimal;
928
929    use super::*;
930    use crate::{
931        config::InteractiveBrokersInstrumentProviderConfig,
932        providers::instruments::InteractiveBrokersInstrumentProvider,
933    };
934
935    fn create_test_instrument_provider() -> InteractiveBrokersInstrumentProvider {
936        let config = InteractiveBrokersInstrumentProviderConfig::default();
937        InteractiveBrokersInstrumentProvider::new(config)
938    }
939
940    fn create_test_instrument_id() -> InstrumentId {
941        InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"))
942    }
943
944    use rstest::rstest;
945
946    fn create_test_avg_px_provider() -> InteractiveBrokersInstrumentProvider {
947        let provider = InteractiveBrokersInstrumentProvider::new(
948            crate::config::InteractiveBrokersInstrumentProviderConfig::default(),
949        );
950        provider.insert_test_instrument(InstrumentAny::Equity(equity_aapl()), 265_598, 1);
951        provider
952    }
953
954    // A fill with a 1.00 commission, so IB's average cost differs from the entry price and a
955    // test tells an accepted entry price from a kept average cost
956    fn create_test_entry_fill(
957        side: OrderSide,
958        qty: &str,
959        px: &str,
960        trade_id: &str,
961        ts_event: u64,
962    ) -> FillReport {
963        FillReport::new(
964            AccountId::from("IB-DU001"),
965            equity_aapl().id,
966            VenueOrderId::new(format!("PERM-{trade_id}")),
967            TradeId::new(trade_id),
968            side,
969            Quantity::from(qty),
970            Price::from(px),
971            Money::from("1.00 USD"),
972            LiquiditySide::Taker,
973            None,
974            None,
975            UnixNanos::from(ts_event),
976            UnixNanos::default(),
977            None,
978        )
979    }
980
981    fn create_test_entry_position_report(
982        side: PositionSide,
983        qty: &str,
984        venue_avg_px: &str,
985    ) -> PositionStatusReport {
986        PositionStatusReport::new(
987            AccountId::from("IB-DU001"),
988            equity_aapl().id,
989            side,
990            Quantity::from(qty),
991            UnixNanos::default(),
992            UnixNanos::default(),
993            None,
994            None,
995            Some(Decimal::from_str(venue_avg_px).unwrap()),
996        )
997    }
998
999    fn rebuild_avg_px_open(
1000        position_report: PositionStatusReport,
1001        evidence: &FillEvidence,
1002    ) -> Option<Decimal> {
1003        let mut position_reports = vec![position_report];
1004        fill_position_avg_px_from_fills(
1005            &mut position_reports,
1006            evidence,
1007            &create_test_avg_px_provider(),
1008        );
1009        position_reports[0].avg_px_open
1010    }
1011
1012    #[rstest]
1013    // IB's average cost is the entry price plus the opening commissions per unit for a long,
1014    // minus them for a short, rounded to the price precision
1015    #[case::fills_open_position(
1016        PositionSide::Long,
1017        vec![(OrderSide::Buy, "7", "229.67")],
1018        "229.81",
1019        "229.67"
1020    )]
1021    #[case::fills_average_in(
1022        PositionSide::Long,
1023        vec![(OrderSide::Buy, "5", "229.00"), (OrderSide::Buy, "2", "232.50")],
1024        "230.29",
1025        "230.00"
1026    )]
1027    #[case::reduce_keeps_entry(
1028        PositionSide::Long,
1029        vec![(OrderSide::Buy, "4", "229.00"), (OrderSide::Buy, "4", "231.00"), (OrderSide::Sell, "1", "235.00")],
1030        "230.25",
1031        "230.00"
1032    )]
1033    #[case::flip_carries_no_commission(
1034        PositionSide::Short,
1035        vec![(OrderSide::Buy, "2", "230.00"), (OrderSide::Sell, "8", "228.00"), (OrderSide::Sell, "1", "221.00")],
1036        "226.86",
1037        "227.00"
1038    )]
1039    #[case::fills_partial_history(
1040        PositionSide::Long,
1041        vec![(OrderSide::Buy, "2", "230.00")],
1042        "229.81",
1043        "229.81"
1044    )]
1045    #[case::remnant_closed_then_reopened(
1046        PositionSide::Long,
1047        vec![(OrderSide::Sell, "7", "229.00"), (OrderSide::Buy, "7", "231.00")],
1048        "231.14",
1049        "231.00"
1050    )]
1051    #[case::remnant_flipped_in_window(
1052        PositionSide::Short,
1053        vec![(OrderSide::Sell, "7", "228.00"), (OrderSide::Sell, "1", "221.00")],
1054        "226.86",
1055        "227.00"
1056    )]
1057    #[case::remnant_reduced_in_window(
1058        PositionSide::Long,
1059        vec![(OrderSide::Sell, "2", "235.00")],
1060        "229.81",
1061        "229.81"
1062    )]
1063    #[case::no_fills(PositionSide::Long, vec![], "229.81", "229.81")]
1064    // A missing execution that IB never returned leaves no parse gap, but the fills then
1065    // disagree with IB's average cost
1066    #[case::average_cost_disagrees(
1067        PositionSide::Long,
1068        vec![(OrderSide::Buy, "7", "229.67")],
1069        "230.50",
1070        "230.50"
1071    )]
1072    fn test_fill_position_avg_px_from_fills(
1073        #[case] position_side: PositionSide,
1074        #[case] fills: Vec<(OrderSide, &str, &str)>,
1075        #[case] venue_avg_px: &str,
1076        #[case] expected: &str,
1077    ) {
1078        let other_fill = FillReport::new(
1079            AccountId::from("IB-DU001"),
1080            InstrumentId::from("MSFT.NASDAQ"),
1081            VenueOrderId::new("PERM-9"),
1082            TradeId::new("T-OTHER"),
1083            OrderSide::Sell,
1084            Quantity::from(7),
1085            Price::from("150.00"),
1086            Money::from("1.00 USD"),
1087            LiquiditySide::Taker,
1088            None,
1089            None,
1090            UnixNanos::from(1),
1091            UnixNanos::default(),
1092            None,
1093        );
1094        let mut reports: Vec<_> = fills
1095            .into_iter()
1096            .enumerate()
1097            .rev()
1098            .map(|(index, (side, qty, px))| {
1099                create_test_entry_fill(side, qty, px, &format!("T-{index}"), index as u64 + 10)
1100            })
1101            .collect();
1102        reports.push(other_fill);
1103        let evidence = FillEvidence {
1104            reports,
1105            ..Default::default()
1106        };
1107
1108        let avg_px_open = rebuild_avg_px_open(
1109            create_test_entry_position_report(position_side, "7", venue_avg_px),
1110            &evidence,
1111        );
1112
1113        assert_eq!(avg_px_open, Some(Decimal::from_str(expected).unwrap()));
1114    }
1115
1116    #[rstest]
1117    fn test_fill_position_avg_px_from_fills_keeps_venue_average_for_other_commission_currency() {
1118        let mut fill = create_test_entry_fill(OrderSide::Buy, "7", "229.67", "T-1", 10);
1119        fill.commission = Money::from("1.00 EUR");
1120        let evidence = FillEvidence {
1121            reports: vec![fill],
1122            ..Default::default()
1123        };
1124
1125        let avg_px_open = rebuild_avg_px_open(
1126            create_test_entry_position_report(PositionSide::Long, "7", "229.81"),
1127            &evidence,
1128        );
1129
1130        assert_eq!(avg_px_open, Some(Decimal::from_str("229.81").unwrap()));
1131    }
1132
1133    #[rstest]
1134    // Long 1 before the window, then BUY 1 at 100 and a SELL 1 that failed to parse. Without
1135    // the SELL the replay infers a flat start and a lot opened at 100, which matches an average
1136    // cost of 101.00, so only the parse gap keeps the venue average
1137    #[case::instrument_missing(false, vec![equity_aapl().id], "101.00")]
1138    #[case::all_missing(true, vec![], "101.00")]
1139    #[case::other_instrument_missing(false, vec![InstrumentId::from("MSFT.NASDAQ")], "100.00")]
1140    fn test_fill_position_avg_px_from_fills_keeps_venue_average_for_missing_executions(
1141        #[case] missing_all: bool,
1142        #[case] missing_instruments: Vec<InstrumentId>,
1143        #[case] expected: &str,
1144    ) {
1145        let evidence = FillEvidence {
1146            reports: vec![create_test_entry_fill(
1147                OrderSide::Buy,
1148                "1",
1149                "100.00",
1150                "T-1",
1151                10,
1152            )],
1153            combo_leg_reports: Vec::new(),
1154            missing_all,
1155            missing_instruments: missing_instruments.into_iter().collect(),
1156        };
1157
1158        let avg_px_open = rebuild_avg_px_open(
1159            create_test_entry_position_report(PositionSide::Long, "1", "101.00"),
1160            &evidence,
1161        );
1162
1163        assert_eq!(avg_px_open, Some(Decimal::from_str(expected).unwrap()));
1164    }
1165
1166    #[rstest]
1167    // Fill reports arrive in commission order, so fills sharing a timestamp have no known
1168    // execution order. In each case one delivery order would reconstruct an entry price that
1169    // matches IB's average cost; with the tied group made ambiguous the venue average stands,
1170    // and a later fill from flat sets the entry again
1171    #[case::opposite_sides(
1172        PositionSide::Long,
1173        "2",
1174        vec![(OrderSide::Buy, "2", "110.00"), (OrderSide::Sell, "2", "120.00")],
1175        vec![(OrderSide::Buy, "1", "100.00")],
1176        "105.50",
1177        "105.50"
1178    )]
1179    #[case::same_side_flip(
1180        PositionSide::Long,
1181        "2",
1182        vec![(OrderSide::Buy, "1", "100.00"), (OrderSide::Buy, "2", "110.00")],
1183        vec![],
1184        "110.50",
1185        "110.50"
1186    )]
1187    #[case::same_side_from_flat(
1188        PositionSide::Long,
1189        "2",
1190        vec![(OrderSide::Buy, "1", "100.00"), (OrderSide::Buy, "1", "110.00")],
1191        vec![],
1192        "106.00",
1193        "105.00"
1194    )]
1195    #[case::reopened_after_ambiguous_group(
1196        PositionSide::Long,
1197        "1",
1198        vec![(OrderSide::Buy, "1", "100.00"), (OrderSide::Sell, "1", "105.00")],
1199        vec![(OrderSide::Buy, "1", "110.00")],
1200        "111.00",
1201        "110.00"
1202    )]
1203    fn test_fill_position_avg_px_from_fills_same_timestamp_fills_ignore_delivery_order(
1204        #[case] position_side: PositionSide,
1205        #[case] quantity: &str,
1206        #[case] tied: Vec<(OrderSide, &str, &str)>,
1207        #[case] later: Vec<(OrderSide, &str, &str)>,
1208        #[case] venue_avg_px: &str,
1209        #[case] expected: &str,
1210        #[values(false, true)] reversed: bool,
1211    ) {
1212        let mut reports: Vec<_> = tied
1213            .into_iter()
1214            .enumerate()
1215            .map(|(index, (side, qty, px))| {
1216                create_test_entry_fill(side, qty, px, &format!("T-{index}"), 10)
1217            })
1218            .collect();
1219
1220        if reversed {
1221            reports.reverse();
1222        }
1223
1224        reports.extend(
1225            later
1226                .into_iter()
1227                .enumerate()
1228                .map(|(index, (side, qty, px))| {
1229                    create_test_entry_fill(side, qty, px, &format!("L-{index}"), 20 + index as u64)
1230                }),
1231        );
1232        let evidence = FillEvidence {
1233            reports,
1234            ..Default::default()
1235        };
1236
1237        let avg_px_open = rebuild_avg_px_open(
1238            create_test_entry_position_report(position_side, quantity, venue_avg_px),
1239            &evidence,
1240        );
1241
1242        assert_eq!(avg_px_open, Some(Decimal::from_str(expected).unwrap()));
1243    }
1244
1245    #[rstest]
1246    #[case::with_combo_legs(true, "4.00")]
1247    #[case::without_combo_legs(false, "3.00")]
1248    fn test_fill_position_avg_px_from_fills_counts_combo_leg_fills(
1249        #[case] with_combo_legs: bool,
1250        #[case] expected: &str,
1251    ) {
1252        // Starting flat, SELL 2 at 5 and BUY 1 at 3 leave short 1, then a combo leg BUY 2 at 4
1253        // flips to long 1 at 4 with no commission in IB's average cost. Without the leg the
1254        // replay reads a pre-window long 2 and a lot opened at 3, whose 1.00 commission also
1255        // gives an average cost of 4.00, so only the leg fill gives the right entry
1256        let combo_leg_reports = if with_combo_legs {
1257            vec![create_test_entry_fill(
1258                OrderSide::Buy,
1259                "2",
1260                "4.00",
1261                "T-3",
1262                30,
1263            )]
1264        } else {
1265            Vec::new()
1266        };
1267        let evidence = FillEvidence {
1268            reports: vec![
1269                create_test_entry_fill(OrderSide::Sell, "2", "5.00", "T-1", 10),
1270                create_test_entry_fill(OrderSide::Buy, "1", "3.00", "T-2", 20),
1271            ],
1272            combo_leg_reports,
1273            ..Default::default()
1274        };
1275
1276        let avg_px_open = rebuild_avg_px_open(
1277            create_test_entry_position_report(PositionSide::Long, "1", "4.00"),
1278            &evidence,
1279        );
1280
1281        assert_eq!(avg_px_open, Some(Decimal::from_str(expected).unwrap()));
1282    }
1283
1284    #[rstest]
1285    fn test_fill_missing_avg_px_uses_fill_reports() {
1286        let instrument_id = InstrumentId::from("ESZ6.XCME");
1287        let account_id = AccountId::from("IB-DU001");
1288
1289        let order_report = |venue_order_id: &str, avg_px: Option<Decimal>| {
1290            let mut report = OrderStatusReport::new(
1291                account_id,
1292                instrument_id,
1293                None,
1294                VenueOrderId::new(venue_order_id),
1295                Some(OrderSide::Buy),
1296                OrderType::Market,
1297                TimeInForce::Day,
1298                NautilusOrderStatus::Filled,
1299                Quantity::from(3),
1300                Quantity::from(3),
1301                UnixNanos::default(),
1302                UnixNanos::default(),
1303                UnixNanos::default(),
1304                None,
1305            );
1306            report.avg_px = avg_px;
1307            report
1308        };
1309
1310        let fill = |venue_order_id: &str, trade_id: &str, qty: u64, px: &str| {
1311            FillReport::new(
1312                account_id,
1313                instrument_id,
1314                VenueOrderId::new(venue_order_id),
1315                TradeId::new(trade_id),
1316                OrderSide::Buy,
1317                Quantity::from(qty),
1318                Price::from(px),
1319                Money::from("0.00 USD"),
1320                LiquiditySide::Taker,
1321                None,
1322                None,
1323                UnixNanos::default(),
1324                UnixNanos::default(),
1325                None,
1326            )
1327        };
1328
1329        let mut reports = vec![
1330            order_report("PERM-1", None),
1331            order_report("PERM-2", Some(Decimal::from(7))),
1332            order_report("PERM-3", None),
1333        ];
1334        let fills = vec![
1335            fill("PERM-1", "T-1", 1, "2.55"),
1336            fill("PERM-1", "T-2", 2, "2.40"),
1337            fill("PERM-2", "T-3", 3, "9.00"),
1338        ];
1339
1340        fill_missing_avg_px(&mut reports, &fills);
1341
1342        assert_eq!(reports[0].avg_px, Some(Decimal::from_str("2.45").unwrap()));
1343        assert_eq!(reports[1].avg_px, Some(Decimal::from(7)));
1344        assert_eq!(reports[2].avg_px, None);
1345    }
1346
1347    #[rstest]
1348    #[case::combo(ibapi::contracts::SecurityType::Spread, true)]
1349    #[case::leg(ibapi::contracts::SecurityType::FuturesOption, false)]
1350    fn test_is_combo_execution(
1351        #[case] security_type: ibapi::contracts::SecurityType,
1352        #[case] expected: bool,
1353    ) {
1354        let exec_data = ibapi::orders::ExecutionData {
1355            contract: ibapi::contracts::Contract {
1356                security_type,
1357                ..Default::default()
1358            },
1359            ..Default::default()
1360        };
1361
1362        assert_eq!(is_combo_execution(&exec_data), expected);
1363    }
1364
1365    #[rstest]
1366    fn test_ibalgo_with_zero_limit_price_maps_to_market() {
1367        assert_eq!(map_ib_order_type("IBALGO", Some(0.0)), OrderType::Market);
1368    }
1369
1370    #[rstest]
1371    fn test_parse_execution_time_hyphenated_format() {
1372        let time_str = "20250225-15:15:00";
1373        let result = parse_execution_time(time_str);
1374        assert!(result.is_ok());
1375        let timestamp = result.unwrap();
1376        assert!(timestamp.as_i64() > 0);
1377    }
1378
1379    #[rstest]
1380    fn test_parse_execution_time_with_met_timezone() {
1381        // Regression for European paper accounts that IB stamps with `MET`.
1382        // MET (CET) in February observes standard time (UTC+1).
1383        let met = parse_execution_time("20230223 00:43:36 MET").unwrap();
1384        let utc = parse_execution_time("20230223 00:43:36 Universal").unwrap();
1385        // Local 00:43:36 MET == 2023-02-22 23:43:36 UTC, i.e. 1 hour before UTC.
1386        assert_eq!(
1387            met.as_i64(),
1388            utc.as_i64() - 3_600_000_000_000,
1389            "MET (CET) should be 1h ahead of UTC in February"
1390        );
1391        assert!(met.as_i64() > 0);
1392    }
1393
1394    #[rstest]
1395    fn test_parse_execution_time_applies_dst_for_regional_timezone() {
1396        // Same zone, two seasons: EST (UTC-5) in winter vs EDT (UTC-4) in summer.
1397        // Equal offsets would mean DST is NOT being applied - a real regression.
1398        let winter = parse_execution_time("20230223 00:43:36 America/New_York").unwrap();
1399        let summer = parse_execution_time("20230715 00:43:36 America/New_York").unwrap();
1400        let winter_utc = parse_execution_time("20230223 00:43:36 Universal").unwrap();
1401        let summer_utc = parse_execution_time("20230715 00:43:36 Universal").unwrap();
1402        assert_eq!(winter.as_i64(), winter_utc.as_i64() + 5 * 3_600_000_000_000); // EST
1403        assert_eq!(summer.as_i64(), summer_utc.as_i64() + 4 * 3_600_000_000_000); // EDT
1404    }
1405
1406    #[rstest]
1407    fn test_parse_execution_time_dst_fall_back_fold_resolves_to_earliest() {
1408        // CME US/Central account (bebop23's case): on 2023-11-05 fall-back night
1409        // 01:30 America/Chicago occurs twice. Resolve to earliest (CDT, 06:30 UTC),
1410        // don't drop the fill.
1411        let fold = parse_execution_time("20231105 01:30:00 America/Chicago").unwrap();
1412        assert_eq!(
1413            fold.as_i64(),
1414            parse_execution_time("20231105 06:30:00 Universal")
1415                .unwrap()
1416                .as_i64()
1417        );
1418        assert_ne!(
1419            fold.as_i64(),
1420            parse_execution_time("20231105 07:30:00 Universal")
1421                .unwrap()
1422                .as_i64()
1423        );
1424    }
1425
1426    #[rstest]
1427    fn test_parse_execution_time_dst_spring_forward_gap_errors() {
1428        // 02:30 America/Chicago never exists on 2023-03-12 spring-forward night.
1429        let gap = parse_execution_time("20230312 02:30:00 America/Chicago");
1430        assert!(gap.is_err());
1431    }
1432
1433    #[rstest]
1434    fn test_parse_execution_time_fixed_offset_zone_without_dst() {
1435        // Asia/Tokyo is JST (UTC+9) year-round - guards the no-DST path.
1436        let tokyo = parse_execution_time("20230223 00:43:36 Asia/Tokyo").unwrap();
1437        let utc = parse_execution_time("20230223 00:43:36 Universal").unwrap();
1438        assert_eq!(tokyo.as_i64(), utc.as_i64() - 9 * 3_600_000_000_000);
1439    }
1440
1441    #[rstest]
1442    fn test_parse_execution_time_with_unrecognized_timezone_errors() {
1443        let time_str = "20230223 00:43:36 Mars/Olympus";
1444        let result = parse_execution_time(time_str);
1445        assert!(result.is_err());
1446    }
1447
1448    #[rstest]
1449    fn test_parse_execution_time_utc() {
1450        let time_str = "20230223 00:43:36 Universal";
1451        let result = parse_execution_time(time_str);
1452        assert!(result.is_ok());
1453        let timestamp = result.unwrap();
1454        assert!(timestamp.as_i64() > 0);
1455    }
1456
1457    #[rstest]
1458    fn test_parse_execution_time_no_timezone_assumes_utc() {
1459        let time_str = "20230223 00:43:36";
1460        let result = parse_execution_time(time_str);
1461        assert!(result.is_ok());
1462        let timestamp = result.unwrap();
1463        assert!(timestamp.as_i64() > 0);
1464    }
1465
1466    #[rstest]
1467    fn test_parse_execution_time_invalid_format() {
1468        let time_str = "invalid format";
1469        let result = parse_execution_time(time_str);
1470        assert!(result.is_err());
1471    }
1472
1473    #[rstest]
1474    fn test_parse_execution_time_short_format() {
1475        let time_str = "20230223 00:43";
1476        let result = parse_execution_time(time_str);
1477        assert!(result.is_err());
1478    }
1479
1480    #[rstest]
1481    fn test_parse_order_status_to_report_submitted() {
1482        let instrument_provider = create_test_instrument_provider();
1483        let instrument_id = create_test_instrument_id();
1484        let account_id = AccountId::from("IB-001");
1485
1486        let order_status = OrderStatus {
1487            order_id: 12345,
1488            status: OrderStatusKind::Submitted,
1489            filled: 0.0,
1490            remaining: 100.0,
1491            average_fill_price: Some(0.0),
1492            perm_id: 0,
1493            parent_id: 0,
1494            last_fill_price: Some(0.0),
1495            client_id: 0,
1496            why_held: String::new(),
1497            market_cap_price: Some(0.0),
1498        };
1499
1500        let result = parse_order_status_to_report(
1501            &order_status,
1502            None,
1503            instrument_id,
1504            account_id,
1505            &instrument_provider,
1506            UnixNanos::new(0),
1507        );
1508
1509        // May fail if instrument not in provider, but that's expected
1510        if let Err(e) = result {
1511            let error_msg = e.to_string();
1512            assert!(
1513                error_msg.contains("not found") || error_msg.contains("instrument"),
1514                "Unexpected error: {error_msg}"
1515            );
1516        }
1517    }
1518
1519    #[rstest]
1520    fn test_parse_order_status_to_report_filled() {
1521        let instrument_provider = create_test_instrument_provider();
1522        let instrument_id = create_test_instrument_id();
1523        let account_id = AccountId::from("IB-001");
1524
1525        let order_status = OrderStatus {
1526            order_id: 12345,
1527            status: OrderStatusKind::Filled,
1528            filled: 100.0,
1529            remaining: 0.0,
1530            average_fill_price: Some(150.25),
1531            perm_id: 0,
1532            parent_id: 0,
1533            last_fill_price: Some(150.25),
1534            client_id: 0,
1535            why_held: String::new(),
1536            market_cap_price: Some(0.0),
1537        };
1538
1539        let result = parse_order_status_to_report(
1540            &order_status,
1541            None,
1542            instrument_id,
1543            account_id,
1544            &instrument_provider,
1545            UnixNanos::new(0),
1546        );
1547
1548        // May fail if instrument not in provider, but that's expected
1549        if let Err(e) = result {
1550            let error_msg = e.to_string();
1551            assert!(
1552                error_msg.contains("not found") || error_msg.contains("instrument"),
1553                "Unexpected error: {error_msg}"
1554            );
1555        }
1556    }
1557
1558    #[rstest]
1559    fn test_parse_order_status_to_report_spread_allows_negative_avg_fill_price() {
1560        let instrument_provider = create_test_instrument_provider();
1561
1562        let instrument_id = InstrumentId::new(
1563            Symbol::from("(1)SPY C400___((1))SPY C410"),
1564            Venue::from("SMART"),
1565        );
1566        let account_id = AccountId::from("IB-001");
1567
1568        let order_status = OrderStatus {
1569            order_id: 12345,
1570            status: OrderStatusKind::Filled,
1571            filled: 1.0,
1572            remaining: 0.0,
1573            average_fill_price: Some(-2.25),
1574            perm_id: 0,
1575            parent_id: 0,
1576            last_fill_price: Some(-2.25),
1577            client_id: 0,
1578            why_held: String::new(),
1579            market_cap_price: Some(0.0),
1580        };
1581
1582        let report = parse_order_status_to_report(
1583            &order_status,
1584            None,
1585            instrument_id,
1586            account_id,
1587            &instrument_provider,
1588            UnixNanos::new(0),
1589        )
1590        .unwrap();
1591
1592        assert_eq!(report.avg_px, Some(Decimal::from_str("-2.25").unwrap()));
1593    }
1594
1595    #[rstest]
1596    fn test_parse_order_status_to_report_inactive_maps_to_rejected() {
1597        let instrument_provider = create_test_instrument_provider();
1598        let instrument_id = create_test_instrument_id();
1599        let account_id = AccountId::from("IB-001");
1600
1601        let order_status = OrderStatus {
1602            order_id: 12345,
1603            status: OrderStatusKind::Inactive,
1604            filled: 0.0,
1605            remaining: 100.0,
1606            average_fill_price: Some(0.0),
1607            perm_id: 0,
1608            parent_id: 0,
1609            last_fill_price: Some(0.0),
1610            client_id: 0,
1611            why_held: String::new(),
1612            market_cap_price: Some(0.0),
1613        };
1614
1615        let report = parse_order_status_to_report(
1616            &order_status,
1617            None,
1618            instrument_id,
1619            account_id,
1620            &instrument_provider,
1621            UnixNanos::new(0),
1622        )
1623        .unwrap();
1624
1625        assert_eq!(report.order_status, NautilusOrderStatus::Rejected);
1626    }
1627
1628    #[rstest]
1629    fn test_parse_order_status_to_report_partial_fill_and_perm_fallback() {
1630        let instrument_provider = create_test_instrument_provider();
1631        let instrument_id = create_test_instrument_id();
1632        let account_id = AccountId::from("IB-001");
1633
1634        let order_status = OrderStatus {
1635            order_id: 0,
1636            status: OrderStatusKind::Submitted,
1637            filled: 3.0,
1638            remaining: 7.0,
1639            average_fill_price: Some(150.25),
1640            perm_id: 123_456,
1641            parent_id: 0,
1642            last_fill_price: Some(150.25),
1643            client_id: 0,
1644            why_held: String::new(),
1645            market_cap_price: Some(0.0),
1646        };
1647
1648        let order = Order {
1649            action: Action::Buy,
1650            total_quantity: 10.0,
1651            order_type: "LMT".to_string(),
1652            limit_price: Some(150.25),
1653            order_ref: "O-20260527-001:123".to_string(),
1654            ..Default::default()
1655        };
1656
1657        let report = parse_order_status_to_report(
1658            &order_status,
1659            Some(&order),
1660            instrument_id,
1661            account_id,
1662            &instrument_provider,
1663            UnixNanos::new(0),
1664        )
1665        .unwrap();
1666
1667        assert_eq!(report.order_status, NautilusOrderStatus::PartiallyFilled);
1668        assert_eq!(report.venue_order_id.to_string(), "PERM-123456");
1669        assert_eq!(
1670            report.client_order_id,
1671            Some(ClientOrderId::from("O-20260527-001"))
1672        );
1673    }
1674
1675    #[rstest]
1676    fn test_parse_order_status_to_report_recovers_completed_order_quantity() {
1677        let instrument_provider = create_test_instrument_provider();
1678        let instrument_id = create_test_instrument_id();
1679
1680        let order_status = OrderStatus {
1681            status: OrderStatusKind::Filled,
1682            filled: 3.0,
1683            remaining: 0.0,
1684            ..Default::default()
1685        };
1686
1687        let order = Order {
1688            total_quantity: 0.0,
1689            filled_quantity: 3.0,
1690            ..Default::default()
1691        };
1692
1693        let report = parse_order_status_to_report(
1694            &order_status,
1695            Some(&order),
1696            instrument_id,
1697            AccountId::from("IB-001"),
1698            &instrument_provider,
1699            UnixNanos::new(0),
1700        )
1701        .unwrap();
1702
1703        assert_eq!(report.quantity, Quantity::from(3));
1704        assert_eq!(report.filled_qty, Quantity::from(3));
1705    }
1706
1707    #[rstest]
1708    fn test_parse_order_status_to_report_sets_stop_trigger_type() {
1709        let instrument_provider = create_test_instrument_provider();
1710        let instrument_id = create_test_instrument_id();
1711
1712        let order_status = OrderStatus {
1713            status: OrderStatusKind::Cancelled,
1714            remaining: 1.0,
1715            ..Default::default()
1716        };
1717
1718        let order = Order {
1719            total_quantity: 1.0,
1720            order_type: "STP".to_string(),
1721            aux_price: Some(100.0),
1722            trigger_method: ibapi::orders::conditions::TriggerMethod::Last,
1723            ..Default::default()
1724        };
1725
1726        let report = parse_order_status_to_report(
1727            &order_status,
1728            Some(&order),
1729            instrument_id,
1730            AccountId::from("IB-001"),
1731            &instrument_provider,
1732            UnixNanos::new(0),
1733        )
1734        .unwrap();
1735
1736        assert_eq!(report.order_type, OrderType::StopMarket);
1737        assert_eq!(report.trigger_price, Some(Price::new(100.0, 0)));
1738        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
1739    }
1740
1741    #[rstest]
1742    #[case(
1743        "GTD",
1744        "20260925 20:00:00 UTC",
1745        TimeInForce::Gtd,
1746        Some(1_790_366_400_000_000_000)
1747    )]
1748    #[case(
1749        "GTC",
1750        "20260925-20:00:00",
1751        TimeInForce::Gtd,
1752        Some(1_790_366_400_000_000_000)
1753    )]
1754    #[case("GTD", "not a date", TimeInForce::Gtc, None)]
1755    #[case("DAY", "", TimeInForce::Day, None)]
1756    fn test_parse_order_status_to_report_sets_gtd_expire_time(
1757        #[case] tif: &str,
1758        #[case] good_till_date: &str,
1759        #[case] expected_time_in_force: TimeInForce,
1760        #[case] expected_expire_time: Option<u64>,
1761    ) {
1762        let instrument_provider = create_test_instrument_provider();
1763        let instrument_id = create_test_instrument_id();
1764
1765        let order_status = OrderStatus {
1766            status: OrderStatusKind::Filled,
1767            filled: 1.0,
1768            ..Default::default()
1769        };
1770
1771        let order = Order {
1772            total_quantity: 1.0,
1773            order_type: "LMT".to_string(),
1774            limit_price: Some(101.0),
1775            tif: ibapi::orders::TimeInForce::from(tif),
1776            good_till_date: good_till_date.to_string(),
1777            ..Default::default()
1778        };
1779
1780        let report = parse_order_status_to_report(
1781            &order_status,
1782            Some(&order),
1783            instrument_id,
1784            AccountId::from("IB-001"),
1785            &instrument_provider,
1786            UnixNanos::new(0),
1787        )
1788        .unwrap();
1789
1790        assert_eq!(report.time_in_force, expected_time_in_force);
1791        assert_eq!(
1792            report.expire_time,
1793            expected_expire_time.map(UnixNanos::from)
1794        );
1795    }
1796
1797    #[rstest]
1798    #[case("MKT", Some(0.0), OrderType::Market, None)]
1799    #[case("STP", Some(0.0), OrderType::StopMarket, None)]
1800    #[case("LMT", Some(101.0), OrderType::Limit, Some(Price::new(101.0, 0)))]
1801    fn test_parse_order_status_to_report_sets_price_only_for_limit_types(
1802        #[case] ib_order_type: &str,
1803        #[case] limit_price: Option<f64>,
1804        #[case] expected_order_type: OrderType,
1805        #[case] expected_price: Option<Price>,
1806    ) {
1807        let instrument_provider = create_test_instrument_provider();
1808        let instrument_id = create_test_instrument_id();
1809
1810        let order_status = OrderStatus {
1811            status: OrderStatusKind::Filled,
1812            filled: 1.0,
1813            ..Default::default()
1814        };
1815
1816        let order = Order {
1817            total_quantity: 1.0,
1818            order_type: ib_order_type.to_string(),
1819            limit_price,
1820            aux_price: Some(100.0),
1821            ..Default::default()
1822        };
1823
1824        let report = parse_order_status_to_report(
1825            &order_status,
1826            Some(&order),
1827            instrument_id,
1828            AccountId::from("IB-001"),
1829            &instrument_provider,
1830            UnixNanos::new(0),
1831        )
1832        .unwrap();
1833
1834        assert_eq!(report.order_type, expected_order_type);
1835        assert_eq!(report.price, expected_price);
1836    }
1837
1838    #[rstest]
1839    fn test_ib_venue_order_id_prefers_perm_id_and_falls_back_to_order_id() {
1840        assert_eq!(ib_venue_order_id(123, 456).to_string(), "PERM-456");
1841        assert_eq!(ib_venue_order_id(123, 0).to_string(), "123");
1842    }
1843
1844    #[rstest]
1845    fn test_normalized_order_ref_strips_ib_suffix() {
1846        assert_eq!(normalized_order_ref("O-001:123"), Some("O-001"));
1847        assert_eq!(normalized_order_ref("O-001"), Some("O-001"));
1848        assert_eq!(normalized_order_ref(""), None);
1849    }
1850
1851    #[rstest]
1852    #[case(Liquidity::AddedLiquidity, LiquiditySide::Maker)]
1853    #[case(Liquidity::RemovedLiquidity, LiquiditySide::Taker)]
1854    #[case(Liquidity::None, LiquiditySide::NoLiquiditySide)]
1855    #[case(Liquidity::LiquidityRoutedOut, LiquiditySide::NoLiquiditySide)]
1856    #[case(Liquidity::Unknown(4), LiquiditySide::NoLiquiditySide)]
1857    fn test_execution_liquidity_side(
1858        #[case] liquidity: Liquidity,
1859        #[case] expected: LiquiditySide,
1860    ) {
1861        assert_eq!(execution_liquidity_side(&liquidity), expected);
1862    }
1863
1864    struct PricingExpectation {
1865        order_type: OrderType,
1866        price: Option<Price>,
1867        trigger_price: Option<Price>,
1868        limit_offset: Option<Decimal>,
1869        trailing_offset: Option<Decimal>,
1870        trailing_offset_type: Option<TrailingOffsetType>,
1871    }
1872
1873    #[rstest]
1874    #[case(
1875        "MKT",
1876        None,
1877        None,
1878        None,
1879        None,
1880        PricingExpectation {
1881            order_type: OrderType::Market,
1882            price: None,
1883            trigger_price: None,
1884            limit_offset: None,
1885            trailing_offset: None,
1886            trailing_offset_type: None,
1887        }
1888    )]
1889    #[case(
1890        "LMT",
1891        Some(185.0),
1892        None,
1893        None,
1894        None,
1895        PricingExpectation {
1896            order_type: OrderType::Limit,
1897            price: Some(Price::new(185.0, 0)),
1898            trigger_price: None,
1899            limit_offset: None,
1900            trailing_offset: None,
1901            trailing_offset_type: None,
1902        }
1903    )]
1904    #[case(
1905        "IBALGO",
1906        Some(185.0),
1907        None,
1908        None,
1909        None,
1910        PricingExpectation {
1911            order_type: OrderType::Limit,
1912            price: Some(Price::new(185.0, 0)),
1913            trigger_price: None,
1914            limit_offset: None,
1915            trailing_offset: None,
1916            trailing_offset_type: None,
1917        }
1918    )]
1919    #[case(
1920        "IBALGO",
1921        None,
1922        None,
1923        None,
1924        None,
1925        PricingExpectation {
1926            order_type: OrderType::Market,
1927            price: None,
1928            trigger_price: None,
1929            limit_offset: None,
1930            trailing_offset: None,
1931            trailing_offset_type: None,
1932        }
1933    )]
1934    #[case(
1935        "MIT",
1936        None,
1937        Some(180.0),
1938        None,
1939        None,
1940        PricingExpectation {
1941            order_type: OrderType::MarketIfTouched,
1942            price: None,
1943            trigger_price: Some(Price::new(180.0, 0)),
1944            limit_offset: None,
1945            trailing_offset: None,
1946            trailing_offset_type: None,
1947        }
1948    )]
1949    #[case(
1950        "LIT",
1951        Some(179.0),
1952        Some(180.0),
1953        None,
1954        None,
1955        PricingExpectation {
1956            order_type: OrderType::LimitIfTouched,
1957            price: Some(Price::new(179.0, 0)),
1958            trigger_price: Some(Price::new(180.0, 0)),
1959            limit_offset: None,
1960            trailing_offset: None,
1961            trailing_offset_type: None,
1962        }
1963    )]
1964    #[case(
1965        "STP",
1966        None,
1967        Some(180.0),
1968        None,
1969        None,
1970        PricingExpectation {
1971            order_type: OrderType::StopMarket,
1972            price: None,
1973            trigger_price: Some(Price::new(180.0, 0)),
1974            limit_offset: None,
1975            trailing_offset: None,
1976            trailing_offset_type: None,
1977        }
1978    )]
1979    #[case(
1980        "STP LMT",
1981        Some(179.0),
1982        Some(180.0),
1983        None,
1984        None,
1985        PricingExpectation {
1986            order_type: OrderType::StopLimit,
1987            price: Some(Price::new(179.0, 0)),
1988            trigger_price: Some(Price::new(180.0, 0)),
1989            limit_offset: None,
1990            trailing_offset: None,
1991            trailing_offset_type: None,
1992        }
1993    )]
1994    #[case(
1995        "TRAIL LIMIT",
1996        None,
1997        Some(2.5),
1998        Some(185.0),
1999        Some(0.25),
2000        PricingExpectation {
2001            order_type: OrderType::TrailingStopLimit,
2002            price: None,
2003            trigger_price: Some(Price::new(185.0, 0)),
2004            limit_offset: Some(Decimal::from_str("0.25").unwrap()),
2005            trailing_offset: Some(Decimal::from_str("2.5").unwrap()),
2006            trailing_offset_type: Some(TrailingOffsetType::Price),
2007        },
2008    )]
2009    fn test_parse_order_status_to_report_maps_pricing_fields_by_order_type(
2010        #[case] ib_order_type: &str,
2011        #[case] limit_price: Option<f64>,
2012        #[case] aux_price: Option<f64>,
2013        #[case] trail_stop_price: Option<f64>,
2014        #[case] limit_price_offset: Option<f64>,
2015        #[case] expected: PricingExpectation,
2016    ) {
2017        let instrument_provider = create_test_instrument_provider();
2018        let instrument_id = create_test_instrument_id();
2019        let account_id = AccountId::from("IB-001");
2020
2021        let order_status = OrderStatus {
2022            order_id: 12345,
2023            status: OrderStatusKind::Submitted,
2024            filled: 0.0,
2025            remaining: 5.0,
2026            average_fill_price: Some(0.0),
2027            perm_id: 0,
2028            parent_id: 0,
2029            last_fill_price: Some(0.0),
2030            client_id: 0,
2031            why_held: String::new(),
2032            market_cap_price: Some(0.0),
2033        };
2034
2035        let order = Order {
2036            action: Action::Buy,
2037            total_quantity: 5.0,
2038            order_type: ib_order_type.to_string(),
2039            limit_price,
2040            aux_price,
2041            trail_stop_price,
2042            limit_price_offset,
2043            tif: ibapi::orders::TimeInForce::GoodTillCanceled,
2044            ..Default::default()
2045        };
2046
2047        let report = parse_order_status_to_report(
2048            &order_status,
2049            Some(&order),
2050            instrument_id,
2051            account_id,
2052            &instrument_provider,
2053            UnixNanos::new(0),
2054        )
2055        .unwrap();
2056
2057        assert_eq!(report.order_type, expected.order_type);
2058        assert_eq!(report.price, expected.price);
2059        assert_eq!(report.trigger_price, expected.trigger_price);
2060        assert_eq!(report.limit_offset, expected.limit_offset);
2061        assert_eq!(report.trailing_offset, expected.trailing_offset);
2062        assert_eq!(report.trailing_offset_type, expected.trailing_offset_type);
2063    }
2064
2065    #[rstest]
2066    fn test_parse_order_status_to_report_maps_trailing_percent_to_basis_points() {
2067        let instrument_provider = create_test_instrument_provider();
2068        let instrument_id = create_test_instrument_id();
2069        let account_id = AccountId::from("IB-001");
2070
2071        let order_status = OrderStatus {
2072            order_id: 12345,
2073            status: OrderStatusKind::Submitted,
2074            filled: 0.0,
2075            remaining: 5.0,
2076            average_fill_price: Some(0.0),
2077            perm_id: 0,
2078            parent_id: 0,
2079            last_fill_price: Some(0.0),
2080            client_id: 0,
2081            why_held: String::new(),
2082            market_cap_price: Some(0.0),
2083        };
2084
2085        let order = Order {
2086            action: Action::Buy,
2087            total_quantity: 5.0,
2088            order_type: "TRAIL".to_string(),
2089            trail_stop_price: Some(185.0),
2090            trailing_percent: Some(2.5),
2091            tif: ibapi::orders::TimeInForce::GoodTillCanceled,
2092            ..Default::default()
2093        };
2094
2095        let report = parse_order_status_to_report(
2096            &order_status,
2097            Some(&order),
2098            instrument_id,
2099            account_id,
2100            &instrument_provider,
2101            UnixNanos::new(0),
2102        )
2103        .unwrap();
2104
2105        assert_eq!(report.order_type, OrderType::TrailingStopMarket);
2106        assert_eq!(report.trigger_price, Some(Price::new(185.0, 0)));
2107        assert_eq!(
2108            report.trailing_offset,
2109            Some(Decimal::from_str("250").unwrap())
2110        );
2111        assert_eq!(
2112            report.trailing_offset_type,
2113            Some(TrailingOffsetType::BasisPoints),
2114        );
2115        assert_eq!(report.limit_offset, None);
2116    }
2117
2118    #[rstest]
2119    fn test_parse_execution_to_fill_report_buy() {
2120        let instrument_provider = create_test_instrument_provider();
2121        let instrument_id = create_test_instrument_id();
2122        let account_id = AccountId::from("IB-001");
2123
2124        let execution = Execution {
2125            order_id: 12345,
2126            client_id: 0,
2127            execution_id: String::from("EXEC-001"),
2128            time: String::from("20230223 00:43:36 Universal"),
2129            account_number: String::new(),
2130            exchange: String::new(),
2131            side: ExecutionSide::Bought,
2132            shares: 100.0,
2133            price: 150.25,
2134            perm_id: 0,
2135            liquidation: 0,
2136            cumulative_quantity: 100.0,
2137            average_price: 150.25,
2138            order_reference: String::from("ORDER-REF-001"),
2139            ev_rule: String::new(),
2140            ev_multiplier: None,
2141            model_code: String::new(),
2142            last_liquidity: Liquidity::None,
2143            pending_price_revision: false,
2144            submitter: String::new(),
2145        };
2146
2147        let contract = Contract::default();
2148        let result = parse_execution_to_fill_report(
2149            &execution,
2150            &contract,
2151            1.0,
2152            "USD",
2153            instrument_id,
2154            account_id,
2155            &instrument_provider,
2156            UnixNanos::new(0),
2157            None, // avg_px
2158        );
2159
2160        // May fail if instrument not in provider, but that's expected
2161        match result {
2162            Err(e) => {
2163                let error_msg = e.to_string();
2164                assert!(
2165                    error_msg.contains("not found") || error_msg.contains("instrument"),
2166                    "Unexpected error: {error_msg}"
2167                );
2168            }
2169            Ok(fill) => {
2170                assert_eq!(fill.order_side, OrderSide::Buy);
2171                assert_eq!(fill.trade_id.to_string(), "EXEC-001");
2172            }
2173        }
2174    }
2175
2176    #[rstest]
2177    fn test_parse_execution_to_fill_report_clamps_only_pending_commission_sentinel() {
2178        let instrument_provider = create_test_instrument_provider();
2179        let instrument = equity_aapl();
2180        let instrument_id = instrument.id();
2181        instrument_provider.insert_test_instrument(InstrumentAny::from(instrument), 265598, 1);
2182        let account_id = AccountId::from("IB-001");
2183        let contract = Contract::default();
2184
2185        for (commission, expected) in [(-1.0, 0.0), (-0.25, -0.25)] {
2186            let execution = Execution {
2187                order_id: 12345,
2188                client_id: 0,
2189                execution_id: format!("EXEC-{commission}"),
2190                time: String::from("20230223 00:43:36 Universal"),
2191                account_number: String::new(),
2192                exchange: String::new(),
2193                side: ExecutionSide::Bought,
2194                shares: 100.0,
2195                price: 150.25,
2196                perm_id: 0,
2197                liquidation: 0,
2198                cumulative_quantity: 100.0,
2199                average_price: 150.25,
2200                order_reference: String::from("ORDER-REF-001"),
2201                ev_rule: String::new(),
2202                ev_multiplier: None,
2203                model_code: String::new(),
2204                last_liquidity: Liquidity::None,
2205                pending_price_revision: false,
2206                submitter: String::new(),
2207            };
2208
2209            let report = parse_execution_to_fill_report(
2210                &execution,
2211                &contract,
2212                commission,
2213                "USD",
2214                instrument_id,
2215                account_id,
2216                &instrument_provider,
2217                UnixNanos::new(0),
2218                None,
2219            )
2220            .unwrap();
2221
2222            assert_eq!(report.commission, Money::new(expected, Currency::USD()));
2223        }
2224    }
2225
2226    #[rstest]
2227    fn test_parse_execution_to_fill_report_sell() {
2228        let instrument_provider = create_test_instrument_provider();
2229        let instrument_id = create_test_instrument_id();
2230        let account_id = AccountId::from("IB-001");
2231
2232        let execution = Execution {
2233            order_id: 12345,
2234            client_id: 0,
2235            execution_id: String::from("EXEC-002"),
2236            time: String::from("20230223 00:43:36 Universal"),
2237            account_number: String::new(),
2238            exchange: String::new(),
2239            side: ExecutionSide::Sold,
2240            shares: 50.0,
2241            price: 151.0,
2242            perm_id: 0,
2243            liquidation: 0,
2244            cumulative_quantity: 50.0,
2245            average_price: 151.0,
2246            order_reference: String::new(),
2247            ev_rule: String::new(),
2248            ev_multiplier: None,
2249            model_code: String::new(),
2250            last_liquidity: Liquidity::None,
2251            pending_price_revision: false,
2252            submitter: String::new(),
2253        };
2254
2255        let contract = Contract::default();
2256        let result = parse_execution_to_fill_report(
2257            &execution,
2258            &contract,
2259            0.5,
2260            "USD",
2261            instrument_id,
2262            account_id,
2263            &instrument_provider,
2264            UnixNanos::new(0),
2265            None, // avg_px
2266        );
2267
2268        // May fail if instrument not in provider, but that's expected
2269        match result {
2270            Err(e) => {
2271                let error_msg = e.to_string();
2272                assert!(
2273                    error_msg.contains("not found") || error_msg.contains("instrument"),
2274                    "Unexpected error: {error_msg}"
2275                );
2276            }
2277            Ok(fill) => {
2278                assert_eq!(fill.order_side, OrderSide::Sell);
2279            }
2280        }
2281    }
2282
2283    fn contingency_report(
2284        client_order_id: Option<&str>,
2285        venue_order_id: &str,
2286    ) -> OrderStatusReport {
2287        OrderStatusReport::new(
2288            AccountId::from("IB-001"),
2289            create_test_instrument_id(),
2290            client_order_id.map(ClientOrderId::from),
2291            VenueOrderId::from(venue_order_id),
2292            OrderSide::Sell.into(),
2293            OrderType::StopMarket,
2294            TimeInForce::Gtc,
2295            NautilusOrderStatus::Accepted,
2296            Quantity::from(1),
2297            Quantity::from(0),
2298            UnixNanos::default(),
2299            UnixNanos::default(),
2300            UnixNanos::default(),
2301            None,
2302        )
2303    }
2304
2305    fn contingency_order(order_id: i32, perm_id: i64, parent_id: i32, oca: &str) -> Order {
2306        Order {
2307            order_id,
2308            perm_id,
2309            client_id: 7,
2310            parent_id,
2311            account: "DU123".to_string(),
2312            oca_group: oca.to_string(),
2313            oca_type: ibapi::orders::OcaType::ReduceWithBlock,
2314            ..Default::default()
2315        }
2316    }
2317
2318    #[rstest]
2319    fn test_link_order_contingencies_restores_bracket_and_oca_group() {
2320        let mut reports = vec![
2321            contingency_report(Some("O-ENTRY"), "PERM-10"),
2322            contingency_report(Some("O-STOP"), "PERM-11"),
2323            contingency_report(None, "PERM-12"),
2324            contingency_report(Some("O-ALONE"), "PERM-13"),
2325        ];
2326        let orders = vec![
2327            contingency_order(1, 10, 0, ""),
2328            contingency_order(2, 11, 1, "EXIT-1"),
2329            contingency_order(3, 12, 1, "EXIT-1"),
2330            contingency_order(4, 13, 99, ""),
2331        ];
2332
2333        link_order_contingencies(&mut reports, &orders);
2334
2335        let links = reports
2336            .iter()
2337            .map(|report| {
2338                (
2339                    report.contingency_type,
2340                    report.linked_order_ids.clone(),
2341                    report.parent_order_id,
2342                )
2343            })
2344            .collect::<Vec<_>>();
2345
2346        assert_eq!(
2347            links,
2348            vec![
2349                (
2350                    Some(ContingencyType::Oto),
2351                    Some(vec![
2352                        ClientOrderId::from("O-STOP"),
2353                        ClientOrderId::from("PERM-12")
2354                    ]),
2355                    None,
2356                ),
2357                (
2358                    Some(ContingencyType::Ouo),
2359                    Some(vec![ClientOrderId::from("PERM-12")]),
2360                    Some(ClientOrderId::from("O-ENTRY")),
2361                ),
2362                (
2363                    Some(ContingencyType::Ouo),
2364                    Some(vec![ClientOrderId::from("O-STOP")]),
2365                    Some(ClientOrderId::from("O-ENTRY")),
2366                ),
2367                (None, None, None),
2368            ]
2369        );
2370    }
2371
2372    #[rstest]
2373    #[case::cancel(ibapi::orders::OcaType::CancelWithBlock, Some(ContingencyType::Oco))]
2374    #[case::reduce_without_block(
2375        ibapi::orders::OcaType::ReduceWithoutBlock,
2376        Some(ContingencyType::Ouo)
2377    )]
2378    #[case::unset(ibapi::orders::OcaType::None, None)]
2379    fn test_link_order_contingencies_maps_oca_type(
2380        #[case] oca_type: ibapi::orders::OcaType,
2381        #[case] expected: Option<ContingencyType>,
2382    ) {
2383        let mut reports = vec![
2384            contingency_report(Some("O-A"), "PERM-20"),
2385            contingency_report(Some("O-B"), "PERM-21"),
2386        ];
2387        let mut orders = vec![
2388            contingency_order(1, 20, 0, "GROUP"),
2389            contingency_order(2, 21, 0, "GROUP"),
2390        ];
2391
2392        for order in &mut orders {
2393            order.oca_type = oca_type;
2394        }
2395
2396        link_order_contingencies(&mut reports, &orders);
2397
2398        assert_eq!(
2399            reports
2400                .iter()
2401                .map(|report| (report.contingency_type, report.linked_order_ids.clone()))
2402                .collect::<Vec<_>>(),
2403            vec![
2404                (expected, expected.map(|_| vec![ClientOrderId::from("O-B")])),
2405                (expected, expected.map(|_| vec![ClientOrderId::from("O-A")])),
2406            ]
2407        );
2408    }
2409
2410    #[rstest]
2411    #[case::iceberg(Some(2), Some(Quantity::from(2)))]
2412    #[case::full_size(Some(5), None)]
2413    #[case::unset(Some(0), None)]
2414    fn test_parse_order_status_to_report_sets_display_qty(
2415        #[case] display_size: Option<i32>,
2416        #[case] expected: Option<Quantity>,
2417    ) {
2418        let instrument_provider = create_test_instrument_provider();
2419
2420        let order = Order {
2421            total_quantity: 5.0,
2422            order_type: "LMT".to_string(),
2423            limit_price: Some(101.0),
2424            display_size,
2425            ..Default::default()
2426        };
2427
2428        let order_status = OrderStatus {
2429            status: OrderStatusKind::Submitted,
2430            remaining: 5.0,
2431            ..Default::default()
2432        };
2433
2434        let report = parse_order_status_to_report(
2435            &order_status,
2436            Some(&order),
2437            create_test_instrument_id(),
2438            AccountId::from("IB-001"),
2439            &instrument_provider,
2440            UnixNanos::new(0),
2441        )
2442        .unwrap();
2443
2444        assert_eq!(report.display_qty, expected);
2445    }
2446
2447    #[rstest]
2448    #[case::completed("20260924 12:02:04 UTC", 1_790_251_324_000_000_000)]
2449    #[case::open("", 7)]
2450    fn test_parse_order_data_to_report_uses_completed_time(
2451        #[case] completed_time: &str,
2452        #[case] expected_ts_last: u64,
2453    ) {
2454        let instrument_provider = create_test_instrument_provider();
2455
2456        let mut data = OrderData {
2457            order_id: 1,
2458            order: Order {
2459                total_quantity: 1.0,
2460                order_type: "MKT".to_string(),
2461                ..Default::default()
2462            },
2463            ..Default::default()
2464        };
2465
2466        data.order_state.completed_time = completed_time.to_string();
2467
2468        let report = parse_order_data_to_report(
2469            &data,
2470            create_test_instrument_id(),
2471            AccountId::from("IB-001"),
2472            &instrument_provider,
2473            UnixNanos::new(7),
2474        )
2475        .unwrap();
2476
2477        assert_eq!(report.ts_last, UnixNanos::from(expected_ts_last));
2478    }
2479}