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
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}
50
51impl ExecutionBuffer {
52 pub fn new() -> Self {
53 Self {
54 inner: Arc::new(Mutex::new(ExecutionBufferInner::default())),
55 }
56 }
57
58 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 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 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 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 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 pub fn pending_count(&self) -> usize {
134 self.inner.lock().pending.len()
135 }
136
137 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub(super) struct ExecutionRevision<'a> {
180 pub(super) execution: &'a str,
182 digits: &'a str,
185 pub(super) revision: u32,
186}
187
188impl<'a> ExecutionRevision<'a> {
189 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 pub(super) fn is_correction(&self) -> bool {
205 self.revision > 1
206 }
207
208 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
221pub(super) fn revision_of(id: &TradeId) -> u32 {
224 ExecutionRevision::parse(&id.0).map_or(0, |revision| revision.revision)
225}
226
227pub(super) fn keep_latest_revisions(
231 trades: Vec<Trade<AssetNameExchange, InstrumentNameExchange>>,
232) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
233 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
276pub const UNKNOWN_FEE_ASSET: &str = "UNKNOWN";
278
279pub(super) fn ib_order_id(ib_order_id: i32) -> OrderId {
282 OrderId::new(format_smolstr!("{ib_order_id}"))
283}
284
285fn 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, };
296 trade_with_fees(pending, fees)
297}
298
299fn trade_with_fees(
301 pending: PendingExecution,
302 fees: AssetFees<AssetNameExchange>,
303) -> Trade<AssetNameExchange, InstrumentNameExchange> {
304 let exec = &pending.execution.execution;
305
306 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 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 order_filled_quantity: Some(parse_decimal_or_warn(
331 exec.cumulative_quantity,
332 "exec.cumulative_quantity",
333 )),
334 fees,
335 }
336}
337
338pub 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
351pub 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
364pub fn parse_ib_timestamp(s: &str) -> Option<DateTime<Utc>> {
376 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 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 if let Some(tz_str) = tz_part {
390 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)] mod tests {
418 use super::*;
419 use chrono::{Datelike, Timelike};
420
421 #[test]
422 fn test_parse_ib_timestamp_with_timezone() {
423 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 assert_eq!(dt.hour(), 14);
432 assert_eq!(dt.minute(), 30);
433 }
434
435 #[test]
436 fn test_parse_ib_timestamp_no_timezone() {
437 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 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 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}