Skip to main content

rustrade_execution/client/ibkr/
execution.rs

1use crate::{
2    order::id::{ClientOrderId, OrderId, StrategyId},
3    trade::{AssetFees, Trade, TradeId, TradeOrigin},
4};
5use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
6use chrono_tz::Tz;
7use fnv::FnvHashMap;
8use ibapi::orders::{CommissionReport, Execution, ExecutionData, ExecutionSide};
9use parking_lot::Mutex;
10use rust_decimal::Decimal;
11use rustrade_instrument::{
12    Side, asset::name::AssetNameExchange, instrument::name::InstrumentNameExchange,
13};
14use smol_str::{SmolStr, format_smolstr};
15use std::{cell::RefCell, collections::hash_map::Entry, sync::Arc};
16use tracing::{debug, warn};
17
18// Thread-local cache for parsed IANA timezones. IB uses per-exchange timezones,
19// but most portfolios only see a handful (US/Eastern, Europe/London, etc.).
20//
21// Note: This cache is per-thread. In the dedicated `ibkr-order-stream` thread
22// spawned by `account_stream`, the cache is fully effective. In `spawn_blocking`
23// contexts (e.g., `fetch_trades`), Tokio's thread pool means each pool thread
24// maintains its own cache — still beneficial but less effective than a single
25// dedicated thread.
26thread_local! {
27    static TZ_CACHE: RefCell<FnvHashMap<SmolStr, Tz>> = RefCell::new(FnvHashMap::default());
28}
29
30/// Buffers IB executions until their commission reports arrive.
31///
32/// IB sends `ExecutionData` and `CommissionReport` as separate events.
33/// This buffer holds executions until we can match them with commissions
34/// to produce complete `Trade` events.
35#[derive(Debug, Clone)]
36pub struct ExecutionBuffer {
37    inner: Arc<Mutex<ExecutionBufferInner>>,
38}
39
40#[derive(Debug, Default)]
41struct ExecutionBufferInner {
42    pending: FnvHashMap<String, PendingExecution>,
43}
44
45#[derive(Debug, Clone)]
46struct PendingExecution {
47    execution: ExecutionData,
48    resolved: ResolvedExecution,
49}
50
51/// Which API clients' executions this client reports, and how it tells its own from theirs.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub(super) struct ExecutionScope {
54    /// This API client's id, which an execution of one of its orders carries.
55    pub(super) api_client_id: i32,
56    /// Whether other clients' executions are reported too:
57    /// [`IbkrConfig::other_clients_fills`](super::IbkrConfig::other_clients_fills).
58    pub(super) other_clients: bool,
59}
60
61impl ExecutionScope {
62    /// The order id and origin `execution`'s trade is reported with, or `None` when it is another
63    /// client's and other clients' are not reported.
64    ///
65    /// An execution IB flags as an IB-initiated liquidation is reported whoever's order it was,
66    /// as [`TradeOrigin::Liquidation`]: it moves the account's position, which no client chose.
67    /// That is why an executions request asks IB for every client's, and this decides which are
68    /// reported.
69    pub(super) fn classify(self, execution: &Execution) -> Option<(OrderId, TradeOrigin)> {
70        let own = execution.client_id == self.api_client_id;
71        let liquidation = execution.liquidation != 0;
72        if !own && !liquidation && !self.other_clients {
73            return None;
74        }
75        let origin = if liquidation {
76            TradeOrigin::Liquidation
77        } else if own {
78            TradeOrigin::Order
79        } else {
80            TradeOrigin::External
81        };
82        let order_id = if own {
83            ib_order_id(execution.order_id)
84        } else {
85            foreign_order_id(execution)
86        };
87        Some((order_id, origin))
88    }
89}
90
91/// What an execution's trade is reported with, besides what the execution itself carries.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub(super) struct ResolvedExecution {
94    pub(super) instrument: InstrumentNameExchange,
95    pub(super) order_id: OrderId,
96    pub(super) origin: TradeOrigin,
97}
98
99impl ExecutionBuffer {
100    pub fn new() -> Self {
101        Self {
102            inner: Arc::new(Mutex::new(ExecutionBufferInner::default())),
103        }
104    }
105
106    /// Buffer an execution, waiting for its commission report.
107    pub(super) fn add_execution(&self, execution: ExecutionData, resolved: ResolvedExecution) {
108        let exec_id = execution.execution.execution_id.clone();
109        let mut inner = self.inner.lock();
110        inner.pending.insert(
111            exec_id,
112            PendingExecution {
113                execution,
114                resolved,
115            },
116        );
117
118        // Warn if buffer is growing unexpectedly large (possible commission report leak)
119        let pending_count = inner.pending.len();
120        if pending_count > 1000 && pending_count.is_multiple_of(100) {
121            warn!(
122                pending_count,
123                "ExecutionBuffer has >1000 pending entries; commission reports may be delayed or lost"
124            );
125        }
126    }
127
128    /// Try to complete a trade with a commission report.
129    /// Returns the completed Trade if the matching execution was buffered.
130    pub fn complete_with_commission(
131        &self,
132        report: &CommissionReport,
133    ) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
134        let pending = {
135            let mut inner = self.inner.lock();
136            inner.pending.remove(&report.execution_id)?
137        };
138
139        Some(build_trade(pending, report))
140    }
141
142    /// Take every pending execution out of the buffer as a trade whose fee is unknown: zero, in
143    /// [`UNKNOWN_FEE_ASSET`].
144    ///
145    /// For a read that has ended, such as one executions request, whose commission reports will
146    /// not arrive in it any more.
147    pub(super) fn take_without_commission(
148        &self,
149    ) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
150        std::mem::take(&mut self.inner.lock().pending)
151            .into_values()
152            .map(|pending| {
153                let fees = AssetFees::new(
154                    AssetNameExchange::from(UNKNOWN_FEE_ASSET),
155                    Decimal::ZERO,
156                    None,
157                );
158                trade_with_fees(pending, fees)
159            })
160            .collect()
161    }
162
163    /// Move every pending execution into `target`, returning how many moved.
164    ///
165    /// Fill recovery buffers what it reads in a buffer of its own, then hands over whatever is
166    /// still waiting for a commission report, so a report that reaches the account stream later
167    /// can complete it there.
168    ///
169    /// Both buffers stay locked for the move, so a commission report the stream reads meanwhile
170    /// finds the execution in one of them. `target` is locked first. The stream locks only its own
171    /// buffer, so the order cannot deadlock against it.
172    pub(super) fn drain_into(&self, target: &ExecutionBuffer) -> usize {
173        let mut target = target.inner.lock();
174        let drained = std::mem::take(&mut self.inner.lock().pending);
175        let count = drained.len();
176        target.pending.extend(drained);
177        count
178    }
179
180    /// Get number of pending executions (for diagnostics).
181    pub fn pending_count(&self) -> usize {
182        self.inner.lock().pending.len()
183    }
184
185    /// Clear stale executions older than the given duration.
186    ///
187    /// Returns number of cleared entries.
188    ///
189    /// # Caller Responsibility
190    ///
191    /// This method is not called automatically. Callers should invoke it
192    /// periodically to prevent unbounded growth if commission reports are
193    /// delayed or lost.
194    pub fn clear_stale(&self, max_age: std::time::Duration) -> usize {
195        let now = Utc::now();
196        let mut inner = self.inner.lock();
197        let before = inner.pending.len();
198
199        let max_age_secs = i64::try_from(max_age.as_secs()).unwrap_or(i64::MAX);
200        inner.pending.retain(|_, pending| {
201            if let Some(exec_time) = parse_ib_timestamp(&pending.execution.execution.time) {
202                let age = now.signed_duration_since(exec_time);
203                age.num_seconds() < max_age_secs
204            } else {
205                // Evict entries with unparseable timestamps to prevent memory leak
206                false
207            }
208        });
209
210        before - inner.pending.len()
211    }
212}
213
214impl Default for ExecutionBuffer {
215    fn default() -> Self {
216        Self::new()
217    }
218}
219
220/// An IB execution id, read as the execution it reports and that execution's revision.
221///
222/// IB reports a correction as a further execution whose id differs from the one it corrects only
223/// in the digits after the final period: `0000e0d5.5f8b1c2a.01.02` corrects
224/// `0000e0d5.5f8b1c2a.01.01`. IB's documentation gives that example rather than a rule, but every
225/// execution IB first reports ends in `01`, so a higher revision is read as a correction.
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub(super) struct ExecutionRevision<'a> {
228    /// The id up to its final period, which every revision of the execution shares.
229    pub(super) execution: &'a str,
230    /// The digits after the final period, kept to write the previous revision's id at the same
231    /// width.
232    digits: &'a str,
233    pub(super) revision: u32,
234}
235
236impl<'a> ExecutionRevision<'a> {
237    /// `None` when `execution_id` does not end in a period followed by digits. Such an id is read
238    /// as an execution that has not been corrected.
239    pub(super) fn parse(execution_id: &'a str) -> Option<Self> {
240        let (execution, digits) = execution_id.rsplit_once('.')?;
241        if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
242            return None;
243        }
244        Some(Self {
245            execution,
246            digits,
247            revision: digits.parse().ok()?,
248        })
249    }
250
251    /// Whether this revision corrects an earlier one.
252    pub(super) fn is_correction(&self) -> bool {
253        self.revision > 1
254    }
255
256    /// The id of the revision this one corrects, or `None` if it is not a correction.
257    pub(super) fn previous_id(&self) -> Option<TradeId> {
258        self.is_correction().then(|| {
259            TradeId(format_smolstr!(
260                "{}.{:0width$}",
261                self.execution,
262                self.revision - 1,
263                width = self.digits.len()
264            ))
265        })
266    }
267}
268
269/// The revision of the execution `id` names, `0` when it names none. Sorting by it puts each
270/// execution ahead of its corrections.
271pub(super) fn revision_of(id: &TradeId) -> u32 {
272    ExecutionRevision::parse(&id.0).map_or(0, |revision| revision.revision)
273}
274
275/// `trades` with each execution only at its latest revision, in the order the executions first
276/// appear, so that a corrected execution is read as corrected rather than twice. An execution
277/// read twice at one revision is kept once.
278pub(super) fn keep_latest_revisions(
279    trades: Vec<Trade<AssetNameExchange, InstrumentNameExchange>>,
280) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
281    // The kept position and revision of each execution with a revision.
282    let mut latest = FnvHashMap::<SmolStr, (usize, u32)>::with_capacity_and_hasher(
283        trades.len(),
284        Default::default(),
285    );
286    let mut kept = Vec::with_capacity(trades.len());
287    for trade in trades {
288        let Some(revision) = ExecutionRevision::parse(&trade.id.0) else {
289            kept.push(trade);
290            continue;
291        };
292        let (execution, revision) = (SmolStr::new(revision.execution), revision.revision);
293        match latest.entry(execution) {
294            Entry::Vacant(entry) => {
295                entry.insert((kept.len(), revision));
296                kept.push(trade);
297            }
298            Entry::Occupied(mut entry) => {
299                let (position, kept_revision) = *entry.get();
300                if revision == kept_revision {
301                    debug!(exec_id = %trade.id, "IBKR execution read twice; keeping one");
302                    continue;
303                }
304                let (older, newer) = if revision > kept_revision {
305                    entry.insert((position, revision));
306                    (
307                        std::mem::replace(&mut kept[position], trade),
308                        &kept[position],
309                    )
310                } else {
311                    (trade, &kept[position])
312                };
313                debug!(
314                    corrected = %older.id,
315                    correction = %newer.id,
316                    "IBKR execution corrected; keeping the correction"
317                );
318            }
319        }
320    }
321    kept
322}
323
324/// The fee asset of a trade whose commission report IB did not send.
325pub const UNKNOWN_FEE_ASSET: &str = "UNKNOWN";
326
327/// The venue order id of IB order `ib_order_id`, as [`Open`](crate::order::state::Open) and every
328/// [`Trade`] of this client carry it.
329pub(super) fn ib_order_id(ib_order_id: i32) -> OrderId {
330    OrderId::new(format_smolstr!("{ib_order_id}"))
331}
332
333/// The venue order id of `execution`, an execution of another client's order.
334///
335/// IB numbers orders per API client, so that client's order id could name one of this client's.
336/// The permanent id IB gives an order does not, and is used where there is one: `perm:` and the
337/// id. An order from outside IB can have none (`0`), and is named by its client and order id
338/// instead: `client:`, the client id, `:` and the order id.
339pub(super) fn foreign_order_id(execution: &Execution) -> OrderId {
340    if execution.perm_id != 0 {
341        OrderId::new(format_smolstr!("perm:{}", execution.perm_id))
342    } else {
343        OrderId::new(format_smolstr!(
344            "client:{}:{}",
345            execution.client_id,
346            execution.order_id
347        ))
348    }
349}
350
351/// Build a rustrade Trade from IB execution + commission data.
352fn build_trade(
353    pending: PendingExecution,
354    commission: &CommissionReport,
355) -> Trade<AssetNameExchange, InstrumentNameExchange> {
356    let commission_amount = parse_decimal_or_warn(commission.commission, "commission");
357    let fees = AssetFees {
358        asset: AssetNameExchange::from(commission.currency.as_str()),
359        fees: commission_amount,
360        fees_quote: None, // Indexer computes based on fee asset vs instrument quote
361    };
362    trade_with_fees(pending, fees)
363}
364
365/// Build a rustrade Trade from an IB execution and its fees.
366fn trade_with_fees(
367    pending: PendingExecution,
368    fees: AssetFees<AssetNameExchange>,
369) -> Trade<AssetNameExchange, InstrumentNameExchange> {
370    let exec = &pending.execution.execution;
371
372    // `ExecutionSide` is a closed two-variant enum in ibapi 3.x (the decoder
373    // rejects unknown wire values upstream), so the mapping is total.
374    let side = match exec.side {
375        ExecutionSide::Bought => Side::Buy,
376        ExecutionSide::Sold => Side::Sell,
377    };
378
379    let price = parse_decimal_or_warn(exec.price, "exec.price");
380    let quantity = parse_decimal_or_warn(exec.shares, "exec.shares");
381
382    let time_exchange = parse_ib_timestamp(&exec.time).unwrap_or_else(Utc::now);
383
384    let ResolvedExecution {
385        instrument,
386        order_id,
387        origin,
388    } = pending.resolved;
389    Trade {
390        id: TradeId::new(&exec.execution_id),
391        // For this client's order, the id its `Open` state carries, which is what a fill is
392        // matched against.
393        order_id,
394        instrument,
395        strategy: StrategyId::unknown(),
396        time_exchange,
397        side,
398        price,
399        quantity,
400        // IB reports the order's running total on every execution, so the order advances from the
401        // fill itself rather than waiting on the next `OrderStatus`.
402        order_filled_quantity: Some(parse_decimal_or_warn(
403            exec.cumulative_quantity,
404            "exec.cumulative_quantity",
405        )),
406        fees,
407        origin,
408        position_side: None,
409        // The order reference: this client places each order with its client order id there.
410        // Another client's order carries whatever reference that client gave it, if any.
411        client_order_id: (!exec.order_reference.is_empty())
412            .then(|| ClientOrderId::new(exec.order_reference.as_str())),
413    }
414}
415
416/// Convert f64 to Decimal, logging a warning if conversion fails (NaN/Inf).
417///
418/// Returns `Decimal::ZERO` for invalid values. This is acceptable because:
419/// - IB's API should never return NaN/Inf for prices, quantities, or commissions
420/// - If it does, something is fundamentally broken and the warning log surfaces it
421/// - Callers processing trades in bulk shouldn't abort on one corrupted record
422///
423/// Where zero would be read as a real value, such as an ended order's fill, use
424/// [`try_decimal_or_warn`] and keep it unknown instead.
425pub fn parse_decimal_or_warn(value: f64, field_name: impl std::fmt::Display) -> Decimal {
426    try_decimal_or_warn(value, field_name).unwrap_or(Decimal::ZERO)
427}
428
429/// Convert an IB `f64` to a `Decimal`: `None`, with a warning, when it is not a finite number
430/// that fits, so that a caller can keep it unknown rather than read it as zero.
431///
432/// `field_name` names the value in the warning; a `format_args!` can add context, such as the
433/// order, without formatting it unless the warning fires.
434pub fn try_decimal_or_warn(value: f64, field_name: impl std::fmt::Display) -> Option<Decimal> {
435    Decimal::try_from(value)
436        .map_err(
437            |e| warn!(field = %field_name, value = %value, error = %e, "Invalid f64 for Decimal"),
438        )
439        .ok()
440}
441
442/// Parse IB timestamp format (YYYYMMDD HH:MM:SS timezone).
443///
444/// IB sends timestamps like "20250418 10:30:00 US/Eastern". This function
445/// parses the timezone and converts to UTC.
446///
447/// # Fallback Behavior
448///
449/// - Unknown timezone string: treats as UTC (logs warning)
450/// - DST-ambiguous time (during "fall back" transition): returns `None`, caller
451///   typically falls back to `Utc::now()`. This affects ~1 second per timezone
452///   per year and is unlikely to occur in practice.
453pub fn parse_ib_timestamp(s: &str) -> Option<DateTime<Utc>> {
454    // Use iterator to avoid Vec allocation
455    let mut parts = s.split_whitespace();
456    let date_part = parts.next()?;
457    let time_part = parts.next()?;
458    let tz_part = parts.next();
459
460    // Find the space between date and time by byte offset to avoid format! allocation
461    let datetime_end = date_part.len() + 1 + time_part.len();
462    let datetime_str = &s[..datetime_end.min(s.len())];
463
464    let naive = NaiveDateTime::parse_from_str(datetime_str, "%Y%m%d %H:%M:%S").ok()?;
465
466    // Try to parse timezone, fall back to UTC
467    if let Some(tz_str) = tz_part {
468        // Use cached timezone to avoid re-parsing IANA database on every call
469        let tz_opt = TZ_CACHE.with(|cache| {
470            let mut cache = cache.borrow_mut();
471            if let Some(tz) = cache.get(tz_str) {
472                return Some(*tz);
473            }
474            if let Ok(tz) = tz_str.parse::<Tz>() {
475                cache.insert(SmolStr::new(tz_str), tz);
476                return Some(tz);
477            }
478            None
479        });
480
481        if let Some(tz) = tz_opt {
482            return tz
483                .from_local_datetime(&naive)
484                .single()
485                .map(|dt| dt.with_timezone(&Utc));
486        }
487        warn!(timezone = %tz_str, "Unknown timezone in IB timestamp, treating as UTC");
488    }
489
490    Some(naive.and_utc())
491}
492
493#[cfg(test)]
494#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
495mod tests {
496    use super::*;
497    use chrono::{Datelike, Timelike};
498
499    #[test]
500    fn test_parse_ib_timestamp_with_timezone() {
501        // US/Eastern is UTC-4 during daylight saving time (April)
502        let ts = parse_ib_timestamp("20250418 10:30:00 US/Eastern");
503        assert!(ts.is_some());
504        let dt = ts.unwrap();
505        assert_eq!(dt.year(), 2025);
506        assert_eq!(dt.month(), 4);
507        assert_eq!(dt.day(), 18);
508        // 10:30 Eastern = 14:30 UTC (EDT is UTC-4)
509        assert_eq!(dt.hour(), 14);
510        assert_eq!(dt.minute(), 30);
511    }
512
513    #[test]
514    fn test_parse_ib_timestamp_no_timezone() {
515        // Without timezone, treat as UTC
516        let ts = parse_ib_timestamp("20250418 10:30:00");
517        assert!(ts.is_some());
518        let dt = ts.unwrap();
519        assert_eq!(dt.hour(), 10);
520    }
521
522    /// A fill reports the order reference as its client order id: this client places each order
523    /// with its client order id there. An order placed without one reports none.
524    #[test]
525    fn a_fill_carries_the_order_reference_as_its_client_order_id() {
526        let buffer = ExecutionBuffer::new();
527        for (exec_id, reference) in [("a", "outside-1"), ("b", "")] {
528            buffer.add_execution(
529                ExecutionData {
530                    execution: ibapi::orders::Execution {
531                        execution_id: exec_id.to_string(),
532                        order_reference: reference.to_string(),
533                        ..Default::default()
534                    },
535                    ..Default::default()
536                },
537                ResolvedExecution {
538                    instrument: InstrumentNameExchange::new("AAPL"),
539                    order_id: ib_order_id(1),
540                    origin: TradeOrigin::Order,
541                },
542            );
543        }
544
545        let mut trades = buffer.take_without_commission();
546        trades.sort_by(|a, b| a.id.cmp(&b.id));
547        assert_eq!(
548            trades[0].client_order_id,
549            Some(ClientOrderId::new("outside-1"))
550        );
551        assert_eq!(trades[1].client_order_id, None);
552    }
553
554    #[test]
555    fn drain_into_moves_everything_and_keeps_the_target() {
556        let execution = |exec_id: &str| ExecutionData {
557            execution: ibapi::orders::Execution {
558                execution_id: exec_id.to_string(),
559                ..Default::default()
560            },
561            ..Default::default()
562        };
563        let add = |buffer: &ExecutionBuffer, exec_id: &str| {
564            buffer.add_execution(
565                execution(exec_id),
566                ResolvedExecution {
567                    instrument: InstrumentNameExchange::new("AAPL"),
568                    order_id: ib_order_id(1),
569                    origin: TradeOrigin::Order,
570                },
571            );
572        };
573        let source = ExecutionBuffer::new();
574        let target = ExecutionBuffer::new();
575        add(&source, "a");
576        add(&source, "b");
577        add(&target, "c");
578
579        assert_eq!(source.drain_into(&target), 2);
580        assert_eq!(source.pending_count(), 0);
581        assert_eq!(target.pending_count(), 3);
582    }
583
584    #[test]
585    fn execution_revision_reads_the_digits_after_the_final_period() {
586        let original = ExecutionRevision::parse("0000e0d5.5f8b1c2a.01.01").unwrap();
587        assert_eq!(original.execution, "0000e0d5.5f8b1c2a.01");
588        assert_eq!(original.revision, 1);
589        assert!(!original.is_correction());
590        assert_eq!(original.previous_id(), None);
591
592        let correction = ExecutionRevision::parse("0000e0d5.5f8b1c2a.01.02").unwrap();
593        assert_eq!(correction.execution, "0000e0d5.5f8b1c2a.01");
594        assert!(correction.is_correction());
595        assert_eq!(
596            correction.previous_id(),
597            Some(TradeId::new("0000e0d5.5f8b1c2a.01.01"))
598        );
599
600        // The previous revision keeps the width of the digits.
601        assert_eq!(
602            ExecutionRevision::parse("x.10").unwrap().previous_id(),
603            Some(TradeId::new("x.09"))
604        );
605        assert_eq!(
606            ExecutionRevision::parse("x.3").unwrap().previous_id(),
607            Some(TradeId::new("x.2"))
608        );
609    }
610
611    #[test]
612    fn execution_id_without_a_revision_is_never_a_correction() {
613        for id in ["e1", "abc.", "abc.0x", "abc.1a", "", ".", "abc.99999999999"] {
614            assert_eq!(ExecutionRevision::parse(id), None, "{id:?}");
615        }
616        assert_eq!(revision_of(&TradeId::new("e1")), 0);
617        assert_eq!(revision_of(&TradeId::new("a.b.02")), 2);
618    }
619
620    fn trade(id: &str, price: i64) -> Trade<AssetNameExchange, InstrumentNameExchange> {
621        Trade {
622            id: TradeId::new(id),
623            order_id: crate::order::id::OrderId::new("cid"),
624            instrument: InstrumentNameExchange::new("AAPL"),
625            strategy: StrategyId::unknown(),
626            time_exchange: DateTime::<Utc>::MIN_UTC,
627            side: Side::Buy,
628            price: Decimal::from(price),
629            quantity: Decimal::ONE,
630            order_filled_quantity: Some(Decimal::ONE),
631            fees: AssetFees::new(AssetNameExchange::from("USD"), Decimal::ZERO, None),
632            origin: TradeOrigin::Order,
633            position_side: None,
634            client_order_id: None,
635        }
636    }
637
638    #[test]
639    fn keep_latest_revisions_reads_a_corrected_execution_once() {
640        let kept = keep_latest_revisions(vec![
641            trade("a.01.01", 100),
642            trade("b.01.01", 200),
643            trade("a.01.02", 101),
644            trade("a.01.02", 101),
645            trade("plain", 300),
646            // An earlier revision after a later one is still the earlier one.
647            trade("b.01.03", 202),
648            trade("b.01.02", 201),
649        ]);
650        let read: Vec<_> = kept
651            .iter()
652            .map(|trade| (trade.id.0.as_str(), trade.price))
653            .collect();
654        assert_eq!(
655            read,
656            [
657                ("a.01.02", Decimal::from(101)),
658                ("b.01.03", Decimal::from(202)),
659                ("plain", Decimal::from(300)),
660            ]
661        );
662    }
663
664    #[test]
665    fn test_parse_ib_timestamp_invalid() {
666        assert!(parse_ib_timestamp("invalid").is_none());
667        assert!(parse_ib_timestamp("").is_none());
668    }
669}