Skip to main content

rustrade_execution/client/ibkr/
execution.rs

1use crate::{
2    order::id::{ClientOrderId, OrderId, StrategyId},
3    trade::{AssetFees, TimeSource, Trade, TradeId, TradeOrigin},
4};
5use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
6use chrono_tz::Tz;
7use fnv::FnvHashMap;
8use ibapi::orders::{CommissionReport, Execution, ExecutionData, ExecutionFilter, 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, time::Duration};
16use tracing::{debug, warn};
17
18// Thread-local cache of the zones IB's times name, as resolved, or `None` for a name that is not a
19// tz database name, so that one is warned of once per thread rather than on every time. IB names
20// the gateway host's zone, so a gateway's times usually name one.
21//
22// Note: This cache is per-thread. In the dedicated `ibkr-order-stream` thread
23// spawned by `account_stream`, the cache is fully effective. In `spawn_blocking`
24// contexts (e.g., `fetch_trades`), Tokio's thread pool means each pool thread
25// maintains its own cache — still beneficial but less effective than a single
26// dedicated thread.
27thread_local! {
28    static TZ_CACHE: RefCell<FnvHashMap<SmolStr, Option<Tz>>> =
29        RefCell::new(FnvHashMap::default());
30}
31
32/// Buffers IB executions until their commission reports arrive.
33///
34/// IB sends `ExecutionData` and `CommissionReport` as separate events.
35/// This buffer holds executions until we can match them with commissions
36/// to produce complete `Trade` events.
37#[derive(Debug, Clone)]
38pub struct ExecutionBuffer {
39    inner: Arc<Mutex<ExecutionBufferInner>>,
40}
41
42#[derive(Debug, Default)]
43struct ExecutionBufferInner {
44    pending: FnvHashMap<String, PendingExecution>,
45}
46
47#[derive(Debug, Clone)]
48struct PendingExecution {
49    execution: ExecutionData,
50    resolved: ResolvedExecution,
51    /// This host's time when the execution was buffered, which ages it in
52    /// [`ExecutionBuffer::clear_stale`] when its own time does not read.
53    buffered_at: DateTime<Utc>,
54}
55
56/// Which API clients' executions this client reports, and how it tells its own from theirs.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub(super) struct ExecutionScope {
59    /// This API client's id, which an execution of one of its orders carries.
60    pub(super) api_client_id: i32,
61    /// Whether other clients' executions are reported too:
62    /// [`IbkrConfig::other_clients_fills`](super::IbkrConfig::other_clients_fills).
63    pub(super) other_clients: bool,
64}
65
66impl ExecutionScope {
67    /// The order id and origin `execution`'s trade is reported with, or `None` when it is another
68    /// client's and other clients' are not reported.
69    ///
70    /// An execution IB flags as an IB-initiated liquidation is reported whoever's order it was,
71    /// as [`TradeOrigin::Liquidation`]: it moves the account's position, which no client chose.
72    /// That is why an executions request asks IB for every client's, and this decides which are
73    /// reported.
74    pub(super) fn classify(self, execution: &Execution) -> Option<(OrderId, TradeOrigin)> {
75        let own = execution.client_id == self.api_client_id;
76        let liquidation = execution.liquidation != 0;
77        if !own && !liquidation && !self.other_clients {
78            return None;
79        }
80        let origin = if liquidation {
81            TradeOrigin::Liquidation
82        } else if own {
83            TradeOrigin::Order
84        } else {
85            TradeOrigin::External
86        };
87        let order_id = if own {
88            ib_order_id(execution.order_id)
89        } else {
90            foreign_order_id(execution)
91        };
92        Some((order_id, origin))
93    }
94}
95
96/// What an execution's trade is reported with, besides what the execution itself carries.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub(super) struct ResolvedExecution {
99    pub(super) instrument: InstrumentNameExchange,
100    pub(super) order_id: OrderId,
101    pub(super) origin: TradeOrigin,
102    /// The execution's time, read by [`parse_ib_timestamp`] in the gateway's session zone, or
103    /// `None` where it does not read.
104    pub(super) time: Option<DateTime<Utc>>,
105}
106
107impl ExecutionBuffer {
108    pub fn new() -> Self {
109        Self {
110            inner: Arc::new(Mutex::new(ExecutionBufferInner::default())),
111        }
112    }
113
114    /// Buffer an execution, waiting for its commission report.
115    pub(super) fn add_execution(&self, execution: ExecutionData, resolved: ResolvedExecution) {
116        let exec_id = execution.execution.execution_id.clone();
117        let mut inner = self.inner.lock();
118        inner.pending.insert(
119            exec_id,
120            PendingExecution {
121                execution,
122                resolved,
123                buffered_at: Utc::now(),
124            },
125        );
126
127        // Warn if buffer is growing unexpectedly large (possible commission report leak)
128        let pending_count = inner.pending.len();
129        if pending_count > 1000 && pending_count.is_multiple_of(100) {
130            warn!(
131                pending_count,
132                "ExecutionBuffer has >1000 pending entries; commission reports may be delayed or lost"
133            );
134        }
135    }
136
137    /// Try to complete a trade with a commission report.
138    /// Returns the completed Trade if the matching execution was buffered.
139    pub fn complete_with_commission(
140        &self,
141        report: &CommissionReport,
142    ) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
143        let pending = {
144            let mut inner = self.inner.lock();
145            inner.pending.remove(&report.execution_id)?
146        };
147
148        Some(build_trade(pending, report))
149    }
150
151    /// Take every pending execution out of the buffer as a trade whose fee is unknown: zero, in
152    /// [`UNKNOWN_FEE_ASSET`].
153    ///
154    /// For a read that has ended, such as one executions request, whose commission reports will
155    /// not arrive in it any more.
156    pub(super) fn take_without_commission(
157        &self,
158    ) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
159        std::mem::take(&mut self.inner.lock().pending)
160            .into_values()
161            .map(|pending| {
162                let fees = AssetFees::new(
163                    AssetNameExchange::from(UNKNOWN_FEE_ASSET),
164                    Decimal::ZERO,
165                    None,
166                );
167                trade_with_fees(pending, fees)
168            })
169            .collect()
170    }
171
172    /// Move every pending execution into `target`, returning how many moved.
173    ///
174    /// Fill recovery buffers what it reads in a buffer of its own, then hands over whatever is
175    /// still waiting for a commission report, so a report that reaches the account stream later
176    /// can complete it there.
177    ///
178    /// Both buffers stay locked for the move, so a commission report the stream reads meanwhile
179    /// finds the execution in one of them. `target` is locked first. The stream locks only its own
180    /// buffer, so the order cannot deadlock against it.
181    pub(super) fn drain_into(&self, target: &ExecutionBuffer) -> usize {
182        let mut target = target.inner.lock();
183        let drained = std::mem::take(&mut self.inner.lock().pending);
184        let count = drained.len();
185        target.pending.extend(drained);
186        count
187    }
188
189    /// Get number of pending executions (for diagnostics).
190    pub fn pending_count(&self) -> usize {
191        self.inner.lock().pending.len()
192    }
193
194    /// Clear stale executions older than the given duration.
195    ///
196    /// An execution is aged by its own time, or, when that does not read, by when it was
197    /// buffered, so one waiting on its commission report is not cleared for its time alone.
198    ///
199    /// Returns number of cleared entries.
200    ///
201    /// # Caller Responsibility
202    ///
203    /// This method is not called automatically. Callers should invoke it
204    /// periodically to prevent unbounded growth if commission reports are
205    /// delayed or lost.
206    pub fn clear_stale(&self, max_age: std::time::Duration) -> usize {
207        let now = Utc::now();
208        let mut inner = self.inner.lock();
209        let before = inner.pending.len();
210
211        let max_age_secs = i64::try_from(max_age.as_secs()).unwrap_or(i64::MAX);
212        inner.pending.retain(|_, pending| {
213            let time = pending.resolved.time.unwrap_or(pending.buffered_at);
214            now.signed_duration_since(time).num_seconds() < max_age_secs
215        });
216
217        before - inner.pending.len()
218    }
219}
220
221impl Default for ExecutionBuffer {
222    fn default() -> Self {
223        Self::new()
224    }
225}
226
227/// An IB execution id, read as the execution it reports and that execution's revision.
228///
229/// IB reports a correction as a further execution whose id differs from the one it corrects only
230/// in the digits after the final period: `0000e0d5.5f8b1c2a.01.02` corrects
231/// `0000e0d5.5f8b1c2a.01.01`. IB's documentation gives that example rather than a rule, but every
232/// execution IB first reports ends in `01`, so a higher revision is read as a correction.
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub(super) struct ExecutionRevision<'a> {
235    /// The id up to its final period, which every revision of the execution shares.
236    pub(super) execution: &'a str,
237    /// The digits after the final period, kept to write the previous revision's id at the same
238    /// width.
239    digits: &'a str,
240    pub(super) revision: u32,
241}
242
243impl<'a> ExecutionRevision<'a> {
244    /// `None` when `execution_id` does not end in a period followed by digits. Such an id is read
245    /// as an execution that has not been corrected.
246    pub(super) fn parse(execution_id: &'a str) -> Option<Self> {
247        let (execution, digits) = execution_id.rsplit_once('.')?;
248        if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
249            return None;
250        }
251        Some(Self {
252            execution,
253            digits,
254            revision: digits.parse().ok()?,
255        })
256    }
257
258    /// Whether this revision corrects an earlier one.
259    pub(super) fn is_correction(&self) -> bool {
260        self.revision > 1
261    }
262
263    /// The id of the revision this one corrects, or `None` if it is not a correction.
264    pub(super) fn previous_id(&self) -> Option<TradeId> {
265        self.is_correction().then(|| {
266            TradeId(format_smolstr!(
267                "{}.{:0width$}",
268                self.execution,
269                self.revision - 1,
270                width = self.digits.len()
271            ))
272        })
273    }
274}
275
276/// The revision of the execution `id` names, `0` when it names none. Sorting by it puts each
277/// execution ahead of its corrections.
278pub(super) fn revision_of(id: &TradeId) -> u32 {
279    ExecutionRevision::parse(&id.0).map_or(0, |revision| revision.revision)
280}
281
282/// `trades` with each execution only at its latest revision, in the order the executions first
283/// appear, so that a corrected execution is read as corrected rather than twice. An execution
284/// read twice at one revision is kept once.
285pub(super) fn keep_latest_revisions(
286    trades: Vec<Trade<AssetNameExchange, InstrumentNameExchange>>,
287) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
288    // The kept position and revision of each execution with a revision.
289    let mut latest = FnvHashMap::<SmolStr, (usize, u32)>::with_capacity_and_hasher(
290        trades.len(),
291        Default::default(),
292    );
293    let mut kept = Vec::with_capacity(trades.len());
294    for trade in trades {
295        let Some(revision) = ExecutionRevision::parse(&trade.id.0) else {
296            kept.push(trade);
297            continue;
298        };
299        let (execution, revision) = (SmolStr::new(revision.execution), revision.revision);
300        match latest.entry(execution) {
301            Entry::Vacant(entry) => {
302                entry.insert((kept.len(), revision));
303                kept.push(trade);
304            }
305            Entry::Occupied(mut entry) => {
306                let (position, kept_revision) = *entry.get();
307                if revision == kept_revision {
308                    debug!(exec_id = %trade.id, "IBKR execution read twice; keeping one");
309                    continue;
310                }
311                let (older, newer) = if revision > kept_revision {
312                    entry.insert((position, revision));
313                    (
314                        std::mem::replace(&mut kept[position], trade),
315                        &kept[position],
316                    )
317                } else {
318                    (trade, &kept[position])
319                };
320                debug!(
321                    corrected = %older.id,
322                    correction = %newer.id,
323                    "IBKR execution corrected; keeping the correction"
324                );
325            }
326        }
327    }
328    kept
329}
330
331/// The fee asset of a trade whose commission report IB did not send.
332///
333/// No instrument implies it, so the indexer keeps such a trade with its fee zeroed and warns
334/// (see [`AccountEventIndexer::trade`](crate::indexer::AccountEventIndexer::trade)).
335pub const UNKNOWN_FEE_ASSET: &str = "UNKNOWN";
336
337/// The venue order id of IB order `ib_order_id`, as [`Open`](crate::order::state::Open) and every
338/// [`Trade`] of this client carry it.
339pub(super) fn ib_order_id(ib_order_id: i32) -> OrderId {
340    OrderId::new(format_smolstr!("{ib_order_id}"))
341}
342
343/// The venue order id of `execution`, an execution of another client's order.
344///
345/// IB numbers orders per API client, so that client's order id could name one of this client's.
346/// The permanent id IB gives an order does not, and is used where there is one: `perm:` and the
347/// id. An order from outside IB can have none (`0`), and is named by its client and order id
348/// instead: `client:`, the client id, `:` and the order id.
349pub(super) fn foreign_order_id(execution: &Execution) -> OrderId {
350    if execution.perm_id != 0 {
351        OrderId::new(format_smolstr!("perm:{}", execution.perm_id))
352    } else {
353        OrderId::new(format_smolstr!(
354            "client:{}:{}",
355            execution.client_id,
356            execution.order_id
357        ))
358    }
359}
360
361/// Build a rustrade Trade from IB execution + commission data.
362fn build_trade(
363    pending: PendingExecution,
364    commission: &CommissionReport,
365) -> Trade<AssetNameExchange, InstrumentNameExchange> {
366    let commission_amount = parse_decimal_or_warn(commission.commission, "commission");
367    let fees = AssetFees {
368        asset: AssetNameExchange::from(commission.currency.as_str()),
369        fees: commission_amount,
370        fees_quote: None, // `AccountEventIndexer` fills this in for a fee in base or quote
371    };
372    trade_with_fees(pending, fees)
373}
374
375/// Build a rustrade Trade from an IB execution and its fees.
376fn trade_with_fees(
377    pending: PendingExecution,
378    fees: AssetFees<AssetNameExchange>,
379) -> Trade<AssetNameExchange, InstrumentNameExchange> {
380    let exec = &pending.execution.execution;
381
382    // `ExecutionSide` is a closed two-variant enum in ibapi 3.x (the decoder
383    // rejects unknown wire values upstream), so the mapping is total.
384    let side = match exec.side {
385        ExecutionSide::Bought => Side::Buy,
386        ExecutionSide::Sold => Side::Sell,
387    };
388
389    let price = parse_decimal_or_warn(exec.price, "exec.price");
390    let quantity = parse_decimal_or_warn(exec.shares, "exec.shares");
391
392    let ResolvedExecution {
393        instrument,
394        order_id,
395        origin,
396        time,
397    } = pending.resolved;
398    // Kept without a time it can read, rather than lost: stamped with this host's clock, and
399    // marked so.
400    let (time_exchange, time_source) = match time {
401        Some(time) => (time, TimeSource::Venue),
402        None => {
403            warn!(exec_id = %exec.execution_id, time = %exec.time, "IBKR execution time unreadable, using now");
404            (Utc::now(), TimeSource::Host)
405        }
406    };
407    Trade {
408        id: TradeId::new(&exec.execution_id),
409        // For this client's order, the id its `Open` state carries, which is what a fill is
410        // matched against.
411        order_id,
412        instrument,
413        strategy: StrategyId::unknown(),
414        time_exchange,
415        side,
416        price,
417        quantity,
418        // IB reports the order's running total on every execution, so the order advances from the
419        // fill itself rather than waiting on the next `OrderStatus`.
420        order_filled_quantity: Some(parse_decimal_or_warn(
421            exec.cumulative_quantity,
422            "exec.cumulative_quantity",
423        )),
424        fees,
425        origin,
426        position_side: None,
427        // The order reference: this client places each order with its client order id there.
428        // Another client's order carries whatever reference that client gave it, if any.
429        client_order_id: (!exec.order_reference.is_empty())
430            .then(|| ClientOrderId::new(exec.order_reference.as_str())),
431        time_source,
432    }
433}
434
435/// Convert f64 to Decimal, logging a warning if conversion fails (NaN/Inf).
436///
437/// Returns `Decimal::ZERO` for invalid values. This is acceptable because:
438/// - IB's API should never return NaN/Inf for prices, quantities, or commissions
439/// - If it does, something is fundamentally broken and the warning log surfaces it
440/// - Callers processing trades in bulk shouldn't abort on one corrupted record
441///
442/// Where zero would be read as a real value, such as an ended order's fill, use
443/// [`try_decimal_or_warn`] and keep it unknown instead.
444pub fn parse_decimal_or_warn(value: f64, field_name: impl std::fmt::Display) -> Decimal {
445    try_decimal_or_warn(value, field_name).unwrap_or(Decimal::ZERO)
446}
447
448/// Convert an IB `f64` to a `Decimal`: `None`, with a warning, when it is not a finite number
449/// that fits, so that a caller can keep it unknown rather than read it as zero.
450///
451/// `field_name` names the value in the warning; a `format_args!` can add context, such as the
452/// order, without formatting it unless the warning fires.
453pub fn try_decimal_or_warn(value: f64, field_name: impl std::fmt::Display) -> Option<Decimal> {
454    Decimal::try_from(value)
455        .map_err(
456            |e| warn!(field = %field_name, value = %value, error = %e, "Invalid f64 for Decimal"),
457        )
458        .ok()
459}
460
461/// Reads IB's text time, as on an execution or a completed order, as an instant.
462///
463/// IB writes such a time as a wall-clock reading and the zone it is in, such as
464/// `20261027 12:48:41 Asia/Singapore`, where the zone is the gateway host's, or as
465/// `20261027-04:48:41`, which is UTC.
466///
467/// - **The zone is a tz database name**, such as `Asia/Singapore`, or one of its aliases, such as
468///   `US/Eastern` or `MET`.
469/// - **A time that names no zone is read in `session_zone`**, the gateway's zone from the
470///   connection handshake (see [`IbkrClient::session_zone`](super::IbkrClient::session_zone)).
471///   Without one, it is `None`. A time that names a zone is read in it, whatever
472///   `session_zone` is.
473/// - **A zone that is not a tz database name is `None`**, with a `warn!` the first time on each
474///   thread. Read in a guessed zone, such as UTC, the instant would be off by the zone's offset,
475///   possibly by hours.
476/// - **A reading in a repeated hour**, as clocks go back, is the earlier of its two instants. One
477///   in a skipped hour, as clocks go forward, never showed on a clock, and is `None`.
478/// - Anything else that does not read as one of these forms is `None`, including the `-` form
479///   followed by a zone.
480pub fn parse_ib_timestamp(s: &str, session_zone: Option<Tz>) -> Option<DateTime<Utc>> {
481    let s = s.trim();
482    let (date, rest) = s
483        .split_once(char::is_whitespace)
484        .map_or((s, ""), |(date, rest)| (date, rest.trim_start()));
485    if let Some((date, time)) = date.split_once('-') {
486        return rest
487            .is_empty()
488            .then(|| naive_ib_time(date, time))
489            .flatten()
490            .map(|naive| naive.and_utc());
491    }
492    let (time, zone) = rest
493        .split_once(char::is_whitespace)
494        .map_or((rest, None), |(time, zone)| (time, Some(zone.trim())));
495    let naive = naive_ib_time(date, time)?;
496    let zone = match zone {
497        Some(name) => resolve_zone(name)?,
498        None => session_zone?,
499    };
500    zone.from_local_datetime(&naive)
501        .earliest()
502        .map(|time| time.with_timezone(&Utc))
503}
504
505/// `date` (`YYYYMMDD`) and `time` (`HH:MM:SS`) as a wall-clock reading.
506fn naive_ib_time(date: &str, time: &str) -> Option<NaiveDateTime> {
507    Some(NaiveDateTime::new(
508        NaiveDate::parse_from_str(date, "%Y%m%d").ok()?,
509        NaiveTime::parse_from_str(time, "%H:%M:%S").ok()?,
510    ))
511}
512
513/// The tz database zone `name` names, or `None`, with a `warn!` the first time on this thread.
514fn resolve_zone(name: &str) -> Option<Tz> {
515    if let Some(zone) = TZ_CACHE.with(|cache| cache.borrow().get(name).copied()) {
516        return zone;
517    }
518    let zone = name.parse::<Tz>().ok();
519    if zone.is_none() {
520        warn!(
521            timezone = name,
522            "Unknown time zone in IB times; not reading them"
523        );
524    }
525    TZ_CACHE.with(|cache| cache.borrow_mut().insert(SmolStr::new(name), zone));
526    zone
527}
528
529/// The gateway's zone, as `ibapi` read it from the connection handshake, as a `chrono_tz` zone:
530/// `None` when `ibapi` did not recognise it (it warns at connect), or with a `warn!` when
531/// `chrono_tz` does not know its name.
532pub(super) fn session_zone(client: &ibapi::client::blocking::Client) -> Option<Tz> {
533    client.time_zone().and_then(chrono_zone)
534}
535
536/// `zone`, a zone `ibapi` resolved, as the `chrono_tz` zone of its name, or `None` with a
537/// `warn!` when `chrono_tz` does not know the name.
538fn chrono_zone(zone: &time_tz::Tz) -> Option<Tz> {
539    let name = time_tz::TimeZone::name(zone);
540    name.parse()
541        .inspect_err(|_| {
542            warn!(
543                timezone = name,
544                "IB Gateway's time zone unknown to chrono-tz"
545            )
546        })
547        .ok()
548}
549
550/// How many days of executions a read asks IB for. With no day count, IB lists only the current
551/// day's, so a read across midnight would miss the fills before it.
552pub(super) const EXECUTION_DAYS: i32 = 7;
553
554/// How far back a read of [`EXECUTION_DAYS`] days of executions surely reaches. Two days short of
555/// the read: IB counts its days in a time zone of its own, whose day can start up to a day after
556/// this host's and shifts by an hour across daylight saving, and whether its count takes in today
557/// is not documented. A paper account listed the previous day's fills for a count of 2 and not
558/// for 1, so in practice the count takes in today.
559pub(super) const EXECUTIONS_COVER: Duration = Duration::from_secs(5 * 24 * 60 * 60);
560
561/// The filter for a read of [`EXECUTION_DAYS`] days of every client's executions.
562pub(super) fn executions_filter() -> ExecutionFilter {
563    ExecutionFilter {
564        last_n_days: EXECUTION_DAYS,
565        ..ExecutionFilter::default()
566    }
567}
568
569/// Where IB's retention cuts into a span from `start` read at `now`: the start of
570/// [`EXECUTIONS_COVER`], when the span starts before it. Fills before it may still be listed,
571/// but are not sure to be.
572pub(super) fn executions_held_since(
573    start: DateTime<Utc>,
574    now: DateTime<Utc>,
575) -> Option<DateTime<Utc>> {
576    let covered_from = now - EXECUTIONS_COVER;
577    (start < covered_from).then_some(covered_from)
578}
579
580#[cfg(test)]
581#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
582mod tests {
583    use super::*;
584
585    fn utc(time: &str) -> DateTime<Utc> {
586        DateTime::parse_from_rfc3339(time).unwrap().to_utc()
587    }
588
589    /// A time is read in the zone it names, a tz database name or one of its aliases, whatever
590    /// the session's zone.
591    #[test]
592    fn an_ib_time_is_read_in_the_zone_it_names() {
593        let session = Some(chrono_tz::Asia::Tokyo);
594        for (time, expected) in [
595            ("20261027 12:48:41 Asia/Singapore", "2026-10-27T04:48:41Z"),
596            // Daylight saving time: UTC-4 in April, UTC-5 in December.
597            ("20250418 10:30:00 US/Eastern", "2025-04-18T14:30:00Z"),
598            ("20251218 10:30:00 America/New_York", "2025-12-18T15:30:00Z"),
599            ("20260929 14:00:00 UTC", "2026-09-29T14:00:00Z"),
600            ("20260115 09:00:00 MET", "2026-01-15T08:00:00Z"),
601        ] {
602            assert_eq!(
603                parse_ib_timestamp(time, session),
604                Some(utc(expected)),
605                "{time}"
606            );
607        }
608    }
609
610    /// A time that names no zone is read in the session's, and without one is not read: UTC
611    /// would be a guess.
612    #[test]
613    fn an_ib_time_without_a_zone_is_read_in_the_sessions() {
614        let time = "20261027 12:48:41";
615        assert_eq!(
616            parse_ib_timestamp(time, Some(chrono_tz::Asia::Singapore)),
617            Some(utc("2026-10-27T04:48:41Z"))
618        );
619        assert_eq!(
620            parse_ib_timestamp("20261027  12:48:41", Some(chrono_tz::Asia::Singapore)),
621            Some(utc("2026-10-27T04:48:41Z")),
622            "older gateways put two spaces between the date and the time"
623        );
624        assert_eq!(parse_ib_timestamp(time, None), None);
625    }
626
627    /// A zone that is not a tz database name is not read, in UTC or the session's zone.
628    #[test]
629    fn an_ib_time_in_an_unknown_zone_is_not_read() {
630        for time in [
631            "20261027 12:48:41 Some Standard Time",
632            "20261027 12:48:41 Nowhere/Atlantis",
633        ] {
634            assert_eq!(
635                parse_ib_timestamp(time, Some(chrono_tz::UTC)),
636                None,
637                "{time}"
638            );
639        }
640    }
641
642    /// `YYYYMMDD-HH:MM:SS` is UTC, whatever the session's zone.
643    #[test]
644    fn an_ib_time_with_a_hyphen_is_utc() {
645        assert_eq!(
646            parse_ib_timestamp("20261027-04:48:41", Some(chrono_tz::Asia::Singapore)),
647            Some(utc("2026-10-27T04:48:41Z"))
648        );
649        assert_eq!(parse_ib_timestamp("20261027-04:48:41 UTC", None), None);
650    }
651
652    /// A reading in a repeated hour is its earlier instant, and one in a skipped hour is not read.
653    #[test]
654    fn an_ib_time_in_a_daylight_saving_change() {
655        // US clocks went back at 02:00 EDT on 2025-11-02, so 01:30 came twice.
656        assert_eq!(
657            parse_ib_timestamp("20251102 01:30:00 US/Eastern", None),
658            Some(utc("2025-11-02T05:30:00Z"))
659        );
660        // And forward at 02:00 EST on 2025-03-09, so 02:30 never showed.
661        assert_eq!(
662            parse_ib_timestamp("20250309 02:30:00 US/Eastern", None),
663            None
664        );
665    }
666
667    #[test]
668    fn an_ib_time_that_is_no_time_is_not_read() {
669        for time in [
670            "",
671            "invalid",
672            "20261027",
673            "20261027 25:00:00 UTC",
674            "20261327 12:00:00 UTC",
675        ] {
676            assert_eq!(
677                parse_ib_timestamp(time, Some(chrono_tz::UTC)),
678                None,
679                "{time:?}"
680            );
681        }
682    }
683
684    /// A fill whose time reads is the venue's; one whose time does not is kept, at this host's
685    /// time, and marked so.
686    #[test]
687    fn a_fill_without_a_time_it_can_read_is_marked_as_this_hosts() {
688        let buffer = ExecutionBuffer::new();
689        let at = utc("2026-10-27T04:48:41Z");
690        for (exec_id, time) in [("read", Some(at)), ("unread", None)] {
691            buffer.add_execution(
692                ExecutionData {
693                    execution: ibapi::orders::Execution {
694                        execution_id: exec_id.to_string(),
695                        ..Default::default()
696                    },
697                    ..Default::default()
698                },
699                ResolvedExecution {
700                    instrument: InstrumentNameExchange::new("AAPL"),
701                    order_id: ib_order_id(1),
702                    origin: TradeOrigin::Order,
703                    time,
704                },
705            );
706        }
707
708        let before = Utc::now();
709        let mut trades = buffer.take_without_commission();
710        trades.sort_by(|a, b| a.id.cmp(&b.id));
711        assert_eq!(trades[0].id, TradeId::new("read"));
712        assert_eq!(
713            (trades[0].time_exchange, trades[0].time_source),
714            (at, TimeSource::Venue)
715        );
716        assert_eq!(trades[1].time_source, TimeSource::Host);
717        assert!((before..=Utc::now()).contains(&trades[1].time_exchange));
718    }
719
720    /// The zone `ibapi` resolves a gateway's zone name to is the `chrono_tz` zone of that name.
721    #[test]
722    fn a_zone_ibapi_resolved_is_the_chrono_zone_of_its_name() {
723        assert_eq!(
724            chrono_zone(time_tz::timezones::db::asia::SINGAPORE),
725            Some(chrono_tz::Asia::Singapore)
726        );
727        assert_eq!(
728            chrono_zone(time_tz::timezones::db::america::NEW_YORK),
729            Some(chrono_tz::America::New_York)
730        );
731    }
732
733    /// An execution waiting for its commission report for `max_age` or longer is cleared. One
734    /// whose time does not read is aged by when it was buffered, so a fresh one is kept.
735    #[test]
736    fn clear_stale_clears_executions_older_than_the_age() {
737        let buffer = ExecutionBuffer::new();
738        let now = Utc::now();
739        let resolved = |time| ResolvedExecution {
740            instrument: InstrumentNameExchange::new("AAPL"),
741            order_id: ib_order_id(1),
742            origin: TradeOrigin::Order,
743            time,
744        };
745        let execution = |exec_id: &str| ExecutionData {
746            execution: ibapi::orders::Execution {
747                execution_id: exec_id.to_string(),
748                ..Default::default()
749            },
750            ..Default::default()
751        };
752        for (exec_id, time) in [
753            ("old", Some(now - chrono::Duration::hours(2))),
754            ("at-age", Some(now - chrono::Duration::hours(1))),
755            ("recent", Some(now - chrono::Duration::minutes(1))),
756            ("unread", None),
757        ] {
758            buffer.add_execution(execution(exec_id), resolved(time));
759        }
760        buffer.inner.lock().pending.insert(
761            "unread-old".to_string(),
762            PendingExecution {
763                execution: execution("unread-old"),
764                resolved: resolved(None),
765                buffered_at: now - chrono::Duration::hours(2),
766            },
767        );
768
769        assert_eq!(buffer.clear_stale(Duration::from_secs(3_600)), 3);
770        let mut kept: Vec<_> = buffer
771            .take_without_commission()
772            .into_iter()
773            .map(|trade| trade.id)
774            .collect();
775        kept.sort();
776        assert_eq!(kept, [TradeId::new("recent"), TradeId::new("unread")]);
777    }
778
779    /// A fill reports the order reference as its client order id: this client places each order
780    /// with its client order id there. An order placed without one reports none.
781    #[test]
782    fn a_fill_carries_the_order_reference_as_its_client_order_id() {
783        let buffer = ExecutionBuffer::new();
784        for (exec_id, reference) in [("a", "outside-1"), ("b", "")] {
785            buffer.add_execution(
786                ExecutionData {
787                    execution: ibapi::orders::Execution {
788                        execution_id: exec_id.to_string(),
789                        order_reference: reference.to_string(),
790                        ..Default::default()
791                    },
792                    ..Default::default()
793                },
794                ResolvedExecution {
795                    instrument: InstrumentNameExchange::new("AAPL"),
796                    order_id: ib_order_id(1),
797                    origin: TradeOrigin::Order,
798                    time: None,
799                },
800            );
801        }
802
803        let mut trades = buffer.take_without_commission();
804        trades.sort_by(|a, b| a.id.cmp(&b.id));
805        assert_eq!(
806            trades[0].client_order_id,
807            Some(ClientOrderId::new("outside-1"))
808        );
809        assert_eq!(trades[1].client_order_id, None);
810    }
811
812    #[test]
813    fn drain_into_moves_everything_and_keeps_the_target() {
814        let execution = |exec_id: &str| ExecutionData {
815            execution: ibapi::orders::Execution {
816                execution_id: exec_id.to_string(),
817                ..Default::default()
818            },
819            ..Default::default()
820        };
821        let add = |buffer: &ExecutionBuffer, exec_id: &str| {
822            buffer.add_execution(
823                execution(exec_id),
824                ResolvedExecution {
825                    instrument: InstrumentNameExchange::new("AAPL"),
826                    order_id: ib_order_id(1),
827                    origin: TradeOrigin::Order,
828                    time: None,
829                },
830            );
831        };
832        let source = ExecutionBuffer::new();
833        let target = ExecutionBuffer::new();
834        add(&source, "a");
835        add(&source, "b");
836        add(&target, "c");
837
838        assert_eq!(source.drain_into(&target), 2);
839        assert_eq!(source.pending_count(), 0);
840        assert_eq!(target.pending_count(), 3);
841    }
842
843    #[test]
844    fn execution_revision_reads_the_digits_after_the_final_period() {
845        let original = ExecutionRevision::parse("0000e0d5.5f8b1c2a.01.01").unwrap();
846        assert_eq!(original.execution, "0000e0d5.5f8b1c2a.01");
847        assert_eq!(original.revision, 1);
848        assert!(!original.is_correction());
849        assert_eq!(original.previous_id(), None);
850
851        let correction = ExecutionRevision::parse("0000e0d5.5f8b1c2a.01.02").unwrap();
852        assert_eq!(correction.execution, "0000e0d5.5f8b1c2a.01");
853        assert!(correction.is_correction());
854        assert_eq!(
855            correction.previous_id(),
856            Some(TradeId::new("0000e0d5.5f8b1c2a.01.01"))
857        );
858
859        // The previous revision keeps the width of the digits.
860        assert_eq!(
861            ExecutionRevision::parse("x.10").unwrap().previous_id(),
862            Some(TradeId::new("x.09"))
863        );
864        assert_eq!(
865            ExecutionRevision::parse("x.3").unwrap().previous_id(),
866            Some(TradeId::new("x.2"))
867        );
868    }
869
870    #[test]
871    fn execution_id_without_a_revision_is_never_a_correction() {
872        for id in ["e1", "abc.", "abc.0x", "abc.1a", "", ".", "abc.99999999999"] {
873            assert_eq!(ExecutionRevision::parse(id), None, "{id:?}");
874        }
875        assert_eq!(revision_of(&TradeId::new("e1")), 0);
876        assert_eq!(revision_of(&TradeId::new("a.b.02")), 2);
877    }
878
879    fn trade(id: &str, price: i64) -> Trade<AssetNameExchange, InstrumentNameExchange> {
880        Trade {
881            id: TradeId::new(id),
882            order_id: crate::order::id::OrderId::new("cid"),
883            instrument: InstrumentNameExchange::new("AAPL"),
884            strategy: StrategyId::unknown(),
885            time_exchange: DateTime::<Utc>::MIN_UTC,
886            side: Side::Buy,
887            price: Decimal::from(price),
888            quantity: Decimal::ONE,
889            order_filled_quantity: Some(Decimal::ONE),
890            fees: AssetFees::new(AssetNameExchange::from("USD"), Decimal::ZERO, None),
891            origin: TradeOrigin::Order,
892            position_side: None,
893            client_order_id: None,
894            time_source: TimeSource::Venue,
895        }
896    }
897
898    #[test]
899    fn keep_latest_revisions_reads_a_corrected_execution_once() {
900        let kept = keep_latest_revisions(vec![
901            trade("a.01.01", 100),
902            trade("b.01.01", 200),
903            trade("a.01.02", 101),
904            trade("a.01.02", 101),
905            trade("plain", 300),
906            // An earlier revision after a later one is still the earlier one.
907            trade("b.01.03", 202),
908            trade("b.01.02", 201),
909        ]);
910        let read: Vec<_> = kept
911            .iter()
912            .map(|trade| (trade.id.0.as_str(), trade.price))
913            .collect();
914        assert_eq!(
915            read,
916            [
917                ("a.01.02", Decimal::from(101)),
918                ("b.01.03", Decimal::from(202)),
919                ("plain", Decimal::from(300)),
920            ]
921        );
922    }
923
924    #[test]
925    fn executions_held_since_is_the_cover_start_only_when_the_span_starts_before_it() {
926        let now = Utc.with_ymd_and_hms(2026, 10, 23, 9, 0, 0).unwrap();
927        let covered_from = Utc.with_ymd_and_hms(2026, 10, 18, 9, 0, 0).unwrap();
928        let before = covered_from - chrono::TimeDelta::seconds(1);
929        assert_eq!(executions_held_since(before, now), Some(covered_from));
930        assert_eq!(executions_held_since(covered_from, now), None);
931        assert_eq!(executions_held_since(now, now), None);
932    }
933
934    #[test]
935    fn executions_filter_reads_every_client_over_the_execution_days() {
936        let filter = executions_filter();
937        assert_eq!(filter.last_n_days, 7);
938        assert_eq!(filter.client_id, None);
939        assert!(filter.specific_dates.is_empty());
940    }
941}