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