use super::{execution::ExecutionBuffer, order::OrderIdMap, resolve_execution};
use crate::{
AccountEventKind, UnindexedAccountEvent,
client::dedup::{SharedDedupCache, dedup_key_from_event, is_duplicate},
emit_stream_terminated,
error::StreamTerminationReason,
trade::Trade,
};
use chrono::{DateTime, Utc};
use ibapi::{
TRANSPORT_RECONNECT_CODE,
client::blocking::{Client, NoticeStream},
orders::{ExecutionData, ExecutionFilter, Executions},
subscriptions::SubscriptionItem,
};
use parking_lot::Mutex;
use rustrade_instrument::{
asset::name::AssetNameExchange, exchange::ExchangeId, ibkr::ContractRegistry,
instrument::name::InstrumentNameExchange,
};
use std::{
sync::Arc,
time::{Duration, Instant},
};
use tokio::sync::mpsc;
use tracing::{debug, error, info, trace, warn};
const POLL_INTERVAL: Duration = Duration::from_secs(1);
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,
}
impl EventSink {
pub(super) fn new(
tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
dedup: SharedDedupCache,
) -> Self {
Self {
tx: Arc::new(Mutex::new(Some(tx))),
dedup,
}
}
pub(super) fn send(&self, event: UnindexedAccountEvent) -> bool {
self.tx
.lock()
.as_ref()
.is_some_and(|tx| tx.send(event).is_ok())
}
pub(super) 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())
}
}
#[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))
}
}
pub(super) struct RecoveredFills<'a> {
floor: DateTime<Utc>,
contracts: &'a ContractRegistry,
order_ids: &'a OrderIdMap,
awaiting_commission: ExecutionBuffer,
trades: Vec<Trade<AssetNameExchange, InstrumentNameExchange>>,
unparseable: usize,
}
impl<'a> RecoveredFills<'a> {
pub(super) fn new(
floor: DateTime<Utc>,
contracts: &'a ContractRegistry,
order_ids: &'a OrderIdMap,
) -> Self {
Self {
floor,
contracts,
order_ids,
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((instrument, client_id)) =
resolve_execution(&execution, self.contracts, self.order_ids)
{
self.awaiting_commission
.add_execution(execution, instrument, client_id);
}
}
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"
);
}
self.trades
}
}
fn recover_fills(
client: &Client,
floor: DateTime<Utc>,
contracts: &ContractRegistry,
order_ids: &OrderIdMap,
pending: &ExecutionBuffer,
sink: &EventSink,
) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, RecoveryError> {
let subscription = client
.executions(ExecutionFilter::default())
.map_err(RecoveryError::Ibapi)?;
let deadline = Instant::now() + RECOVERY_TIMEOUT;
let mut fills = RecoveredFills::new(floor, contracts, order_ids);
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))
}
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: ExecutionBuffer,
pub(super) sink: EventSink,
}
impl RecoveryWatcher {
pub(super) fn run(self) {
let mut tracker = GapTracker::new(Utc::now());
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)
&& !self.recover(&mut tracker, floor)
{
return;
}
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) => {
warn!("IBKR client shut down; terminating the account stream");
self.sink.terminate(StreamTerminationReason::Error(
"IBKR client shut down: ibapi gave up reconnecting to TWS/Gateway, or \
the client was disconnected"
.to_string(),
));
return;
}
None => {}
}
}
}
fn recover(&self, tracker: &mut GapTracker, floor: DateTime<Utc>) -> bool {
match recover_fills(
&self.client,
floor,
&self.contracts,
&self.order_ids,
&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_trade(trade) {
return false;
}
}
tracker.recovered();
true
}
Err(RecoveryError::StreamClosed) => false,
Err(e) => {
warn!(error = %e, since = %floor, "IBKR fill recovery failed");
if tracker.recovery_failed(e.is_transport_loss()) {
error!(error = %e, "Giving up IBKR fill recovery; terminating the account stream");
self.sink.terminate(StreamTerminationReason::Error(format!(
"IBKR fill recovery after a gap in event delivery failed: {e}"
)));
return false;
}
true
}
}
}
}
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,
order::{OrderKind, TimeInForce, id::ClientOrderId},
};
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(9000)));
}
#[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 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),
}
}
fn sink() -> (EventSink, mpsc::UnboundedReceiver<UnindexedAccountEvent>) {
let (tx, rx) = mpsc::unbounded_channel();
(EventSink::new(tx, new_dedup_cache()), rx)
}
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");
}
#[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 tracked() -> (ContractRegistry, OrderIdMap) {
let contracts = ContractRegistry::new();
contracts.register(
InstrumentNameExchange::new("AAPL"),
Contract {
contract_id: CON_ID,
..Contract::default()
},
);
let order_ids = OrderIdMap::new();
order_ids.register(
ClientOrderId::new("cid-7"),
IB_ORDER_ID,
super::super::order::OrderContext {
instrument: InstrumentNameExchange::new("AAPL"),
side: Side::Buy,
price: Some(Decimal::from(100)),
quantity: Decimal::from(2),
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
},
);
(contracts, order_ids)
}
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 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_tracked_orders() {
let (contracts, order_ids) = tracked();
let pending = ExecutionBuffer::new();
let mut fills = RecoveredFills::new(at("2026-09-29T14:00:00Z"), &contracts, &order_ids);
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("foreign", 99, "20260929 14:00:01 UTC"),
commission("foreign"),
] {
fills.push(item);
}
let trades = fills.finish(&pending);
assert_eq!(trades.len(), 1, "{trades:?}");
assert_eq!(trades[0].id.0.as_str(), "gap");
assert_eq!(trades[0].order_id.0.as_str(), "cid-7");
assert_eq!(trades[0].fees.fees, Decimal::ONE);
assert_eq!(pending.pending_count(), 0);
}
#[test]
fn recovered_execution_with_unparseable_time_is_kept() {
let (contracts, order_ids) = tracked();
let pending = ExecutionBuffer::new();
let mut fills = RecoveredFills::new(at("2026-09-29T14:00:00Z"), &contracts, &order_ids);
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_execution_without_commission_moves_to_the_stream_buffer() {
let (contracts, order_ids) = tracked();
let pending = ExecutionBuffer::new();
let mut fills = RecoveredFills::new(at("2026-09-29T14:00:00Z"), &contracts, &order_ids);
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");
}
}