use crate::{
order::id::{OrderId, StrategyId},
trade::{AssetFees, Trade, TradeId},
};
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
use chrono_tz::Tz;
use fnv::FnvHashMap;
use ibapi::orders::{CommissionReport, ExecutionData, ExecutionSide};
use parking_lot::Mutex;
use rust_decimal::Decimal;
use rustrade_instrument::{
Side, asset::name::AssetNameExchange, instrument::name::InstrumentNameExchange,
};
use smol_str::{SmolStr, format_smolstr};
use std::{cell::RefCell, collections::hash_map::Entry, sync::Arc};
use tracing::{debug, warn};
thread_local! {
static TZ_CACHE: RefCell<FnvHashMap<SmolStr, Tz>> = RefCell::new(FnvHashMap::default());
}
#[derive(Debug, Clone)]
pub struct ExecutionBuffer {
inner: Arc<Mutex<ExecutionBufferInner>>,
}
#[derive(Debug, Default)]
struct ExecutionBufferInner {
pending: FnvHashMap<String, PendingExecution>,
}
#[derive(Debug, Clone)]
struct PendingExecution {
execution: ExecutionData,
instrument: InstrumentNameExchange,
}
impl ExecutionBuffer {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(ExecutionBufferInner::default())),
}
}
pub fn add_execution(&self, execution: ExecutionData, instrument: InstrumentNameExchange) {
let exec_id = execution.execution.execution_id.clone();
let mut inner = self.inner.lock();
inner.pending.insert(
exec_id,
PendingExecution {
execution,
instrument,
},
);
let pending_count = inner.pending.len();
if pending_count > 1000 && pending_count.is_multiple_of(100) {
warn!(
pending_count,
"ExecutionBuffer has >1000 pending entries; commission reports may be delayed or lost"
);
}
}
pub fn complete_with_commission(
&self,
report: &CommissionReport,
) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
let pending = {
let mut inner = self.inner.lock();
inner.pending.remove(&report.execution_id)?
};
Some(build_trade(pending, report))
}
pub(super) fn take_without_commission(
&self,
) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
std::mem::take(&mut self.inner.lock().pending)
.into_values()
.map(|pending| {
let fees = AssetFees::new(
AssetNameExchange::from(UNKNOWN_FEE_ASSET),
Decimal::ZERO,
None,
);
trade_with_fees(pending, fees)
})
.collect()
}
pub(super) fn drain_into(&self, target: &ExecutionBuffer) -> usize {
let mut target = target.inner.lock();
let drained = std::mem::take(&mut self.inner.lock().pending);
let count = drained.len();
target.pending.extend(drained);
count
}
pub fn pending_count(&self) -> usize {
self.inner.lock().pending.len()
}
pub fn clear_stale(&self, max_age: std::time::Duration) -> usize {
let now = Utc::now();
let mut inner = self.inner.lock();
let before = inner.pending.len();
let max_age_secs = i64::try_from(max_age.as_secs()).unwrap_or(i64::MAX);
inner.pending.retain(|_, pending| {
if let Some(exec_time) = parse_ib_timestamp(&pending.execution.execution.time) {
let age = now.signed_duration_since(exec_time);
age.num_seconds() < max_age_secs
} else {
false
}
});
before - inner.pending.len()
}
}
impl Default for ExecutionBuffer {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct ExecutionRevision<'a> {
pub(super) execution: &'a str,
digits: &'a str,
pub(super) revision: u32,
}
impl<'a> ExecutionRevision<'a> {
pub(super) fn parse(execution_id: &'a str) -> Option<Self> {
let (execution, digits) = execution_id.rsplit_once('.')?;
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
Some(Self {
execution,
digits,
revision: digits.parse().ok()?,
})
}
pub(super) fn is_correction(&self) -> bool {
self.revision > 1
}
pub(super) fn previous_id(&self) -> Option<TradeId> {
self.is_correction().then(|| {
TradeId(format_smolstr!(
"{}.{:0width$}",
self.execution,
self.revision - 1,
width = self.digits.len()
))
})
}
}
pub(super) fn revision_of(id: &TradeId) -> u32 {
ExecutionRevision::parse(&id.0).map_or(0, |revision| revision.revision)
}
pub(super) fn keep_latest_revisions(
trades: Vec<Trade<AssetNameExchange, InstrumentNameExchange>>,
) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
let mut latest = FnvHashMap::<SmolStr, (usize, u32)>::with_capacity_and_hasher(
trades.len(),
Default::default(),
);
let mut kept = Vec::with_capacity(trades.len());
for trade in trades {
let Some(revision) = ExecutionRevision::parse(&trade.id.0) else {
kept.push(trade);
continue;
};
let (execution, revision) = (SmolStr::new(revision.execution), revision.revision);
match latest.entry(execution) {
Entry::Vacant(entry) => {
entry.insert((kept.len(), revision));
kept.push(trade);
}
Entry::Occupied(mut entry) => {
let (position, kept_revision) = *entry.get();
if revision == kept_revision {
debug!(exec_id = %trade.id, "IBKR execution read twice; keeping one");
continue;
}
let (older, newer) = if revision > kept_revision {
entry.insert((position, revision));
(
std::mem::replace(&mut kept[position], trade),
&kept[position],
)
} else {
(trade, &kept[position])
};
debug!(
corrected = %older.id,
correction = %newer.id,
"IBKR execution corrected; keeping the correction"
);
}
}
}
kept
}
pub const UNKNOWN_FEE_ASSET: &str = "UNKNOWN";
pub(super) fn ib_order_id(ib_order_id: i32) -> OrderId {
OrderId::new(format_smolstr!("{ib_order_id}"))
}
fn build_trade(
pending: PendingExecution,
commission: &CommissionReport,
) -> Trade<AssetNameExchange, InstrumentNameExchange> {
let commission_amount = parse_decimal_or_warn(commission.commission, "commission");
let fees = AssetFees {
asset: AssetNameExchange::from(commission.currency.as_str()),
fees: commission_amount,
fees_quote: None, };
trade_with_fees(pending, fees)
}
fn trade_with_fees(
pending: PendingExecution,
fees: AssetFees<AssetNameExchange>,
) -> Trade<AssetNameExchange, InstrumentNameExchange> {
let exec = &pending.execution.execution;
let side = match exec.side {
ExecutionSide::Bought => Side::Buy,
ExecutionSide::Sold => Side::Sell,
};
let price = parse_decimal_or_warn(exec.price, "exec.price");
let quantity = parse_decimal_or_warn(exec.shares, "exec.shares");
let time_exchange = parse_ib_timestamp(&exec.time).unwrap_or_else(Utc::now);
Trade {
id: TradeId::new(&exec.execution_id),
order_id: ib_order_id(exec.order_id),
instrument: pending.instrument,
strategy: StrategyId::unknown(),
time_exchange,
side,
price,
quantity,
order_filled_quantity: Some(parse_decimal_or_warn(
exec.cumulative_quantity,
"exec.cumulative_quantity",
)),
fees,
}
}
pub fn parse_decimal_or_warn(value: f64, field_name: impl std::fmt::Display) -> Decimal {
try_decimal_or_warn(value, field_name).unwrap_or(Decimal::ZERO)
}
pub fn try_decimal_or_warn(value: f64, field_name: impl std::fmt::Display) -> Option<Decimal> {
Decimal::try_from(value)
.map_err(
|e| warn!(field = %field_name, value = %value, error = %e, "Invalid f64 for Decimal"),
)
.ok()
}
pub fn parse_ib_timestamp(s: &str) -> Option<DateTime<Utc>> {
let mut parts = s.split_whitespace();
let date_part = parts.next()?;
let time_part = parts.next()?;
let tz_part = parts.next();
let datetime_end = date_part.len() + 1 + time_part.len();
let datetime_str = &s[..datetime_end.min(s.len())];
let naive = NaiveDateTime::parse_from_str(datetime_str, "%Y%m%d %H:%M:%S").ok()?;
if let Some(tz_str) = tz_part {
let tz_opt = TZ_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
if let Some(tz) = cache.get(tz_str) {
return Some(*tz);
}
if let Ok(tz) = tz_str.parse::<Tz>() {
cache.insert(SmolStr::new(tz_str), tz);
return Some(tz);
}
None
});
if let Some(tz) = tz_opt {
return tz
.from_local_datetime(&naive)
.single()
.map(|dt| dt.with_timezone(&Utc));
}
warn!(timezone = %tz_str, "Unknown timezone in IB timestamp, treating as UTC");
}
Some(naive.and_utc())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use chrono::{Datelike, Timelike};
#[test]
fn test_parse_ib_timestamp_with_timezone() {
let ts = parse_ib_timestamp("20250418 10:30:00 US/Eastern");
assert!(ts.is_some());
let dt = ts.unwrap();
assert_eq!(dt.year(), 2025);
assert_eq!(dt.month(), 4);
assert_eq!(dt.day(), 18);
assert_eq!(dt.hour(), 14);
assert_eq!(dt.minute(), 30);
}
#[test]
fn test_parse_ib_timestamp_no_timezone() {
let ts = parse_ib_timestamp("20250418 10:30:00");
assert!(ts.is_some());
let dt = ts.unwrap();
assert_eq!(dt.hour(), 10);
}
#[test]
fn drain_into_moves_everything_and_keeps_the_target() {
let execution = |exec_id: &str| ExecutionData {
execution: ibapi::orders::Execution {
execution_id: exec_id.to_string(),
..Default::default()
},
..Default::default()
};
let add = |buffer: &ExecutionBuffer, exec_id: &str| {
buffer.add_execution(execution(exec_id), InstrumentNameExchange::new("AAPL"));
};
let source = ExecutionBuffer::new();
let target = ExecutionBuffer::new();
add(&source, "a");
add(&source, "b");
add(&target, "c");
assert_eq!(source.drain_into(&target), 2);
assert_eq!(source.pending_count(), 0);
assert_eq!(target.pending_count(), 3);
}
#[test]
fn execution_revision_reads_the_digits_after_the_final_period() {
let original = ExecutionRevision::parse("0000e0d5.5f8b1c2a.01.01").unwrap();
assert_eq!(original.execution, "0000e0d5.5f8b1c2a.01");
assert_eq!(original.revision, 1);
assert!(!original.is_correction());
assert_eq!(original.previous_id(), None);
let correction = ExecutionRevision::parse("0000e0d5.5f8b1c2a.01.02").unwrap();
assert_eq!(correction.execution, "0000e0d5.5f8b1c2a.01");
assert!(correction.is_correction());
assert_eq!(
correction.previous_id(),
Some(TradeId::new("0000e0d5.5f8b1c2a.01.01"))
);
assert_eq!(
ExecutionRevision::parse("x.10").unwrap().previous_id(),
Some(TradeId::new("x.09"))
);
assert_eq!(
ExecutionRevision::parse("x.3").unwrap().previous_id(),
Some(TradeId::new("x.2"))
);
}
#[test]
fn execution_id_without_a_revision_is_never_a_correction() {
for id in ["e1", "abc.", "abc.0x", "abc.1a", "", ".", "abc.99999999999"] {
assert_eq!(ExecutionRevision::parse(id), None, "{id:?}");
}
assert_eq!(revision_of(&TradeId::new("e1")), 0);
assert_eq!(revision_of(&TradeId::new("a.b.02")), 2);
}
fn trade(id: &str, price: i64) -> Trade<AssetNameExchange, InstrumentNameExchange> {
Trade {
id: TradeId::new(id),
order_id: crate::order::id::OrderId::new("cid"),
instrument: InstrumentNameExchange::new("AAPL"),
strategy: StrategyId::unknown(),
time_exchange: DateTime::<Utc>::MIN_UTC,
side: Side::Buy,
price: Decimal::from(price),
quantity: Decimal::ONE,
order_filled_quantity: Some(Decimal::ONE),
fees: AssetFees::new(AssetNameExchange::from("USD"), Decimal::ZERO, None),
}
}
#[test]
fn keep_latest_revisions_reads_a_corrected_execution_once() {
let kept = keep_latest_revisions(vec![
trade("a.01.01", 100),
trade("b.01.01", 200),
trade("a.01.02", 101),
trade("a.01.02", 101),
trade("plain", 300),
trade("b.01.03", 202),
trade("b.01.02", 201),
]);
let read: Vec<_> = kept
.iter()
.map(|trade| (trade.id.0.as_str(), trade.price))
.collect();
assert_eq!(
read,
[
("a.01.02", Decimal::from(101)),
("b.01.03", Decimal::from(202)),
("plain", Decimal::from(300)),
]
);
}
#[test]
fn test_parse_ib_timestamp_invalid() {
assert!(parse_ib_timestamp("invalid").is_none());
assert!(parse_ib_timestamp("").is_none());
}
}