use super::{
ListingLock,
ended_orders::{EarlierCompletions, EndedOrderReader, release_ended},
execution::{
ExecutionBuffer, ExecutionRevision, ExecutionScope, executions_filter,
executions_held_since, revision_of,
},
listed_cids,
order::{OrderIdMap, PendingCancels},
resolve_execution,
};
use crate::{
AccountEventKind, UnindexedAccountEvent,
client::{
dedup::{DEDUP_CACHE_SIZE, SharedDedupCache, dedup_key_from_event, is_duplicate},
order_recovery::{
EventSender, KnownLiveOrders, NoPendingFills, OpenListing, OrderLookup,
SharedKnownLiveOrders, UncheckedOrders, recover_ended_orders,
},
},
emit_stream_terminated,
error::{StreamTerminationReason, UnindexedClientError},
fill_recovery::{FillRecoveryFailure, FillRecoveryGap, FillRecoveryScope},
order::id::ClientOrderId,
trade::{Trade, TradeAmendment, TradeAmendmentKind, TradeId, TradeOrigin},
};
use chrono::{DateTime, Utc};
use fnv::FnvHashSet;
use ibapi::{
TRANSPORT_RECONNECT_CODE,
client::blocking::{Client, NoticeStream},
orders::{ExecutionData, Executions},
subscriptions::SubscriptionItem,
};
use lru::LruCache;
use parking_lot::Mutex;
use rustrade_instrument::{
asset::name::AssetNameExchange, exchange::ExchangeId, ibkr::ContractRegistry,
instrument::name::InstrumentNameExchange,
};
use smol_str::SmolStr;
use std::{
num::NonZeroUsize,
sync::Arc,
time::{Duration, Instant},
};
use tokio::sync::mpsc;
use tracing::{debug, error, info, trace, warn};
const REVISIONS_REMEMBERED: NonZeroUsize = match NonZeroUsize::new(DEDUP_CACHE_SIZE) {
Some(capacity) => capacity,
None => panic!("the dedup cache size is non-zero"),
};
const POLL_INTERVAL: Duration = Duration::from_secs(1);
pub(super) const CLIENT_SHUT_DOWN: &str = "IBKR client shut down: ibapi gave up reconnecting \
to TWS/Gateway, the client was disconnected, or TWS/Gateway ended the API session";
const RECOVERY_LOOKBACK: chrono::Duration = chrono::Duration::seconds(30);
const RECOVERY_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_RECOVERY_FAILURES: u32 = 3;
const CODE_LINK_LOST: i32 = 1100;
const CODE_LINK_RESTORED_DATA_LOST: i32 = 1101;
const CODE_LINK_RESTORED_DATA_KEPT: i32 = 1102;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ConnectivityNotice {
Lost,
Restored,
}
pub(super) fn classify_notice(code: i32) -> Option<ConnectivityNotice> {
match code {
CODE_LINK_LOST => Some(ConnectivityNotice::Lost),
CODE_LINK_RESTORED_DATA_LOST | CODE_LINK_RESTORED_DATA_KEPT | TRANSPORT_RECONNECT_CODE => {
Some(ConnectivityNotice::Restored)
}
_ => None,
}
}
pub(super) fn is_replayed_execution(execution: &ExecutionData) -> bool {
execution.request_id > 0
}
#[derive(Debug)]
pub(super) struct GapTracker {
last_connected: DateTime<Utc>,
gap_start: Option<DateTime<Utc>>,
transport_down: bool,
recovery_due: bool,
failures: u32,
}
impl GapTracker {
pub(super) fn new(now: DateTime<Utc>) -> Self {
Self {
last_connected: now,
gap_start: None,
transport_down: false,
recovery_due: false,
failures: 0,
}
}
pub(super) fn observe_connection(&mut self, connected: bool, now: DateTime<Utc>) {
if connected {
if std::mem::take(&mut self.transport_down) {
self.recovery_due = true;
}
self.last_connected = now;
} else {
self.transport_down = true;
self.gap_start.get_or_insert(self.last_connected);
}
}
pub(super) fn observe_notice(&mut self, notice: ConnectivityNotice, now: DateTime<Utc>) {
self.gap_start.get_or_insert(now);
if notice == ConnectivityNotice::Restored {
self.recovery_due = true;
}
}
pub(super) fn recovery_floor(&self, connected: bool) -> Option<DateTime<Utc>> {
if !(self.recovery_due && connected) {
return None;
}
self.gap_start.map(|start| start - RECOVERY_LOOKBACK)
}
pub(super) fn recovered(&mut self) {
self.gap_start = None;
self.recovery_due = false;
self.failures = 0;
}
pub(super) fn recovery_failed(&mut self, transport_lost: bool) -> bool {
if transport_lost {
return false;
}
self.failures += 1;
self.failures >= MAX_RECOVERY_FAILURES
}
}
#[derive(Debug, Clone)]
pub(super) struct EventSink {
tx: Arc<Mutex<Option<mpsc::UnboundedSender<UnindexedAccountEvent>>>>,
dedup: SharedDedupCache,
revisions: Arc<Mutex<LruCache<SmolStr, DeliveredRevision>>>,
known: SharedKnownLiveOrders,
}
#[derive(Debug)]
struct DeliveredRevision {
revision: u32,
id: TradeId,
origin: TradeOrigin,
}
impl EventSink {
pub(super) fn new(
tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
dedup: SharedDedupCache,
known: SharedKnownLiveOrders,
) -> Self {
Self {
tx: Arc::new(Mutex::new(Some(tx))),
dedup,
revisions: Arc::new(Mutex::new(LruCache::new(REVISIONS_REMEMBERED))),
known,
}
}
pub(super) fn send(&self, event: UnindexedAccountEvent) -> bool {
let known = KnownLiveOrders::observes(&event.kind).then(|| {
let mut known = self.known.lock();
known.observe(&event);
known
});
let sent = self.send_event(event);
drop(known);
sent
}
pub(super) fn send_execution(
&self,
mut trade: Trade<AssetNameExchange, InstrumentNameExchange>,
) -> bool {
let Some(revision) = ExecutionRevision::parse(&trade.id.0) else {
return self.send_trade(trade);
};
let execution = SmolStr::new(revision.execution);
let number = revision.revision;
let mut revisions = self.revisions.lock();
let original = match revisions.peek(&execution) {
Some(delivered) if delivered.revision >= number => {
trace!(
exec_id = %trade.id,
delivered = %delivered.id,
"IBKR execution already delivered at this revision or a later one, skipping"
);
return true;
}
Some(delivered) => {
if trade.origin == TradeOrigin::Order {
trade.origin = delivered.origin;
}
Some(delivered.id.clone())
}
None => revision.previous_id(),
};
let id = trade.id.clone();
let origin = trade.origin;
let sent = match original {
None => self.send_trade(trade),
Some(original) => {
debug!(%original, correction = %id, "IBKR execution corrected, reporting it");
self.send(UnindexedAccountEvent {
exchange: ExchangeId::Ibkr,
kind: AccountEventKind::TradeAmended(TradeAmendment::new(
trade.instrument.clone(),
trade.order_id.clone(),
Utc::now(),
Some(original),
TradeAmendmentKind::Corrected { replacement: trade },
)),
})
}
};
if sent {
revisions.put(
execution,
DeliveredRevision {
revision: number,
id,
origin,
},
);
}
sent
}
fn send_trade(&self, trade: Trade<AssetNameExchange, InstrumentNameExchange>) -> bool {
let event = UnindexedAccountEvent {
exchange: ExchangeId::Ibkr,
kind: AccountEventKind::Trade(trade),
};
if let Some(key) = dedup_key_from_event(&event)
&& is_duplicate(&self.dedup, key)
{
trace!("IBKR dedup: skipping trade already delivered");
return true;
}
self.send(event)
}
pub(super) fn terminate(&self, reason: StreamTerminationReason) {
if let Some(tx) = self.tx.lock().take() {
emit_stream_terminated(&tx, ExchangeId::Ibkr, reason);
}
}
pub(super) fn is_open(&self) -> bool {
self.tx.lock().as_ref().is_some_and(|tx| !tx.is_closed())
}
}
impl EventSender for EventSink {
fn send_event(&self, event: UnindexedAccountEvent) -> bool {
self.tx
.lock()
.as_ref()
.is_some_and(|tx| tx.send(event).is_ok())
}
}
#[derive(Debug, thiserror::Error)]
pub(super) enum RecoveryError {
#[error("executions request failed: {0}")]
Ibapi(#[source] ibapi::Error),
#[error("executions request did not complete within {}s", RECOVERY_TIMEOUT.as_secs())]
TimedOut,
#[error("account stream ended during recovery")]
StreamClosed,
}
impl RecoveryError {
fn is_transport_loss(&self) -> bool {
matches!(self, Self::Ibapi(e) if super::is_transport_loss(e))
}
fn failure(&self) -> FillRecoveryFailure {
match self {
Self::TimedOut => FillRecoveryFailure::TimedOut {
timeout_secs: RECOVERY_TIMEOUT.as_secs(),
},
Self::Ibapi(_) | Self::StreamClosed => FillRecoveryFailure::Request(self.to_string()),
}
}
}
pub(super) struct RecoveredFills<'a> {
floor: DateTime<Utc>,
scope: ExecutionScope,
contracts: &'a ContractRegistry,
awaiting_commission: ExecutionBuffer,
trades: Vec<Trade<AssetNameExchange, InstrumentNameExchange>>,
unparseable: usize,
}
impl<'a> RecoveredFills<'a> {
pub(super) fn new(
floor: DateTime<Utc>,
scope: ExecutionScope,
contracts: &'a ContractRegistry,
) -> Self {
Self {
floor,
scope,
contracts,
awaiting_commission: ExecutionBuffer::new(),
trades: Vec::new(),
unparseable: 0,
}
}
pub(super) fn push(&mut self, item: Executions) {
match item {
Executions::ExecutionData(execution) => {
match super::execution::parse_ib_timestamp(&execution.execution.time) {
Some(time) if time < self.floor => return,
Some(_) => {}
None => {
debug!(
exec_id = %execution.execution.execution_id,
time = %execution.execution.time,
"Unparseable timestamp in recovered execution, keeping it"
);
self.unparseable += 1;
}
}
if let Some(resolved) = resolve_execution(&execution, self.scope, self.contracts) {
self.awaiting_commission.add_execution(execution, resolved);
}
}
Executions::CommissionReport(report) => {
if let Some(trade) = self.awaiting_commission.complete_with_commission(&report) {
self.trades.push(trade);
}
}
}
}
pub(super) fn finish(
self,
pending: &ExecutionBuffer,
) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
if self.unparseable > 0 {
warn!(
count = self.unparseable,
"Recovered IBKR executions with unparseable timestamps were kept regardless of \
the recovery window"
);
}
let moved = self.awaiting_commission.drain_into(pending);
if moved > 0 {
warn!(
count = moved,
"Recovered IBKR executions arrived without a commission report; they are \
delivered only if the report reaches the account stream later"
);
}
let mut trades = self.trades;
trades.sort_by_cached_key(|trade| revision_of(&trade.id));
trades
}
}
fn retention_gap(
floor: DateTime<Utc>,
now: DateTime<Utc>,
) -> Option<FillRecoveryGap<InstrumentNameExchange>> {
let held_since = executions_held_since(floor, now)?;
Some(FillRecoveryGap::new(
FillRecoveryScope::AllInstruments,
floor,
(held_since - chrono::Duration::milliseconds(1)).max(floor),
1,
FillRecoveryFailure::VenueRetention { held_since },
))
}
fn report_retention_gap(sink: &EventSink, floor: DateTime<Utc>, now: DateTime<Utc>) -> bool {
retention_gap(floor, now).is_none_or(|gap| {
warn!(
since = %gap.start,
until = %gap.end,
"Part of the IBKR fill gap is older than IB holds executions for and cannot be \
recovered: reconcile it from another source"
);
sink.send(UnindexedAccountEvent::new(
ExchangeId::Ibkr,
AccountEventKind::FillRecoveryGaveUp(gap),
))
})
}
fn recover_fills(
client: &Client,
floor: DateTime<Utc>,
scope: ExecutionScope,
contracts: &ContractRegistry,
pending: &ExecutionBuffer,
sink: &EventSink,
) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, RecoveryError> {
let subscription = client
.executions(executions_filter())
.map_err(RecoveryError::Ibapi)?;
let deadline = Instant::now() + RECOVERY_TIMEOUT;
let mut fills = RecoveredFills::new(floor, scope, contracts);
loop {
if !sink.is_open() {
return Err(RecoveryError::StreamClosed);
}
let slice = deadline
.saturating_duration_since(Instant::now())
.min(POLL_INTERVAL);
let waited = Instant::now();
match subscription.next_timeout(slice) {
Some(Ok(SubscriptionItem::Data(item))) => fills.push(item),
Some(Ok(SubscriptionItem::Notice(notice))) => {
debug!(%notice, "Notice during IBKR fill recovery");
}
Some(Err(e)) => return Err(RecoveryError::Ibapi(e)),
None if Instant::now() >= deadline => return Err(RecoveryError::TimedOut),
None if returned_early(waited.elapsed(), slice) => break,
None => {}
}
}
Ok(fills.finish(pending))
}
pub(super) fn returned_early(elapsed: Duration, timeout: Duration) -> bool {
elapsed < timeout / 2
}
pub(super) struct RecoveryWatcher {
pub(super) client: Arc<Client>,
pub(super) notices: NoticeStream,
pub(super) contracts: ContractRegistry,
pub(super) order_ids: OrderIdMap,
pub(super) pending_cancels: PendingCancels,
pub(super) pending: ExecutionBuffer,
pub(super) known: SharedKnownLiveOrders,
pub(super) listings: ListingLock,
pub(super) earlier_completions: EarlierCompletions,
pub(super) sink: EventSink,
pub(super) runtime: tokio::runtime::Handle,
pub(super) scope: ExecutionScope,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Recovery {
Done,
Retry,
StreamClosed,
}
impl RecoveryWatcher {
pub(super) fn run(self) {
let mut tracker = GapTracker::new(Utc::now());
let mut unchecked = UncheckedOrders::default();
while self.sink.is_open() {
let connected = self.client.is_connected();
tracker.observe_connection(connected, Utc::now());
while let Some(notice) = self.notices.try_next() {
observe_notice(&mut tracker, ¬ice);
}
if let Some(floor) = tracker.recovery_floor(connected) {
match self.recover(&mut tracker, floor) {
Recovery::Done => unchecked.open(self.known.lock().instruments()),
Recovery::Retry => {}
Recovery::StreamClosed => return,
}
}
if connected && order_check_due(&unchecked) {
self.check_orders(&mut unchecked);
}
let waited = Instant::now();
match self.notices.next_timeout(POLL_INTERVAL) {
Some(notice) => observe_notice(&mut tracker, ¬ice),
None if returned_early(waited.elapsed(), POLL_INTERVAL) => {
debug!("IBKR notice stream closed; recovery stops");
return;
}
None => {}
}
}
}
fn recover(&self, tracker: &mut GapTracker, floor: DateTime<Utc>) -> Recovery {
let requested_at = Utc::now();
match recover_fills(
&self.client,
floor,
self.scope,
&self.contracts,
&self.pending,
&self.sink,
) {
Ok(trades) => {
info!(
count = trades.len(),
since = %floor,
"Recovered IBKR fills after a gap in event delivery"
);
for trade in trades {
if !self.sink.send_execution(trade) {
return Recovery::StreamClosed;
}
}
if !report_retention_gap(&self.sink, floor, requested_at) {
return Recovery::StreamClosed;
}
tracker.recovered();
Recovery::Done
}
Err(RecoveryError::StreamClosed) => Recovery::StreamClosed,
Err(e) => {
warn!(error = %e, since = %floor, "IBKR fill recovery failed");
if !tracker.recovery_failed(e.is_transport_loss()) {
return Recovery::Retry;
}
error!(
error = %e,
since = %floor,
"Giving up IBKR fill recovery: reconcile the gap with fetch_trades"
);
let gave_up = UnindexedAccountEvent::new(
ExchangeId::Ibkr,
AccountEventKind::FillRecoveryGaveUp(FillRecoveryGap::new(
FillRecoveryScope::AllInstruments,
floor,
Utc::now().max(floor),
MAX_RECOVERY_FAILURES,
e.failure(),
)),
);
if !self.sink.send(gave_up) {
return Recovery::StreamClosed;
}
tracker.recovered();
Recovery::Done
}
}
}
fn check_orders(&self, unchecked: &mut UncheckedOrders) {
let reader = EndedOrderReader::new(
&self.client,
&self.listings,
&self.contracts,
&self.order_ids,
&self.pending_cancels,
&self.earlier_completions,
);
let list_open = |_instruments: Vec<_>| std::future::ready(self.listed_cids());
let lookup = |key| {
let found = reader.lookup(&key);
if let Ok(OrderLookup::Ended(order)) = &found {
release_ended(order, &self.order_ids, &self.pending_cancels);
}
std::future::ready(found)
};
self.runtime.block_on(recover_ended_orders(
ExchangeId::Ibkr,
&self.known,
unchecked,
&NoPendingFills,
&self.sink,
OpenListing::Batched,
list_open,
lookup,
));
}
fn listed_cids(&self) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
let listing = self.listings.open_orders(&self.client)?;
Ok(listed_cids(
listing,
self.client.client_id(),
&self.order_ids,
))
}
}
fn order_check_due(unchecked: &UncheckedOrders) -> bool {
unchecked
.next_due(&NoPendingFills)
.is_some_and(|due| due <= tokio::time::Instant::now())
}
fn observe_notice(tracker: &mut GapTracker, notice: &ibapi::Notice) {
if let Some(kind) = classify_notice(notice.code) {
info!(code = notice.code, message = %notice.message, "IBKR connectivity notice");
tracker.observe_notice(kind, Utc::now());
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
use super::*;
use crate::client::dedup::new_dedup_cache;
use crate::client::ibkr::execution::EXECUTIONS_COVER;
use ibapi::{
contracts::Contract,
orders::{CommissionReport, Execution},
};
use rust_decimal::Decimal;
use rustrade_instrument::Side;
fn at(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
}
#[test]
fn classify_notice_maps_connectivity_codes() {
assert_eq!(classify_notice(1100), Some(ConnectivityNotice::Lost));
assert_eq!(classify_notice(1101), Some(ConnectivityNotice::Restored));
assert_eq!(classify_notice(1102), Some(ConnectivityNotice::Restored));
assert_eq!(
classify_notice(TRANSPORT_RECONNECT_CODE),
Some(ConnectivityNotice::Restored)
);
for code in [2104, 2106, 2158, 1300, 0] {
assert_eq!(classify_notice(code), None, "code {code}");
}
}
#[test]
fn early_return_means_nothing_left_to_wait_for() {
let timeout = Duration::from_secs(1);
assert!(returned_early(Duration::ZERO, timeout));
assert!(returned_early(Duration::from_millis(100), timeout));
assert!(!returned_early(timeout, timeout));
assert!(!returned_early(Duration::from_millis(990), timeout));
assert!(!returned_early(Duration::from_millis(1100), timeout));
}
#[test]
fn replayed_execution_is_one_answering_a_request() {
let with_request_id = |request_id| ExecutionData {
request_id,
..ExecutionData::default()
};
assert!(!is_replayed_execution(&with_request_id(-1)));
assert!(!is_replayed_execution(&with_request_id(0)));
assert!(is_replayed_execution(&with_request_id(1_500_000_000)));
}
#[test]
fn tracker_is_quiet_without_a_gap() {
let mut tracker = GapTracker::new(at("2026-09-29T14:00:00Z"));
tracker.observe_connection(true, at("2026-09-29T14:00:01Z"));
assert_eq!(tracker.recovery_floor(true), None);
}
#[test]
fn observed_drop_recovers_from_last_connected_sample() {
let mut tracker = GapTracker::new(at("2026-09-29T14:00:00Z"));
tracker.observe_connection(true, at("2026-09-29T14:00:10Z"));
tracker.observe_connection(false, at("2026-09-29T14:00:11Z"));
tracker.observe_connection(false, at("2026-09-29T14:05:00Z"));
assert_eq!(tracker.recovery_floor(false), None);
tracker.observe_connection(true, at("2026-09-29T14:05:01Z"));
tracker.observe_notice(ConnectivityNotice::Restored, at("2026-09-29T14:05:01Z"));
assert_eq!(
tracker.recovery_floor(true),
Some(at("2026-09-29T14:00:10Z") - RECOVERY_LOOKBACK)
);
}
#[test]
fn observed_reconnect_makes_recovery_due_without_a_notice() {
let mut tracker = GapTracker::new(at("2026-09-29T14:00:00Z"));
tracker.observe_connection(true, at("2026-09-29T14:00:10Z"));
tracker.observe_connection(false, at("2026-09-29T14:00:11Z"));
tracker.observe_connection(true, at("2026-09-29T14:00:40Z"));
assert_eq!(
tracker.recovery_floor(true),
Some(at("2026-09-29T14:00:10Z") - RECOVERY_LOOKBACK)
);
}
#[test]
fn restoration_without_an_observed_drop_recovers_from_the_notice() {
let mut tracker = GapTracker::new(at("2026-09-29T14:00:00Z"));
tracker.observe_connection(true, at("2026-09-29T14:00:10Z"));
tracker.observe_connection(true, at("2026-09-29T14:00:11Z"));
tracker.observe_notice(ConnectivityNotice::Restored, at("2026-09-29T14:00:11Z"));
assert_eq!(
tracker.recovery_floor(true),
Some(at("2026-09-29T14:00:11Z") - RECOVERY_LOOKBACK)
);
}
#[test]
fn link_loss_notice_opens_gap_that_restoration_closes() {
let mut tracker = GapTracker::new(at("2026-09-29T14:00:00Z"));
tracker.observe_notice(ConnectivityNotice::Lost, at("2026-09-29T14:01:00Z"));
tracker.observe_connection(true, at("2026-09-29T14:02:00Z"));
assert_eq!(tracker.recovery_floor(true), None);
tracker.observe_notice(ConnectivityNotice::Restored, at("2026-09-29T14:03:00Z"));
assert_eq!(
tracker.recovery_floor(true),
Some(at("2026-09-29T14:01:00Z") - RECOVERY_LOOKBACK)
);
}
#[test]
fn retention_gap_starts_at_the_floor_and_ends_before_the_cover() {
let now = at("2026-10-24T12:00:00Z");
let held_since = now - EXECUTIONS_COVER;
let floor = held_since - chrono::Duration::hours(3);
let gap = retention_gap(floor, now).unwrap();
assert_eq!(gap.start, floor);
assert_eq!(gap.end, held_since - chrono::Duration::milliseconds(1));
assert_eq!(gap.attempts, 1);
assert_eq!(gap.scope, FillRecoveryScope::AllInstruments);
assert!(matches!(
gap.reason,
FillRecoveryFailure::VenueRetention { held_since: h } if h == held_since
));
}
#[test]
fn retention_gap_is_absent_when_the_cover_reaches_the_floor() {
let now = at("2026-10-24T12:00:00Z");
let held_since = now - EXECUTIONS_COVER;
assert!(retention_gap(held_since, now).is_none());
assert!(retention_gap(held_since + chrono::Duration::seconds(1), now).is_none());
assert!(retention_gap(held_since - chrono::Duration::seconds(1), now).is_some());
}
#[test]
fn retention_gap_ends_before_held_since_and_not_before_its_start() {
let now = at("2026-10-24T12:00:00Z");
let held_since = now - EXECUTIONS_COVER;
let early = held_since - chrono::Duration::hours(3);
let gap = retention_gap(early, now).unwrap();
assert_eq!(gap.start, early);
assert_eq!(gap.end, held_since - chrono::Duration::milliseconds(1));
let just_before = held_since - chrono::Duration::nanoseconds(1);
let gap = retention_gap(just_before, now).unwrap();
assert_eq!((gap.start, gap.end), (just_before, just_before));
}
#[test]
fn retention_gap_is_sent_and_reports_a_closed_stream() {
let now = at("2026-10-24T12:00:00Z");
let old = now - EXECUTIONS_COVER - chrono::Duration::hours(1);
let (sink, mut rx) = sink();
assert!(report_retention_gap(&sink, now, now));
assert!(drain(&mut rx).is_empty());
assert!(report_retention_gap(&sink, old, now));
let events = drain(&mut rx);
assert_eq!(events.len(), 1);
assert!(matches!(
events[0].kind,
AccountEventKind::FillRecoveryGaveUp(_)
));
drop(rx);
assert!(!report_retention_gap(&sink, old, now));
assert!(report_retention_gap(&sink, now, now));
}
#[test]
fn recovery_waits_for_the_transport() {
let mut tracker = GapTracker::new(at("2026-09-29T14:00:00Z"));
tracker.observe_notice(ConnectivityNotice::Restored, at("2026-09-29T14:00:05Z"));
assert_eq!(tracker.recovery_floor(false), None);
assert!(tracker.recovery_floor(true).is_some());
}
#[test]
fn recovered_gap_is_cleared() {
let mut tracker = GapTracker::new(at("2026-09-29T14:00:00Z"));
tracker.observe_notice(ConnectivityNotice::Restored, at("2026-09-29T14:00:05Z"));
tracker.recovered();
assert_eq!(tracker.recovery_floor(true), None);
tracker.observe_connection(true, at("2026-09-29T15:00:00Z"));
tracker.observe_connection(false, at("2026-09-29T15:00:01Z"));
tracker.observe_notice(ConnectivityNotice::Restored, at("2026-09-29T15:00:30Z"));
assert_eq!(
tracker.recovery_floor(true),
Some(at("2026-09-29T15:00:00Z") - RECOVERY_LOOKBACK)
);
}
#[test]
fn transport_loss_during_recovery_keeps_the_gap_and_is_not_counted() {
let mut tracker = GapTracker::new(at("2026-09-29T14:00:00Z"));
tracker.observe_connection(false, at("2026-09-29T14:00:01Z"));
tracker.observe_notice(ConnectivityNotice::Restored, at("2026-09-29T14:01:00Z"));
let floor = tracker.recovery_floor(true);
for _ in 0..MAX_RECOVERY_FAILURES * 2 {
assert!(!tracker.recovery_failed(true));
}
tracker.observe_connection(false, at("2026-09-29T14:01:01Z"));
assert_eq!(tracker.recovery_floor(true), floor);
}
#[test]
fn repeated_other_failures_abandon_recovery() {
let mut tracker = GapTracker::new(at("2026-09-29T14:00:00Z"));
tracker.observe_notice(ConnectivityNotice::Restored, at("2026-09-29T14:00:05Z"));
for _ in 1..MAX_RECOVERY_FAILURES {
assert!(!tracker.recovery_failed(false));
}
assert!(tracker.recovery_failed(false));
}
#[test]
fn success_resets_the_failure_count() {
let mut tracker = GapTracker::new(at("2026-09-29T14:00:00Z"));
tracker.observe_notice(ConnectivityNotice::Restored, at("2026-09-29T14:00:05Z"));
for _ in 1..MAX_RECOVERY_FAILURES {
assert!(!tracker.recovery_failed(false));
}
tracker.recovered();
assert!(!tracker.recovery_failed(false));
}
fn trade(id: &str) -> Trade<AssetNameExchange, InstrumentNameExchange> {
use crate::{
order::id::{OrderId, StrategyId},
trade::{AssetFees, TradeId},
};
Trade {
id: TradeId::new(id),
order_id: OrderId::new("cid"),
instrument: InstrumentNameExchange::new("AAPL"),
strategy: StrategyId::unknown(),
time_exchange: at("2026-09-29T14:00:00Z"),
side: Side::Buy,
price: Decimal::from(100),
quantity: Decimal::ONE,
order_filled_quantity: Some(Decimal::ONE),
fees: AssetFees::new(AssetNameExchange::from("USD"), Decimal::ONE, None),
origin: TradeOrigin::Order,
position_side: None,
client_order_id: None,
}
}
fn sink() -> (EventSink, mpsc::UnboundedReceiver<UnindexedAccountEvent>) {
let (tx, rx) = mpsc::unbounded_channel();
(
EventSink::new(
tx,
new_dedup_cache(),
KnownLiveOrders::shared(ExchangeId::Ibkr),
),
rx,
)
}
#[test]
fn the_sink_teaches_the_live_orders_what_it_sends() {
use crate::order::{
Order, OrderKey, OrderKind, TimeInForce,
id::{OrderId, StrategyId},
state::{Cancelled, Open, OrderState},
};
use rust_decimal_macros::dec;
let known = KnownLiveOrders::shared(ExchangeId::Ibkr);
let (tx, mut rx) = mpsc::unbounded_channel();
let sink = EventSink::new(tx, new_dedup_cache(), known.clone());
let snapshot = |cid: &str, state| {
UnindexedAccountEvent::new(
ExchangeId::Ibkr,
AccountEventKind::OrderSnapshot(crate::Snapshot::new(Order {
key: OrderKey {
exchange: ExchangeId::Ibkr,
instrument: InstrumentNameExchange::new("AAPL"),
strategy: StrategyId::unknown(),
cid: ClientOrderId::new(cid),
},
side: Side::Buy,
price: Some(dec!(150)),
quantity: dec!(10),
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
state,
})),
)
};
let open = || {
OrderState::active(Open::new(
crate::order::id::VenueOrderId::Assigned(OrderId::new("7")),
Utc::now(),
Decimal::ZERO,
))
};
assert!(sink.send(snapshot("a", open())));
assert!(known.lock().contains(&ClientOrderId::new("a")));
assert!(sink.send(snapshot(
"a",
OrderState::inactive(Cancelled::new(OrderId::new("7"), Utc::now(), None))
)));
assert!(!known.lock().contains(&ClientOrderId::new("a")));
assert!(sink.send_event(snapshot("b", open())));
assert!(!known.lock().contains(&ClientOrderId::new("b")));
assert_eq!(drain(&mut rx).len(), 3);
}
#[test]
fn a_failed_recovery_states_why() {
assert_eq!(
RecoveryError::TimedOut.failure(),
FillRecoveryFailure::TimedOut {
timeout_secs: RECOVERY_TIMEOUT.as_secs()
}
);
assert!(matches!(
RecoveryError::Ibapi(ibapi::Error::Simple("refused".into())).failure(),
FillRecoveryFailure::Request(reason) if reason.contains("refused")
));
}
fn drain(
rx: &mut mpsc::UnboundedReceiver<UnindexedAccountEvent>,
) -> Vec<UnindexedAccountEvent> {
std::iter::from_fn(|| rx.try_recv().ok()).collect()
}
#[test]
fn sink_delivers_a_trade_once() {
let (sink, mut rx) = sink();
assert!(sink.send_trade(trade("e1")));
assert!(sink.send_trade(trade("e1")));
assert!(sink.send_trade(trade("e2")));
let ids: Vec<_> = drain(&mut rx)
.into_iter()
.map(|event| match event.kind {
AccountEventKind::Trade(trade) => trade.id,
other => panic!("expected a trade, got {other:?}"),
})
.collect();
assert_eq!(ids.len(), 2);
assert_eq!(ids[0].0.as_str(), "e1");
assert_eq!(ids[1].0.as_str(), "e2");
}
fn delivered(event: UnindexedAccountEvent) -> String {
match event.kind {
AccountEventKind::Trade(trade) => trade.id.0.to_string(),
AccountEventKind::TradeAmended(amendment) => match amendment.kind {
TradeAmendmentKind::Corrected { replacement } => format!(
"{} corrected by {}",
amendment
.original
.expect("an IBKR correction names its original"),
replacement.id
),
other => panic!("expected a correction, got {other:?}"),
},
other => panic!("expected a trade or an amendment, got {other:?}"),
}
}
fn delivered_all(rx: &mut mpsc::UnboundedReceiver<UnindexedAccountEvent>) -> Vec<String> {
drain(rx).into_iter().map(delivered).collect()
}
#[test]
fn sink_reports_a_correction_of_a_delivered_execution() {
let (sink, mut rx) = sink();
let mut correction = trade("x.01.02");
correction.price = Decimal::from(101);
assert!(sink.send_execution(trade("x.01.01")));
assert!(sink.send_execution(correction.clone()));
let events = drain(&mut rx);
assert_eq!(events.len(), 2, "{events:?}");
let AccountEventKind::TradeAmended(amendment) = &events[1].kind else {
panic!("expected an amendment, got {:?}", events[1]);
};
assert_eq!(amendment.instrument, correction.instrument);
assert_eq!(amendment.order_id, correction.order_id);
assert_eq!(amendment.original, Some(TradeId::new("x.01.01")));
assert_eq!(
amendment.kind,
TradeAmendmentKind::Corrected {
replacement: correction
}
);
}
#[test]
fn a_correction_keeps_the_origin_it_corrects() {
let replacement = |events: &[UnindexedAccountEvent]| {
let AccountEventKind::TradeAmended(TradeAmendment {
kind: TradeAmendmentKind::Corrected { replacement },
..
}) = &events[1].kind
else {
panic!("expected a correction, got {:?}", events[1]);
};
replacement.origin
};
let (unflagged, mut rx) = sink();
assert!(unflagged.send_execution(trade("x.01.01").with_origin(TradeOrigin::Liquidation)));
assert!(unflagged.send_execution(trade("x.01.02")));
assert_eq!(replacement(&drain(&mut rx)), TradeOrigin::Liquidation);
let (flagged, mut rx) = sink();
assert!(flagged.send_execution(trade("y.01.01")));
assert!(flagged.send_execution(trade("y.01.02").with_origin(TradeOrigin::Liquidation)));
assert_eq!(replacement(&drain(&mut rx)), TradeOrigin::Liquidation);
}
#[test]
fn sink_reports_a_correction_of_an_execution_it_did_not_deliver() {
let (sink, mut rx) = sink();
assert!(sink.send_execution(trade("x.01.02")));
assert_eq!(delivered_all(&mut rx), ["x.01.01 corrected by x.01.02"]);
}
#[test]
fn sink_names_the_latest_revision_it_delivered() {
let (sink, mut rx) = sink();
for id in ["x.01.01", "x.01.02", "x.01.04"] {
assert!(sink.send_execution(trade(id)));
}
assert_eq!(
delivered_all(&mut rx),
[
"x.01.01",
"x.01.01 corrected by x.01.02",
"x.01.02 corrected by x.01.04"
]
);
}
#[test]
fn sink_drops_a_revision_older_than_one_delivered() {
let (sink, mut rx) = sink();
for id in ["x.01.02", "x.01.01", "x.01.02", "y"] {
assert!(sink.send_execution(trade(id)));
}
assert_eq!(
delivered_all(&mut rx),
["x.01.01 corrected by x.01.02", "y"]
);
}
#[test]
fn sink_delivers_an_execution_without_a_revision_once() {
let (sink, mut rx) = sink();
for id in ["e1", "e1", "x.01.01", "x.01.01"] {
assert!(sink.send_execution(trade(id)));
}
assert_eq!(delivered_all(&mut rx), ["e1", "x.01.01"]);
}
#[test]
fn nothing_follows_stream_terminated() {
let (sink, mut rx) = sink();
let other = sink.clone();
sink.terminate(StreamTerminationReason::Error("first".into()));
other.terminate(StreamTerminationReason::Error("second".into()));
assert!(!other.send_trade(trade("e1")));
assert!(!sink.is_open());
let events = drain(&mut rx);
assert_eq!(events.len(), 1, "exactly one terminal event: {events:?}");
assert!(matches!(
&events[0].kind,
AccountEventKind::StreamTerminated(StreamTerminationReason::Error(reason)) if reason == "first"
));
assert!(matches!(
rx.try_recv(),
Err(mpsc::error::TryRecvError::Disconnected)
));
}
#[test]
fn sink_closes_when_consumer_goes() {
let (sink, rx) = sink();
assert!(sink.is_open());
drop(rx);
assert!(!sink.is_open());
assert!(!sink.send_trade(trade("e1")));
}
const CON_ID: i32 = 265598;
const IB_ORDER_ID: i32 = 7;
fn registered() -> ContractRegistry {
let contracts = ContractRegistry::new();
contracts
.register(
InstrumentNameExchange::new("AAPL"),
Contract {
contract_id: CON_ID,
..Contract::default()
},
)
.unwrap();
contracts
}
const OWN: ExecutionScope = ExecutionScope {
api_client_id: 0,
other_clients: false,
};
fn execution(exec_id: &str, order_id: i32, time: &str) -> Executions {
Executions::ExecutionData(ExecutionData {
request_id: 9001,
contract: Contract {
contract_id: CON_ID,
..Contract::default()
},
execution: Execution {
order_id,
execution_id: exec_id.to_string(),
time: time.to_string(),
shares: 1.0,
price: 100.0,
cumulative_quantity: 1.0,
..Execution::default()
},
})
}
fn from_api_client(api_client_id: i32, item: Executions) -> Executions {
let Executions::ExecutionData(mut data) = item else {
unreachable!("an execution")
};
data.execution.client_id = api_client_id;
Executions::ExecutionData(data)
}
fn commission(exec_id: &str) -> Executions {
Executions::CommissionReport(CommissionReport {
execution_id: exec_id.to_string(),
commission: 1.0,
currency: "USD".to_string(),
..CommissionReport::default()
})
}
#[test]
fn recovered_fills_keep_the_window_and_this_clients_orders() {
let contracts = registered();
let pending = ExecutionBuffer::new();
let mut fills = RecoveredFills::new(at("2026-09-29T14:00:00Z"), OWN, &contracts);
for item in [
execution("before", IB_ORDER_ID, "20260929 13:59:59 UTC"),
commission("before"),
execution("gap", IB_ORDER_ID, "20260929 14:00:00 UTC"),
commission("gap"),
execution("untracked", 99, "20260929 14:00:01 UTC"),
commission("untracked"),
from_api_client(
905,
execution("other-client", IB_ORDER_ID, "20260929 14:00:02 UTC"),
),
commission("other-client"),
] {
fills.push(item);
}
let trades = fills.finish(&pending);
let ids: Vec<_> = trades.iter().map(|trade| trade.id.0.as_str()).collect();
assert_eq!(ids, ["gap", "untracked"]);
assert_eq!(trades[0].order_id.0, IB_ORDER_ID.to_string());
assert_eq!(trades[1].order_id.0, "99");
assert!(
trades
.iter()
.all(|trade| trade.origin == TradeOrigin::Order)
);
assert_eq!(trades[0].fees.fees, Decimal::ONE);
assert_eq!(pending.pending_count(), 0);
}
#[test]
fn recovered_fills_report_other_clients_by_scope_and_liquidations_always() {
let other = |exec_id: &str, perm_id: i64, liquidation: i32| {
let Executions::ExecutionData(mut data) = from_api_client(
905,
execution(exec_id, IB_ORDER_ID, "20260929 14:00:00 UTC"),
) else {
unreachable!("an execution")
};
data.execution.perm_id = perm_id;
data.execution.liquidation = liquidation;
Executions::ExecutionData(data)
};
let read = |scope: ExecutionScope| {
let contracts = registered();
let pending = ExecutionBuffer::new();
let mut fills = RecoveredFills::new(at("2026-09-29T14:00:00Z"), scope, &contracts);
for item in [
other("external", 4242, 0),
commission("external"),
other("outside-ib", 0, 0),
commission("outside-ib"),
other("liquidated", 4343, 1),
commission("liquidated"),
] {
fills.push(item);
}
fills
.finish(&pending)
.into_iter()
.map(|trade| {
(
trade.id.0.to_string(),
trade.order_id.0.to_string(),
trade.origin,
)
})
.collect::<Vec<_>>()
};
assert_eq!(
read(OWN),
[(
"liquidated".to_string(),
"perm:4343".to_string(),
TradeOrigin::Liquidation
)]
);
assert_eq!(
read(ExecutionScope {
other_clients: true,
..OWN
}),
[
(
"external".to_string(),
"perm:4242".to_string(),
TradeOrigin::External
),
(
"outside-ib".to_string(),
format!("client:905:{IB_ORDER_ID}"),
TradeOrigin::External
),
(
"liquidated".to_string(),
"perm:4343".to_string(),
TradeOrigin::Liquidation
),
]
);
}
#[test]
fn recovered_execution_with_unparseable_time_is_kept() {
let contracts = registered();
let pending = ExecutionBuffer::new();
let mut fills = RecoveredFills::new(at("2026-09-29T14:00:00Z"), OWN, &contracts);
fills.push(execution("garbled", IB_ORDER_ID, "not a time"));
fills.push(commission("garbled"));
let trades = fills.finish(&pending);
assert_eq!(trades.len(), 1, "{trades:?}");
assert_eq!(trades[0].id.0.as_str(), "garbled");
}
#[test]
fn recovered_fills_put_an_execution_ahead_of_its_corrections() {
let contracts = registered();
let pending = ExecutionBuffer::new();
let mut fills = RecoveredFills::new(at("2026-09-29T14:00:00Z"), OWN, &contracts);
for item in [
execution("x.01.02", IB_ORDER_ID, "20260929 14:00:00 UTC"),
commission("x.01.02"),
execution("x.01.01", IB_ORDER_ID, "20260929 14:00:00 UTC"),
commission("x.01.01"),
execution("y.01.01", IB_ORDER_ID, "20260929 14:00:01 UTC"),
commission("y.01.01"),
] {
fills.push(item);
}
let (sink, mut rx) = sink();
for trade in fills.finish(&pending) {
assert!(sink.send_execution(trade));
}
assert_eq!(
delivered_all(&mut rx),
["x.01.01", "y.01.01", "x.01.01 corrected by x.01.02"]
);
}
#[test]
fn recovered_execution_without_commission_moves_to_the_stream_buffer() {
let contracts = registered();
let pending = ExecutionBuffer::new();
let mut fills = RecoveredFills::new(at("2026-09-29T14:00:00Z"), OWN, &contracts);
fills.push(execution("late", IB_ORDER_ID, "20260929 14:00:30 UTC"));
assert!(fills.finish(&pending).is_empty());
assert_eq!(pending.pending_count(), 1);
let Executions::CommissionReport(report) = commission("late") else {
unreachable!()
};
let trade = pending.complete_with_commission(&report).unwrap();
assert_eq!(trade.id.0.as_str(), "late");
}
}