use crate::{
order::id::{ClientOrderId, OrderId, StrategyId},
trade::{AssetFees, TimeSource, Trade, TradeId, TradeOrigin},
};
use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
use chrono_tz::Tz;
use fnv::FnvHashMap;
use ibapi::orders::{CommissionReport, Execution, ExecutionData, ExecutionFilter, 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, time::Duration};
use tracing::{debug, warn};
thread_local! {
static TZ_CACHE: RefCell<FnvHashMap<SmolStr, Option<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,
resolved: ResolvedExecution,
buffered_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct ExecutionScope {
pub(super) api_client_id: i32,
pub(super) other_clients: bool,
}
impl ExecutionScope {
pub(super) fn classify(self, execution: &Execution) -> Option<(OrderId, TradeOrigin)> {
let own = execution.client_id == self.api_client_id;
let liquidation = execution.liquidation != 0;
if !own && !liquidation && !self.other_clients {
return None;
}
let origin = if liquidation {
TradeOrigin::Liquidation
} else if own {
TradeOrigin::Order
} else {
TradeOrigin::External
};
let order_id = if own {
ib_order_id(execution.order_id)
} else {
foreign_order_id(execution)
};
Some((order_id, origin))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ResolvedExecution {
pub(super) instrument: InstrumentNameExchange,
pub(super) order_id: OrderId,
pub(super) origin: TradeOrigin,
pub(super) time: Option<DateTime<Utc>>,
}
impl ExecutionBuffer {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(ExecutionBufferInner::default())),
}
}
pub(super) fn add_execution(&self, execution: ExecutionData, resolved: ResolvedExecution) {
let exec_id = execution.execution.execution_id.clone();
let mut inner = self.inner.lock();
inner.pending.insert(
exec_id,
PendingExecution {
execution,
resolved,
buffered_at: Utc::now(),
},
);
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| {
let time = pending.resolved.time.unwrap_or(pending.buffered_at);
now.signed_duration_since(time).num_seconds() < max_age_secs
});
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}"))
}
pub(super) fn foreign_order_id(execution: &Execution) -> OrderId {
if execution.perm_id != 0 {
OrderId::new(format_smolstr!("perm:{}", execution.perm_id))
} else {
OrderId::new(format_smolstr!(
"client:{}:{}",
execution.client_id,
execution.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 ResolvedExecution {
instrument,
order_id,
origin,
time,
} = pending.resolved;
let (time_exchange, time_source) = match time {
Some(time) => (time, TimeSource::Venue),
None => {
warn!(exec_id = %exec.execution_id, time = %exec.time, "IBKR execution time unreadable, using now");
(Utc::now(), TimeSource::Host)
}
};
Trade {
id: TradeId::new(&exec.execution_id),
order_id,
instrument,
strategy: StrategyId::unknown(),
time_exchange,
side,
price,
quantity,
order_filled_quantity: Some(parse_decimal_or_warn(
exec.cumulative_quantity,
"exec.cumulative_quantity",
)),
fees,
origin,
position_side: None,
client_order_id: (!exec.order_reference.is_empty())
.then(|| ClientOrderId::new(exec.order_reference.as_str())),
time_source,
}
}
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, session_zone: Option<Tz>) -> Option<DateTime<Utc>> {
let s = s.trim();
let (date, rest) = s
.split_once(char::is_whitespace)
.map_or((s, ""), |(date, rest)| (date, rest.trim_start()));
if let Some((date, time)) = date.split_once('-') {
return rest
.is_empty()
.then(|| naive_ib_time(date, time))
.flatten()
.map(|naive| naive.and_utc());
}
let (time, zone) = rest
.split_once(char::is_whitespace)
.map_or((rest, None), |(time, zone)| (time, Some(zone.trim())));
let naive = naive_ib_time(date, time)?;
let zone = match zone {
Some(name) => resolve_zone(name)?,
None => session_zone?,
};
zone.from_local_datetime(&naive)
.earliest()
.map(|time| time.with_timezone(&Utc))
}
fn naive_ib_time(date: &str, time: &str) -> Option<NaiveDateTime> {
Some(NaiveDateTime::new(
NaiveDate::parse_from_str(date, "%Y%m%d").ok()?,
NaiveTime::parse_from_str(time, "%H:%M:%S").ok()?,
))
}
fn resolve_zone(name: &str) -> Option<Tz> {
if let Some(zone) = TZ_CACHE.with(|cache| cache.borrow().get(name).copied()) {
return zone;
}
let zone = name.parse::<Tz>().ok();
if zone.is_none() {
warn!(
timezone = name,
"Unknown time zone in IB times; not reading them"
);
}
TZ_CACHE.with(|cache| cache.borrow_mut().insert(SmolStr::new(name), zone));
zone
}
pub(super) fn session_zone(client: &ibapi::client::blocking::Client) -> Option<Tz> {
client.time_zone().and_then(chrono_zone)
}
fn chrono_zone(zone: &time_tz::Tz) -> Option<Tz> {
let name = time_tz::TimeZone::name(zone);
name.parse()
.inspect_err(|_| {
warn!(
timezone = name,
"IB Gateway's time zone unknown to chrono-tz"
)
})
.ok()
}
pub(super) const EXECUTION_DAYS: i32 = 7;
pub(super) const EXECUTIONS_COVER: Duration = Duration::from_secs(5 * 24 * 60 * 60);
pub(super) fn executions_filter() -> ExecutionFilter {
ExecutionFilter {
last_n_days: EXECUTION_DAYS,
..ExecutionFilter::default()
}
}
pub(super) fn executions_held_since(
start: DateTime<Utc>,
now: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
let covered_from = now - EXECUTIONS_COVER;
(start < covered_from).then_some(covered_from)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
fn utc(time: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(time).unwrap().to_utc()
}
#[test]
fn an_ib_time_is_read_in_the_zone_it_names() {
let session = Some(chrono_tz::Asia::Tokyo);
for (time, expected) in [
("20261027 12:48:41 Asia/Singapore", "2026-10-27T04:48:41Z"),
("20250418 10:30:00 US/Eastern", "2025-04-18T14:30:00Z"),
("20251218 10:30:00 America/New_York", "2025-12-18T15:30:00Z"),
("20260929 14:00:00 UTC", "2026-09-29T14:00:00Z"),
("20260115 09:00:00 MET", "2026-01-15T08:00:00Z"),
] {
assert_eq!(
parse_ib_timestamp(time, session),
Some(utc(expected)),
"{time}"
);
}
}
#[test]
fn an_ib_time_without_a_zone_is_read_in_the_sessions() {
let time = "20261027 12:48:41";
assert_eq!(
parse_ib_timestamp(time, Some(chrono_tz::Asia::Singapore)),
Some(utc("2026-10-27T04:48:41Z"))
);
assert_eq!(
parse_ib_timestamp("20261027 12:48:41", Some(chrono_tz::Asia::Singapore)),
Some(utc("2026-10-27T04:48:41Z")),
"older gateways put two spaces between the date and the time"
);
assert_eq!(parse_ib_timestamp(time, None), None);
}
#[test]
fn an_ib_time_in_an_unknown_zone_is_not_read() {
for time in [
"20261027 12:48:41 Some Standard Time",
"20261027 12:48:41 Nowhere/Atlantis",
] {
assert_eq!(
parse_ib_timestamp(time, Some(chrono_tz::UTC)),
None,
"{time}"
);
}
}
#[test]
fn an_ib_time_with_a_hyphen_is_utc() {
assert_eq!(
parse_ib_timestamp("20261027-04:48:41", Some(chrono_tz::Asia::Singapore)),
Some(utc("2026-10-27T04:48:41Z"))
);
assert_eq!(parse_ib_timestamp("20261027-04:48:41 UTC", None), None);
}
#[test]
fn an_ib_time_in_a_daylight_saving_change() {
assert_eq!(
parse_ib_timestamp("20251102 01:30:00 US/Eastern", None),
Some(utc("2025-11-02T05:30:00Z"))
);
assert_eq!(
parse_ib_timestamp("20250309 02:30:00 US/Eastern", None),
None
);
}
#[test]
fn an_ib_time_that_is_no_time_is_not_read() {
for time in [
"",
"invalid",
"20261027",
"20261027 25:00:00 UTC",
"20261327 12:00:00 UTC",
] {
assert_eq!(
parse_ib_timestamp(time, Some(chrono_tz::UTC)),
None,
"{time:?}"
);
}
}
#[test]
fn a_fill_without_a_time_it_can_read_is_marked_as_this_hosts() {
let buffer = ExecutionBuffer::new();
let at = utc("2026-10-27T04:48:41Z");
for (exec_id, time) in [("read", Some(at)), ("unread", None)] {
buffer.add_execution(
ExecutionData {
execution: ibapi::orders::Execution {
execution_id: exec_id.to_string(),
..Default::default()
},
..Default::default()
},
ResolvedExecution {
instrument: InstrumentNameExchange::new("AAPL"),
order_id: ib_order_id(1),
origin: TradeOrigin::Order,
time,
},
);
}
let before = Utc::now();
let mut trades = buffer.take_without_commission();
trades.sort_by(|a, b| a.id.cmp(&b.id));
assert_eq!(trades[0].id, TradeId::new("read"));
assert_eq!(
(trades[0].time_exchange, trades[0].time_source),
(at, TimeSource::Venue)
);
assert_eq!(trades[1].time_source, TimeSource::Host);
assert!((before..=Utc::now()).contains(&trades[1].time_exchange));
}
#[test]
fn a_zone_ibapi_resolved_is_the_chrono_zone_of_its_name() {
assert_eq!(
chrono_zone(time_tz::timezones::db::asia::SINGAPORE),
Some(chrono_tz::Asia::Singapore)
);
assert_eq!(
chrono_zone(time_tz::timezones::db::america::NEW_YORK),
Some(chrono_tz::America::New_York)
);
}
#[test]
fn clear_stale_clears_executions_older_than_the_age() {
let buffer = ExecutionBuffer::new();
let now = Utc::now();
let resolved = |time| ResolvedExecution {
instrument: InstrumentNameExchange::new("AAPL"),
order_id: ib_order_id(1),
origin: TradeOrigin::Order,
time,
};
let execution = |exec_id: &str| ExecutionData {
execution: ibapi::orders::Execution {
execution_id: exec_id.to_string(),
..Default::default()
},
..Default::default()
};
for (exec_id, time) in [
("old", Some(now - chrono::Duration::hours(2))),
("at-age", Some(now - chrono::Duration::hours(1))),
("recent", Some(now - chrono::Duration::minutes(1))),
("unread", None),
] {
buffer.add_execution(execution(exec_id), resolved(time));
}
buffer.inner.lock().pending.insert(
"unread-old".to_string(),
PendingExecution {
execution: execution("unread-old"),
resolved: resolved(None),
buffered_at: now - chrono::Duration::hours(2),
},
);
assert_eq!(buffer.clear_stale(Duration::from_secs(3_600)), 3);
let mut kept: Vec<_> = buffer
.take_without_commission()
.into_iter()
.map(|trade| trade.id)
.collect();
kept.sort();
assert_eq!(kept, [TradeId::new("recent"), TradeId::new("unread")]);
}
#[test]
fn a_fill_carries_the_order_reference_as_its_client_order_id() {
let buffer = ExecutionBuffer::new();
for (exec_id, reference) in [("a", "outside-1"), ("b", "")] {
buffer.add_execution(
ExecutionData {
execution: ibapi::orders::Execution {
execution_id: exec_id.to_string(),
order_reference: reference.to_string(),
..Default::default()
},
..Default::default()
},
ResolvedExecution {
instrument: InstrumentNameExchange::new("AAPL"),
order_id: ib_order_id(1),
origin: TradeOrigin::Order,
time: None,
},
);
}
let mut trades = buffer.take_without_commission();
trades.sort_by(|a, b| a.id.cmp(&b.id));
assert_eq!(
trades[0].client_order_id,
Some(ClientOrderId::new("outside-1"))
);
assert_eq!(trades[1].client_order_id, None);
}
#[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),
ResolvedExecution {
instrument: InstrumentNameExchange::new("AAPL"),
order_id: ib_order_id(1),
origin: TradeOrigin::Order,
time: None,
},
);
};
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),
origin: TradeOrigin::Order,
position_side: None,
client_order_id: None,
time_source: TimeSource::Venue,
}
}
#[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 executions_held_since_is_the_cover_start_only_when_the_span_starts_before_it() {
let now = Utc.with_ymd_and_hms(2026, 10, 23, 9, 0, 0).unwrap();
let covered_from = Utc.with_ymd_and_hms(2026, 10, 18, 9, 0, 0).unwrap();
let before = covered_from - chrono::TimeDelta::seconds(1);
assert_eq!(executions_held_since(before, now), Some(covered_from));
assert_eq!(executions_held_since(covered_from, now), None);
assert_eq!(executions_held_since(now, now), None);
}
#[test]
fn executions_filter_reads_every_client_over_the_execution_days() {
let filter = executions_filter();
assert_eq!(filter.last_n_days, 7);
assert_eq!(filter.client_id, None);
assert!(filter.specific_dates.is_empty());
}
}