Skip to main content

rustrade_execution/client/ibkr/
execution.rs

1use crate::{
2    order::id::{ClientOrderId, 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;
15use std::{cell::RefCell, sync::Arc};
16use tracing::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    client_order_id: ClientOrderId,
50}
51
52impl ExecutionBuffer {
53    pub fn new() -> Self {
54        Self {
55            inner: Arc::new(Mutex::new(ExecutionBufferInner::default())),
56        }
57    }
58
59    /// Buffer an execution, waiting for its commission report.
60    pub fn add_execution(
61        &self,
62        execution: ExecutionData,
63        instrument: InstrumentNameExchange,
64        client_order_id: ClientOrderId,
65    ) {
66        let exec_id = execution.execution.execution_id.clone();
67        let mut inner = self.inner.lock();
68        inner.pending.insert(
69            exec_id,
70            PendingExecution {
71                execution,
72                instrument,
73                client_order_id,
74            },
75        );
76
77        // Warn if buffer is growing unexpectedly large (possible commission report leak)
78        let pending_count = inner.pending.len();
79        if pending_count > 1000 && pending_count.is_multiple_of(100) {
80            warn!(
81                pending_count,
82                "ExecutionBuffer has >1000 pending entries; commission reports may be delayed or lost"
83            );
84        }
85    }
86
87    /// Try to complete a trade with a commission report.
88    /// Returns the completed Trade if the matching execution was buffered.
89    pub fn complete_with_commission(
90        &self,
91        report: &CommissionReport,
92    ) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
93        let pending = {
94            let mut inner = self.inner.lock();
95            inner.pending.remove(&report.execution_id)?
96        };
97
98        Some(build_trade(pending, report))
99    }
100
101    /// Move every pending execution into `target`, returning how many moved.
102    ///
103    /// Fill recovery buffers what it reads in a buffer of its own, then hands over whatever is
104    /// still waiting for a commission report, so a report that reaches the account stream later
105    /// can complete it there.
106    ///
107    /// Both buffers stay locked for the move, so a commission report the stream reads meanwhile
108    /// finds the execution in one of them. `target` is locked first. The stream locks only its own
109    /// buffer, so the order cannot deadlock against it.
110    pub(super) fn drain_into(&self, target: &ExecutionBuffer) -> usize {
111        let mut target = target.inner.lock();
112        let drained = std::mem::take(&mut self.inner.lock().pending);
113        let count = drained.len();
114        target.pending.extend(drained);
115        count
116    }
117
118    /// Get number of pending executions (for diagnostics).
119    pub fn pending_count(&self) -> usize {
120        self.inner.lock().pending.len()
121    }
122
123    /// Clear stale executions older than the given duration.
124    ///
125    /// Returns number of cleared entries.
126    ///
127    /// # Caller Responsibility
128    ///
129    /// This method is not called automatically. Callers should invoke it
130    /// periodically to prevent unbounded growth if commission reports are
131    /// delayed or lost.
132    pub fn clear_stale(&self, max_age: std::time::Duration) -> usize {
133        let now = Utc::now();
134        let mut inner = self.inner.lock();
135        let before = inner.pending.len();
136
137        let max_age_secs = i64::try_from(max_age.as_secs()).unwrap_or(i64::MAX);
138        inner.pending.retain(|_, pending| {
139            if let Some(exec_time) = parse_ib_timestamp(&pending.execution.execution.time) {
140                let age = now.signed_duration_since(exec_time);
141                age.num_seconds() < max_age_secs
142            } else {
143                // Evict entries with unparseable timestamps to prevent memory leak
144                false
145            }
146        });
147
148        before - inner.pending.len()
149    }
150}
151
152impl Default for ExecutionBuffer {
153    fn default() -> Self {
154        Self::new()
155    }
156}
157
158/// Build a rustrade Trade from IB execution + commission data.
159fn build_trade(
160    pending: PendingExecution,
161    commission: &CommissionReport,
162) -> Trade<AssetNameExchange, InstrumentNameExchange> {
163    let exec = &pending.execution.execution;
164
165    // `ExecutionSide` is a closed two-variant enum in ibapi 3.x (the decoder
166    // rejects unknown wire values upstream), so the mapping is total.
167    let side = match exec.side {
168        ExecutionSide::Bought => Side::Buy,
169        ExecutionSide::Sold => Side::Sell,
170    };
171
172    let price = parse_decimal_or_warn(exec.price, "exec.price");
173    let quantity = parse_decimal_or_warn(exec.shares, "exec.shares");
174    let commission_amount = parse_decimal_or_warn(commission.commission, "commission");
175
176    let time_exchange = parse_ib_timestamp(&exec.time).unwrap_or_else(Utc::now);
177
178    Trade {
179        id: TradeId::new(&exec.execution_id),
180        order_id: crate::order::id::OrderId::new(&pending.client_order_id.0),
181        instrument: pending.instrument,
182        strategy: StrategyId::unknown(),
183        time_exchange,
184        side,
185        price,
186        quantity,
187        // IB reports the order's running total on every execution, so the order advances from the
188        // fill itself rather than waiting on the next `OrderStatus`.
189        order_filled_quantity: Some(parse_decimal_or_warn(
190            exec.cumulative_quantity,
191            "exec.cumulative_quantity",
192        )),
193        fees: AssetFees {
194            asset: AssetNameExchange::from(commission.currency.as_str()),
195            fees: commission_amount,
196            fees_quote: None, // Indexer computes based on fee asset vs instrument quote
197        },
198    }
199}
200
201/// Convert f64 to Decimal, logging a warning if conversion fails (NaN/Inf).
202///
203/// Returns `Decimal::ZERO` for invalid values. This is acceptable because:
204/// - IB's API should never return NaN/Inf for prices, quantities, or commissions
205/// - If it does, something is fundamentally broken and the warning log surfaces it
206/// - Callers processing trades in bulk shouldn't abort on one corrupted record
207///
208/// For stricter handling, callers can check for zero in critical fields.
209pub fn parse_decimal_or_warn(value: f64, field_name: &str) -> Decimal {
210    Decimal::try_from(value).unwrap_or_else(|e| {
211        warn!(field = %field_name, value = %value, error = %e, "Invalid f64 for Decimal, using zero");
212        Decimal::ZERO
213    })
214}
215
216/// Parse IB timestamp format (YYYYMMDD HH:MM:SS timezone).
217///
218/// IB sends timestamps like "20250418 10:30:00 US/Eastern". This function
219/// parses the timezone and converts to UTC.
220///
221/// # Fallback Behavior
222///
223/// - Unknown timezone string: treats as UTC (logs warning)
224/// - DST-ambiguous time (during "fall back" transition): returns `None`, caller
225///   typically falls back to `Utc::now()`. This affects ~1 second per timezone
226///   per year and is unlikely to occur in practice.
227pub fn parse_ib_timestamp(s: &str) -> Option<DateTime<Utc>> {
228    // Use iterator to avoid Vec allocation
229    let mut parts = s.split_whitespace();
230    let date_part = parts.next()?;
231    let time_part = parts.next()?;
232    let tz_part = parts.next();
233
234    // Find the space between date and time by byte offset to avoid format! allocation
235    let datetime_end = date_part.len() + 1 + time_part.len();
236    let datetime_str = &s[..datetime_end.min(s.len())];
237
238    let naive = NaiveDateTime::parse_from_str(datetime_str, "%Y%m%d %H:%M:%S").ok()?;
239
240    // Try to parse timezone, fall back to UTC
241    if let Some(tz_str) = tz_part {
242        // Use cached timezone to avoid re-parsing IANA database on every call
243        let tz_opt = TZ_CACHE.with(|cache| {
244            let mut cache = cache.borrow_mut();
245            if let Some(tz) = cache.get(tz_str) {
246                return Some(*tz);
247            }
248            if let Ok(tz) = tz_str.parse::<Tz>() {
249                cache.insert(SmolStr::new(tz_str), tz);
250                return Some(tz);
251            }
252            None
253        });
254
255        if let Some(tz) = tz_opt {
256            return tz
257                .from_local_datetime(&naive)
258                .single()
259                .map(|dt| dt.with_timezone(&Utc));
260        }
261        warn!(timezone = %tz_str, "Unknown timezone in IB timestamp, treating as UTC");
262    }
263
264    Some(naive.and_utc())
265}
266
267#[cfg(test)]
268#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
269mod tests {
270    use super::*;
271    use chrono::{Datelike, Timelike};
272
273    #[test]
274    fn test_parse_ib_timestamp_with_timezone() {
275        // US/Eastern is UTC-4 during daylight saving time (April)
276        let ts = parse_ib_timestamp("20250418 10:30:00 US/Eastern");
277        assert!(ts.is_some());
278        let dt = ts.unwrap();
279        assert_eq!(dt.year(), 2025);
280        assert_eq!(dt.month(), 4);
281        assert_eq!(dt.day(), 18);
282        // 10:30 Eastern = 14:30 UTC (EDT is UTC-4)
283        assert_eq!(dt.hour(), 14);
284        assert_eq!(dt.minute(), 30);
285    }
286
287    #[test]
288    fn test_parse_ib_timestamp_no_timezone() {
289        // Without timezone, treat as UTC
290        let ts = parse_ib_timestamp("20250418 10:30:00");
291        assert!(ts.is_some());
292        let dt = ts.unwrap();
293        assert_eq!(dt.hour(), 10);
294    }
295
296    #[test]
297    fn drain_into_moves_everything_and_keeps_the_target() {
298        let execution = |exec_id: &str| ExecutionData {
299            execution: ibapi::orders::Execution {
300                execution_id: exec_id.to_string(),
301                ..Default::default()
302            },
303            ..Default::default()
304        };
305        let add = |buffer: &ExecutionBuffer, exec_id: &str| {
306            buffer.add_execution(
307                execution(exec_id),
308                InstrumentNameExchange::new("AAPL"),
309                ClientOrderId::new("cid"),
310            );
311        };
312        let source = ExecutionBuffer::new();
313        let target = ExecutionBuffer::new();
314        add(&source, "a");
315        add(&source, "b");
316        add(&target, "c");
317
318        assert_eq!(source.drain_into(&target), 2);
319        assert_eq!(source.pending_count(), 0);
320        assert_eq!(target.pending_count(), 3);
321    }
322
323    #[test]
324    fn test_parse_ib_timestamp_invalid() {
325        assert!(parse_ib_timestamp("invalid").is_none());
326        assert!(parse_ib_timestamp("").is_none());
327    }
328}