use super::{
server_time::SigningClock,
shared::{REST_BALANCE_LAG, balance_read_time},
};
use crate::{
AccountEventKind, UnindexedAccountEvent,
client::venue_clock::{HostMoment, VenueClock},
error::UnindexedClientError,
trade::TimeSource,
};
use chrono::{DateTime, Utc};
use std::{fmt::Display, future::Future, sync::Arc, time::Duration};
use tokio::{sync::mpsc, time::Instant};
use tracing::{info, warn};
pub(crate) const RESTATEMENT_RETRY_BASE: Duration = Duration::from_secs(5);
pub(crate) const RESTATEMENT_RETRY_MAX: Duration = Duration::from_secs(120);
pub(crate) const RESTATEMENT_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) type SharedStreamClock = Arc<parking_lot::Mutex<StreamClock>>;
#[derive(Debug, Default)]
pub(crate) struct StreamClock {
venue: VenueClock,
server: Option<SigningClock>,
last_balance: Option<DateTime<Utc>>,
}
impl StreamClock {
pub(crate) fn shared(server: Option<SigningClock>) -> SharedStreamClock {
Arc::new(parking_lot::Mutex::new(Self {
server,
..Self::default()
}))
}
pub(crate) fn observe_frame(
clock: &SharedStreamClock,
sent: Option<DateTime<Utc>>,
events: &[UnindexedAccountEvent],
arrived: Instant,
) {
let mut samples = events.iter().filter_map(Self::sample_of).peekable();
if sent.is_none() && samples.peek().is_none() {
return;
}
let mut clock = clock.lock();
if let Some(sent) = sent {
clock.venue.observe(sent, arrived);
}
for (time, balance) in samples {
clock.sample(time, balance, arrived);
}
}
#[cfg(test)]
pub(crate) fn observe(
clock: &SharedStreamClock,
event: &UnindexedAccountEvent,
arrived: Instant,
) {
Self::observe_frame(clock, None, std::slice::from_ref(event), arrived);
}
fn sample_of(event: &UnindexedAccountEvent) -> Option<(DateTime<Utc>, bool)> {
match &event.kind {
AccountEventKind::Trade(trade) if trade.time_source == TimeSource::Venue => {
Some((trade.time_exchange, false))
}
AccountEventKind::BalanceStreamUpdate(update) => Some((update.0.time_exchange, true)),
AccountEventKind::InstrumentBalanceUpdate(update) => {
Some((update.balance.time_exchange, true))
}
AccountEventKind::MarginDebt(update) => Some((update.time_exchange, true)),
_ => None,
}
}
pub(crate) fn venue(&self) -> VenueClock {
let mut venue = self.venue;
if let Some(server) = &self.server {
venue.merge(&server.venue());
}
venue
}
fn sample(&mut self, time: DateTime<Utc>, balance: bool, arrived: Instant) {
self.venue.observe(time, arrived);
if balance {
self.last_balance = self.last_balance.max(Some(time));
}
}
pub(crate) fn read_start(&self, now: Instant) -> ReadStart {
let Some(last) = self.last_balance else {
return ReadStart::Now { floor: None };
};
let target = last + REST_BALANCE_LAG;
let short = self
.venue()
.earliest_at(now)
.map_or(Duration::ZERO, |earliest| {
(target - earliest).to_std().unwrap_or(Duration::ZERO)
});
if short.is_zero() {
ReadStart::Now { floor: Some(last) }
} else {
ReadStart::At(now + short + Duration::from_millis(1))
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReadStart {
Now { floor: Option<DateTime<Utc>> },
At(Instant),
}
pub(crate) fn restatement_time(
clock: &StreamClock,
sent: HostMoment,
floor: Option<DateTime<Utc>>,
) -> DateTime<Utc> {
let time = balance_read_time(sent, &clock.venue());
floor.map_or(time, |floor| time.max(floor))
}
pub(crate) async fn restate<Read>(
account: &'static str,
clock: &SharedStreamClock,
restatement: &mut BalanceRestatement,
tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
read: impl FnOnce(Option<DateTime<Utc>>) -> Read,
) where
Read: Future<Output = Result<Vec<UnindexedAccountEvent>, UnindexedClientError>>,
{
let now = Instant::now();
if !restatement.is_due(now) {
return;
}
let read_start = clock.lock().read_start(now);
let floor = match read_start {
ReadStart::Now { floor } => floor,
ReadStart::At(start) => {
restatement.defer(start);
return;
}
};
let events = match tokio::time::timeout(RESTATEMENT_TIMEOUT, read(floor)).await {
Ok(Ok(events)) => events,
Ok(Err(error)) => return retry(account, restatement, &error),
Err(_) => {
let secs = RESTATEMENT_TIMEOUT.as_secs();
return retry(
account,
restatement,
&format_args!("unfinished after {secs}s"),
);
}
};
let count = events.len();
for event in events {
if tx.send(event).is_err() {
return;
}
}
restatement.restated();
info!(
account,
count, "Binance balances restated after a reconnect"
);
}
fn retry(account: &'static str, restatement: &mut BalanceRestatement, error: &dyn Display) {
let retry_in = restatement.failed().unwrap_or_default();
warn!(
account,
%error,
retry_in_secs = retry_in.as_secs(),
"Binance balance restatement after a reconnect failed, will retry"
);
}
#[derive(Debug, Default)]
pub(crate) struct BalanceRestatement(Option<Pending>);
#[derive(Debug, Clone, Copy)]
struct Pending {
due: Instant,
failures: u32,
}
impl BalanceRestatement {
pub(crate) fn open(&mut self) {
self.0 = Some(Pending {
due: Instant::now(),
failures: 0,
});
}
pub(crate) fn next_due(&self) -> Option<Instant> {
self.0.map(|pending| pending.due)
}
pub(crate) fn is_due(&self, now: Instant) -> bool {
self.0.is_some_and(|pending| pending.due <= now)
}
pub(crate) fn defer(&mut self, due: Instant) {
if let Some(pending) = &mut self.0 {
pending.due = pending.due.max(due);
}
}
pub(crate) fn failed(&mut self) -> Option<Duration> {
let pending = self.0.as_mut()?;
let delay = RESTATEMENT_RETRY_BASE
.saturating_mul(1 << pending.failures.min(16))
.min(RESTATEMENT_RETRY_MAX);
pending.failures = pending.failures.saturating_add(1);
pending.due = Instant::now() + delay;
Some(delay)
}
pub(crate) fn restated(&mut self) {
self.0 = None;
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use crate::{
AccountEventKind, InstrumentBalanceUpdate, UnindexedAccountEvent,
balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate, MarginDetails},
error::ConnectivityError,
margin_debt::{MarginDebtKind, MarginDebtUpdate},
};
use chrono::TimeZone;
use rust_decimal::Decimal;
use rustrade_instrument::{asset::name::AssetNameExchange, exchange::ExchangeId};
use rustrade_integration::collection::snapshot::Snapshot;
fn at(secs: i64) -> DateTime<Utc> {
Utc.timestamp_opt(1_700_000_000 + secs, 0).unwrap()
}
fn balance_update(time: DateTime<Utc>) -> UnindexedAccountEvent {
UnindexedAccountEvent::new(
ExchangeId::BinanceMargin,
AccountEventKind::BalanceStreamUpdate(Snapshot(AssetBalanceUpdate::new(
AssetNameExchange::new("BTC"),
BalanceUpdate::new(Decimal::ONE, Decimal::ZERO),
time,
))),
)
}
#[tokio::test(start_paused = true)]
async fn a_frames_event_time_samples_the_clock_the_stamp_is_taken_from() {
let clock = SharedStreamClock::default();
let arrived = Instant::now();
StreamClock::observe_frame(&clock, Some(at(100)), &[], arrived);
assert_eq!(clock.lock().venue().earliest_at(arrived), Some(at(100)));
assert_eq!(
restatement_time(&clock.lock(), HostMoment::new(at(160), arrived), None),
at(100) - REST_BALANCE_LAG
);
}
#[tokio::test(start_paused = true)]
async fn the_server_time_read_bounds_the_clock_before_the_streams_first_event() {
let arrived = Instant::now();
let clock = StreamClock::shared(Some(SigningClock::sampled(at(100), arrived)));
assert_eq!(clock.lock().venue().earliest_at(arrived), Some(at(100)));
assert_eq!(
restatement_time(&clock.lock(), HostMoment::new(at(160), arrived), None),
at(100) - REST_BALANCE_LAG
);
StreamClock::observe_frame(&clock, Some(at(105)), &[], arrived);
assert_eq!(clock.lock().venue().earliest_at(arrived), Some(at(105)));
}
#[tokio::test(start_paused = true)]
async fn a_read_with_nothing_delivered_starts_at_once_stamped_the_lag_before_the_host() {
let clock = SharedStreamClock::default();
let now = Instant::now();
assert_eq!(clock.lock().read_start(now), ReadStart::Now { floor: None });
assert_eq!(
restatement_time(&clock.lock(), HostMoment::new(at(100), now), None),
at(100) - REST_BALANCE_LAG
);
}
#[tokio::test(start_paused = true)]
async fn a_read_waits_out_the_lag_after_the_last_balance_and_is_stamped_no_earlier() {
let clock = SharedStreamClock::default();
let arrived = Instant::now();
StreamClock::observe(&clock, &balance_update(at(100)), arrived);
let ReadStart::At(start) = clock.lock().read_start(arrived + Duration::from_secs(2)) else {
panic!("a read 2 s after the balance must wait");
};
assert!(
start >= arrived + REST_BALANCE_LAG.to_std().unwrap(),
"{start:?}"
);
assert!(start <= arrived + Duration::from_secs(5) + Duration::from_millis(2));
let ReadStart::Now { floor } = clock.lock().read_start(start) else {
panic!("the wait must have ended at {start:?}");
};
assert_eq!(floor, Some(at(100)));
assert_eq!(
restatement_time(&clock.lock(), HostMoment::new(at(50), start), floor),
at(100)
);
StreamClock::observe(&clock, &balance_update(at(200)), start);
assert_eq!(
restatement_time(&clock.lock(), HostMoment::new(at(50), start), floor),
at(100)
);
}
#[tokio::test(start_paused = true)]
async fn a_host_clock_ahead_of_the_venue_does_not_carry_into_the_stamp() {
let clock = SharedStreamClock::default();
let arrived = Instant::now();
StreamClock::observe(&clock, &balance_update(at(100)), arrived);
let sent_at = arrived + Duration::from_secs(20);
let stamp = restatement_time(
&clock.lock(),
HostMoment::new(at(500), sent_at),
Some(at(100)),
);
assert!(stamp <= at(120) - REST_BALANCE_LAG, "{stamp}");
assert!(stamp >= at(119) - REST_BALANCE_LAG, "{stamp}");
}
#[tokio::test(start_paused = true)]
async fn the_latest_balance_time_is_the_latest_balance_or_debt_event() {
let clock = SharedStreamClock::default();
let now = Instant::now();
StreamClock::observe(&clock, &balance_update(at(100)), now);
StreamClock::observe(&clock, &balance_update(at(90)), now);
assert_eq!(clock.lock().last_balance, Some(at(100)));
StreamClock::observe(&clock, &balance_update(at(105)), now);
assert_eq!(clock.lock().last_balance, Some(at(105)));
let pair = || rustrade_instrument::instrument::name::InstrumentNameExchange::new("BTCUSDT");
let AccountEventKind::BalanceStreamUpdate(Snapshot(update)) = balance_update(at(110)).kind
else {
unreachable!()
};
StreamClock::observe(
&clock,
&UnindexedAccountEvent::new(
ExchangeId::BinanceMargin,
AccountEventKind::InstrumentBalanceUpdate(InstrumentBalanceUpdate::new(
pair(),
update,
)),
),
now,
);
assert_eq!(
clock.lock().last_balance,
Some(at(110)),
"an isolated pair's"
);
StreamClock::observe(
&clock,
&UnindexedAccountEvent::new(
ExchangeId::BinanceMargin,
AccountEventKind::MarginDebt(MarginDebtUpdate::new(
AssetNameExchange::new("BTC"),
Some(pair()),
MarginDetails::new(Decimal::ONE, Decimal::ZERO),
MarginDebtKind::Borrow,
"BORROW".into(),
at(120),
)),
),
now,
);
assert_eq!(clock.lock().last_balance, Some(at(120)), "a debt's");
let fill = |time| {
crate::trade::Trade::new(
crate::trade::TradeId::new("t-1"),
crate::order::id::OrderId::new("o-1"),
rustrade_instrument::instrument::name::InstrumentNameExchange::new("BTCUSDT"),
crate::order::id::StrategyId::unknown(),
time,
rustrade_instrument::Side::Buy,
Decimal::ONE,
Decimal::ONE,
None,
crate::trade::AssetFees::new(AssetNameExchange::new("BTC"), Decimal::ZERO, None),
)
};
let host_stamped = fill(at(300)).with_time_source(TimeSource::Host);
StreamClock::observe(
&clock,
&UnindexedAccountEvent::new(
ExchangeId::BinanceMargin,
AccountEventKind::Trade(host_stamped),
),
now,
);
assert_eq!(
clock.lock().venue.earliest_at(now),
Some(at(120)),
"a fill stamped with this host's time does not sample the clock"
);
StreamClock::observe(
&clock,
&UnindexedAccountEvent::new(
ExchangeId::BinanceMargin,
AccountEventKind::Trade(fill(at(150))),
),
now,
);
assert_eq!(
clock.lock().last_balance,
Some(at(120)),
"a fill is no balance"
);
assert_eq!(
clock.lock().venue.earliest_at(now),
Some(at(150)),
"but it samples the clock"
);
StreamClock::observe(
&clock,
&UnindexedAccountEvent::new(
ExchangeId::BinanceMargin,
AccountEventKind::BalanceSnapshot(Snapshot(AssetBalance::new(
AssetNameExchange::new("BTC"),
Balance::new(Decimal::ONE, Decimal::ONE),
at(200),
))),
),
now,
);
assert_eq!(
clock.lock().last_balance,
Some(at(120)),
"a REST read is ignored"
);
assert_eq!(clock.lock().venue.earliest_at(now), Some(at(150)));
}
#[tokio::test(start_paused = true)]
async fn a_failed_restatement_backs_off_and_a_reconnect_makes_it_due_again() {
let mut restatement = BalanceRestatement::default();
assert_eq!(restatement.next_due(), None);
assert_eq!(restatement.failed(), None);
restatement.open();
assert!(restatement.is_due(Instant::now()));
let delays: Vec<_> = (0..7).map(|_| restatement.failed().unwrap()).collect();
assert_eq!(
delays,
[5, 10, 20, 40, 80, 120, 120].map(Duration::from_secs)
);
assert!(!restatement.is_due(Instant::now()));
restatement.open();
assert!(restatement.is_due(Instant::now()));
assert_eq!(restatement.failed(), Some(RESTATEMENT_RETRY_BASE));
restatement.restated();
assert_eq!(restatement.next_due(), None);
}
#[tokio::test(start_paused = true)]
async fn a_deferral_only_moves_the_due_time_later() {
let mut restatement = BalanceRestatement::default();
restatement.open();
let now = Instant::now();
restatement.defer(now + Duration::from_secs(3));
restatement.defer(now + Duration::from_secs(1));
assert_eq!(restatement.next_due(), Some(now + Duration::from_secs(3)));
}
fn restated_btc(floor: Option<DateTime<Utc>>) -> Vec<UnindexedAccountEvent> {
vec![balance_update(floor.unwrap_or(at(0)))]
}
#[tokio::test(start_paused = true)]
async fn a_due_restatement_sends_the_read_and_is_settled() {
let clock = SharedStreamClock::default();
let mut restatement = BalanceRestatement::default();
let (tx, mut rx) = mpsc::unbounded_channel();
let unread = |_| async { panic!("nothing is due") };
restate("test", &clock, &mut restatement, &tx, unread).await;
restatement.open();
restatement.failed();
restate("test", &clock, &mut restatement, &tx, unread).await;
restatement.open();
StreamClock::observe(&clock, &balance_update(at(100)), Instant::now());
tokio::time::advance(REST_BALANCE_LAG.to_std().unwrap() + Duration::from_secs(1)).await;
restate("test", &clock, &mut restatement, &tx, |floor| async move {
assert_eq!(floor, Some(at(100)));
Ok(restated_btc(floor))
})
.await;
assert_eq!(rx.try_recv().unwrap(), restated_btc(Some(at(100)))[0]);
assert!(rx.try_recv().is_err());
assert_eq!(restatement.next_due(), None);
}
#[tokio::test(start_paused = true)]
async fn a_restatement_not_yet_allowed_to_start_is_deferred() {
let clock = SharedStreamClock::default();
StreamClock::observe(&clock, &balance_update(at(100)), Instant::now());
let mut restatement = BalanceRestatement::default();
restatement.open();
let (tx, _rx) = mpsc::unbounded_channel();
restate("test", &clock, &mut restatement, &tx, |_| async {
panic!("the read may not start yet")
})
.await;
let ReadStart::At(start) = clock.lock().read_start(Instant::now()) else {
panic!("the read must wait");
};
assert_eq!(restatement.next_due(), Some(start));
}
#[tokio::test(start_paused = true)]
async fn a_failed_or_unfinished_read_is_retried_after_the_backoff() {
let clock = SharedStreamClock::default();
let mut restatement = BalanceRestatement::default();
let (tx, mut rx) = mpsc::unbounded_channel();
restatement.open();
restate("test", &clock, &mut restatement, &tx, |_| async {
Err(UnindexedClientError::Connectivity(
ConnectivityError::Timeout,
))
})
.await;
assert_eq!(
restatement.next_due(),
Some(Instant::now() + RESTATEMENT_RETRY_BASE)
);
tokio::time::advance(RESTATEMENT_RETRY_BASE).await;
let started = Instant::now();
restate("test", &clock, &mut restatement, &tx, |_| {
std::future::pending()
})
.await;
assert_eq!(Instant::now(), started + RESTATEMENT_TIMEOUT);
assert_eq!(
restatement.next_due(),
Some(Instant::now() + 2 * RESTATEMENT_RETRY_BASE)
);
assert!(rx.try_recv().is_err());
}
#[tokio::test(start_paused = true)]
async fn a_restatement_not_delivered_stays_owed() {
let clock = SharedStreamClock::default();
let mut restatement = BalanceRestatement::default();
let (tx, rx) = mpsc::unbounded_channel();
restatement.open();
let due = restatement.next_due();
let dropped = tokio::time::timeout(
Duration::from_secs(1),
restate("test", &clock, &mut restatement, &tx, |_| {
std::future::pending()
}),
)
.await;
assert!(dropped.is_err());
assert_eq!(restatement.next_due(), due);
drop(rx);
restate("test", &clock, &mut restatement, &tx, |floor| async move {
Ok(restated_btc(floor))
})
.await;
assert_eq!(restatement.next_due(), due);
}
}