Skip to main content

rustrade_execution/client/ibkr/
execution.rs

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