rustrade_execution/client/ibkr/
execution.rs1use 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
18thread_local! {
27 static TZ_CACHE: RefCell<FnvHashMap<SmolStr, Tz>> = RefCell::new(FnvHashMap::default());
28}
29
30#[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 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 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 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 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 pub fn pending_count(&self) -> usize {
120 self.inner.lock().pending.len()
121 }
122
123 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 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
158fn build_trade(
160 pending: PendingExecution,
161 commission: &CommissionReport,
162) -> Trade<AssetNameExchange, InstrumentNameExchange> {
163 let exec = &pending.execution.execution;
164
165 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 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, },
198 }
199}
200
201pub 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
216pub fn parse_ib_timestamp(s: &str) -> Option<DateTime<Utc>> {
228 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 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 if let Some(tz_str) = tz_part {
242 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)] mod tests {
270 use super::*;
271 use chrono::{Datelike, Timelike};
272
273 #[test]
274 fn test_parse_ib_timestamp_with_timezone() {
275 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 assert_eq!(dt.hour(), 14);
284 assert_eq!(dt.minute(), 30);
285 }
286
287 #[test]
288 fn test_parse_ib_timestamp_no_timezone() {
289 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}