1use crate::{
2 order::id::{ClientOrderId, OrderId, StrategyId},
3 trade::{AssetFees, Trade, TradeId, TradeOrigin},
4};
5use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
6use chrono_tz::Tz;
7use fnv::FnvHashMap;
8use ibapi::orders::{CommissionReport, Execution, 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 resolved: ResolvedExecution,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub(super) struct ExecutionScope {
54 pub(super) api_client_id: i32,
56 pub(super) other_clients: bool,
59}
60
61impl ExecutionScope {
62 pub(super) fn classify(self, execution: &Execution) -> Option<(OrderId, TradeOrigin)> {
70 let own = execution.client_id == self.api_client_id;
71 let liquidation = execution.liquidation != 0;
72 if !own && !liquidation && !self.other_clients {
73 return None;
74 }
75 let origin = if liquidation {
76 TradeOrigin::Liquidation
77 } else if own {
78 TradeOrigin::Order
79 } else {
80 TradeOrigin::External
81 };
82 let order_id = if own {
83 ib_order_id(execution.order_id)
84 } else {
85 foreign_order_id(execution)
86 };
87 Some((order_id, origin))
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
93pub(super) struct ResolvedExecution {
94 pub(super) instrument: InstrumentNameExchange,
95 pub(super) order_id: OrderId,
96 pub(super) origin: TradeOrigin,
97}
98
99impl ExecutionBuffer {
100 pub fn new() -> Self {
101 Self {
102 inner: Arc::new(Mutex::new(ExecutionBufferInner::default())),
103 }
104 }
105
106 pub(super) fn add_execution(&self, execution: ExecutionData, resolved: ResolvedExecution) {
108 let exec_id = execution.execution.execution_id.clone();
109 let mut inner = self.inner.lock();
110 inner.pending.insert(
111 exec_id,
112 PendingExecution {
113 execution,
114 resolved,
115 },
116 );
117
118 let pending_count = inner.pending.len();
120 if pending_count > 1000 && pending_count.is_multiple_of(100) {
121 warn!(
122 pending_count,
123 "ExecutionBuffer has >1000 pending entries; commission reports may be delayed or lost"
124 );
125 }
126 }
127
128 pub fn complete_with_commission(
131 &self,
132 report: &CommissionReport,
133 ) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
134 let pending = {
135 let mut inner = self.inner.lock();
136 inner.pending.remove(&report.execution_id)?
137 };
138
139 Some(build_trade(pending, report))
140 }
141
142 pub(super) fn take_without_commission(
148 &self,
149 ) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
150 std::mem::take(&mut self.inner.lock().pending)
151 .into_values()
152 .map(|pending| {
153 let fees = AssetFees::new(
154 AssetNameExchange::from(UNKNOWN_FEE_ASSET),
155 Decimal::ZERO,
156 None,
157 );
158 trade_with_fees(pending, fees)
159 })
160 .collect()
161 }
162
163 pub(super) fn drain_into(&self, target: &ExecutionBuffer) -> usize {
173 let mut target = target.inner.lock();
174 let drained = std::mem::take(&mut self.inner.lock().pending);
175 let count = drained.len();
176 target.pending.extend(drained);
177 count
178 }
179
180 pub fn pending_count(&self) -> usize {
182 self.inner.lock().pending.len()
183 }
184
185 pub fn clear_stale(&self, max_age: std::time::Duration) -> usize {
195 let now = Utc::now();
196 let mut inner = self.inner.lock();
197 let before = inner.pending.len();
198
199 let max_age_secs = i64::try_from(max_age.as_secs()).unwrap_or(i64::MAX);
200 inner.pending.retain(|_, pending| {
201 if let Some(exec_time) = parse_ib_timestamp(&pending.execution.execution.time) {
202 let age = now.signed_duration_since(exec_time);
203 age.num_seconds() < max_age_secs
204 } else {
205 false
207 }
208 });
209
210 before - inner.pending.len()
211 }
212}
213
214impl Default for ExecutionBuffer {
215 fn default() -> Self {
216 Self::new()
217 }
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub(super) struct ExecutionRevision<'a> {
228 pub(super) execution: &'a str,
230 digits: &'a str,
233 pub(super) revision: u32,
234}
235
236impl<'a> ExecutionRevision<'a> {
237 pub(super) fn parse(execution_id: &'a str) -> Option<Self> {
240 let (execution, digits) = execution_id.rsplit_once('.')?;
241 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
242 return None;
243 }
244 Some(Self {
245 execution,
246 digits,
247 revision: digits.parse().ok()?,
248 })
249 }
250
251 pub(super) fn is_correction(&self) -> bool {
253 self.revision > 1
254 }
255
256 pub(super) fn previous_id(&self) -> Option<TradeId> {
258 self.is_correction().then(|| {
259 TradeId(format_smolstr!(
260 "{}.{:0width$}",
261 self.execution,
262 self.revision - 1,
263 width = self.digits.len()
264 ))
265 })
266 }
267}
268
269pub(super) fn revision_of(id: &TradeId) -> u32 {
272 ExecutionRevision::parse(&id.0).map_or(0, |revision| revision.revision)
273}
274
275pub(super) fn keep_latest_revisions(
279 trades: Vec<Trade<AssetNameExchange, InstrumentNameExchange>>,
280) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
281 let mut latest = FnvHashMap::<SmolStr, (usize, u32)>::with_capacity_and_hasher(
283 trades.len(),
284 Default::default(),
285 );
286 let mut kept = Vec::with_capacity(trades.len());
287 for trade in trades {
288 let Some(revision) = ExecutionRevision::parse(&trade.id.0) else {
289 kept.push(trade);
290 continue;
291 };
292 let (execution, revision) = (SmolStr::new(revision.execution), revision.revision);
293 match latest.entry(execution) {
294 Entry::Vacant(entry) => {
295 entry.insert((kept.len(), revision));
296 kept.push(trade);
297 }
298 Entry::Occupied(mut entry) => {
299 let (position, kept_revision) = *entry.get();
300 if revision == kept_revision {
301 debug!(exec_id = %trade.id, "IBKR execution read twice; keeping one");
302 continue;
303 }
304 let (older, newer) = if revision > kept_revision {
305 entry.insert((position, revision));
306 (
307 std::mem::replace(&mut kept[position], trade),
308 &kept[position],
309 )
310 } else {
311 (trade, &kept[position])
312 };
313 debug!(
314 corrected = %older.id,
315 correction = %newer.id,
316 "IBKR execution corrected; keeping the correction"
317 );
318 }
319 }
320 }
321 kept
322}
323
324pub const UNKNOWN_FEE_ASSET: &str = "UNKNOWN";
326
327pub(super) fn ib_order_id(ib_order_id: i32) -> OrderId {
330 OrderId::new(format_smolstr!("{ib_order_id}"))
331}
332
333pub(super) fn foreign_order_id(execution: &Execution) -> OrderId {
340 if execution.perm_id != 0 {
341 OrderId::new(format_smolstr!("perm:{}", execution.perm_id))
342 } else {
343 OrderId::new(format_smolstr!(
344 "client:{}:{}",
345 execution.client_id,
346 execution.order_id
347 ))
348 }
349}
350
351fn build_trade(
353 pending: PendingExecution,
354 commission: &CommissionReport,
355) -> Trade<AssetNameExchange, InstrumentNameExchange> {
356 let commission_amount = parse_decimal_or_warn(commission.commission, "commission");
357 let fees = AssetFees {
358 asset: AssetNameExchange::from(commission.currency.as_str()),
359 fees: commission_amount,
360 fees_quote: None, };
362 trade_with_fees(pending, fees)
363}
364
365fn trade_with_fees(
367 pending: PendingExecution,
368 fees: AssetFees<AssetNameExchange>,
369) -> Trade<AssetNameExchange, InstrumentNameExchange> {
370 let exec = &pending.execution.execution;
371
372 let side = match exec.side {
375 ExecutionSide::Bought => Side::Buy,
376 ExecutionSide::Sold => Side::Sell,
377 };
378
379 let price = parse_decimal_or_warn(exec.price, "exec.price");
380 let quantity = parse_decimal_or_warn(exec.shares, "exec.shares");
381
382 let time_exchange = parse_ib_timestamp(&exec.time).unwrap_or_else(Utc::now);
383
384 let ResolvedExecution {
385 instrument,
386 order_id,
387 origin,
388 } = pending.resolved;
389 Trade {
390 id: TradeId::new(&exec.execution_id),
391 order_id,
394 instrument,
395 strategy: StrategyId::unknown(),
396 time_exchange,
397 side,
398 price,
399 quantity,
400 order_filled_quantity: Some(parse_decimal_or_warn(
403 exec.cumulative_quantity,
404 "exec.cumulative_quantity",
405 )),
406 fees,
407 origin,
408 position_side: None,
409 client_order_id: (!exec.order_reference.is_empty())
412 .then(|| ClientOrderId::new(exec.order_reference.as_str())),
413 }
414}
415
416pub fn parse_decimal_or_warn(value: f64, field_name: impl std::fmt::Display) -> Decimal {
426 try_decimal_or_warn(value, field_name).unwrap_or(Decimal::ZERO)
427}
428
429pub fn try_decimal_or_warn(value: f64, field_name: impl std::fmt::Display) -> Option<Decimal> {
435 Decimal::try_from(value)
436 .map_err(
437 |e| warn!(field = %field_name, value = %value, error = %e, "Invalid f64 for Decimal"),
438 )
439 .ok()
440}
441
442pub fn parse_ib_timestamp(s: &str) -> Option<DateTime<Utc>> {
454 let mut parts = s.split_whitespace();
456 let date_part = parts.next()?;
457 let time_part = parts.next()?;
458 let tz_part = parts.next();
459
460 let datetime_end = date_part.len() + 1 + time_part.len();
462 let datetime_str = &s[..datetime_end.min(s.len())];
463
464 let naive = NaiveDateTime::parse_from_str(datetime_str, "%Y%m%d %H:%M:%S").ok()?;
465
466 if let Some(tz_str) = tz_part {
468 let tz_opt = TZ_CACHE.with(|cache| {
470 let mut cache = cache.borrow_mut();
471 if let Some(tz) = cache.get(tz_str) {
472 return Some(*tz);
473 }
474 if let Ok(tz) = tz_str.parse::<Tz>() {
475 cache.insert(SmolStr::new(tz_str), tz);
476 return Some(tz);
477 }
478 None
479 });
480
481 if let Some(tz) = tz_opt {
482 return tz
483 .from_local_datetime(&naive)
484 .single()
485 .map(|dt| dt.with_timezone(&Utc));
486 }
487 warn!(timezone = %tz_str, "Unknown timezone in IB timestamp, treating as UTC");
488 }
489
490 Some(naive.and_utc())
491}
492
493#[cfg(test)]
494#[allow(clippy::unwrap_used)] mod tests {
496 use super::*;
497 use chrono::{Datelike, Timelike};
498
499 #[test]
500 fn test_parse_ib_timestamp_with_timezone() {
501 let ts = parse_ib_timestamp("20250418 10:30:00 US/Eastern");
503 assert!(ts.is_some());
504 let dt = ts.unwrap();
505 assert_eq!(dt.year(), 2025);
506 assert_eq!(dt.month(), 4);
507 assert_eq!(dt.day(), 18);
508 assert_eq!(dt.hour(), 14);
510 assert_eq!(dt.minute(), 30);
511 }
512
513 #[test]
514 fn test_parse_ib_timestamp_no_timezone() {
515 let ts = parse_ib_timestamp("20250418 10:30:00");
517 assert!(ts.is_some());
518 let dt = ts.unwrap();
519 assert_eq!(dt.hour(), 10);
520 }
521
522 #[test]
525 fn a_fill_carries_the_order_reference_as_its_client_order_id() {
526 let buffer = ExecutionBuffer::new();
527 for (exec_id, reference) in [("a", "outside-1"), ("b", "")] {
528 buffer.add_execution(
529 ExecutionData {
530 execution: ibapi::orders::Execution {
531 execution_id: exec_id.to_string(),
532 order_reference: reference.to_string(),
533 ..Default::default()
534 },
535 ..Default::default()
536 },
537 ResolvedExecution {
538 instrument: InstrumentNameExchange::new("AAPL"),
539 order_id: ib_order_id(1),
540 origin: TradeOrigin::Order,
541 },
542 );
543 }
544
545 let mut trades = buffer.take_without_commission();
546 trades.sort_by(|a, b| a.id.cmp(&b.id));
547 assert_eq!(
548 trades[0].client_order_id,
549 Some(ClientOrderId::new("outside-1"))
550 );
551 assert_eq!(trades[1].client_order_id, None);
552 }
553
554 #[test]
555 fn drain_into_moves_everything_and_keeps_the_target() {
556 let execution = |exec_id: &str| ExecutionData {
557 execution: ibapi::orders::Execution {
558 execution_id: exec_id.to_string(),
559 ..Default::default()
560 },
561 ..Default::default()
562 };
563 let add = |buffer: &ExecutionBuffer, exec_id: &str| {
564 buffer.add_execution(
565 execution(exec_id),
566 ResolvedExecution {
567 instrument: InstrumentNameExchange::new("AAPL"),
568 order_id: ib_order_id(1),
569 origin: TradeOrigin::Order,
570 },
571 );
572 };
573 let source = ExecutionBuffer::new();
574 let target = ExecutionBuffer::new();
575 add(&source, "a");
576 add(&source, "b");
577 add(&target, "c");
578
579 assert_eq!(source.drain_into(&target), 2);
580 assert_eq!(source.pending_count(), 0);
581 assert_eq!(target.pending_count(), 3);
582 }
583
584 #[test]
585 fn execution_revision_reads_the_digits_after_the_final_period() {
586 let original = ExecutionRevision::parse("0000e0d5.5f8b1c2a.01.01").unwrap();
587 assert_eq!(original.execution, "0000e0d5.5f8b1c2a.01");
588 assert_eq!(original.revision, 1);
589 assert!(!original.is_correction());
590 assert_eq!(original.previous_id(), None);
591
592 let correction = ExecutionRevision::parse("0000e0d5.5f8b1c2a.01.02").unwrap();
593 assert_eq!(correction.execution, "0000e0d5.5f8b1c2a.01");
594 assert!(correction.is_correction());
595 assert_eq!(
596 correction.previous_id(),
597 Some(TradeId::new("0000e0d5.5f8b1c2a.01.01"))
598 );
599
600 assert_eq!(
602 ExecutionRevision::parse("x.10").unwrap().previous_id(),
603 Some(TradeId::new("x.09"))
604 );
605 assert_eq!(
606 ExecutionRevision::parse("x.3").unwrap().previous_id(),
607 Some(TradeId::new("x.2"))
608 );
609 }
610
611 #[test]
612 fn execution_id_without_a_revision_is_never_a_correction() {
613 for id in ["e1", "abc.", "abc.0x", "abc.1a", "", ".", "abc.99999999999"] {
614 assert_eq!(ExecutionRevision::parse(id), None, "{id:?}");
615 }
616 assert_eq!(revision_of(&TradeId::new("e1")), 0);
617 assert_eq!(revision_of(&TradeId::new("a.b.02")), 2);
618 }
619
620 fn trade(id: &str, price: i64) -> Trade<AssetNameExchange, InstrumentNameExchange> {
621 Trade {
622 id: TradeId::new(id),
623 order_id: crate::order::id::OrderId::new("cid"),
624 instrument: InstrumentNameExchange::new("AAPL"),
625 strategy: StrategyId::unknown(),
626 time_exchange: DateTime::<Utc>::MIN_UTC,
627 side: Side::Buy,
628 price: Decimal::from(price),
629 quantity: Decimal::ONE,
630 order_filled_quantity: Some(Decimal::ONE),
631 fees: AssetFees::new(AssetNameExchange::from("USD"), Decimal::ZERO, None),
632 origin: TradeOrigin::Order,
633 position_side: None,
634 client_order_id: None,
635 }
636 }
637
638 #[test]
639 fn keep_latest_revisions_reads_a_corrected_execution_once() {
640 let kept = keep_latest_revisions(vec![
641 trade("a.01.01", 100),
642 trade("b.01.01", 200),
643 trade("a.01.02", 101),
644 trade("a.01.02", 101),
645 trade("plain", 300),
646 trade("b.01.03", 202),
648 trade("b.01.02", 201),
649 ]);
650 let read: Vec<_> = kept
651 .iter()
652 .map(|trade| (trade.id.0.as_str(), trade.price))
653 .collect();
654 assert_eq!(
655 read,
656 [
657 ("a.01.02", Decimal::from(101)),
658 ("b.01.03", Decimal::from(202)),
659 ("plain", Decimal::from(300)),
660 ]
661 );
662 }
663
664 #[test]
665 fn test_parse_ib_timestamp_invalid() {
666 assert!(parse_ib_timestamp("invalid").is_none());
667 assert!(parse_ib_timestamp("").is_none());
668 }
669}