use crate::{
AccountEventKind, UnindexedAccountEvent,
error::{ApiError, UnindexedClientError},
order::{
OrderKind, UnindexedInactiveOrder, UnindexedOrderKey,
id::{ClientOrderId, OrderId, StrategyId},
state::{ActiveOrderState, Open, OrderState, UnindexedOrderState},
},
};
use fnv::{FnvHashMap, FnvHashSet};
use lru::LruCache;
use rust_decimal::Decimal;
use rustrade_instrument::{exchange::ExchangeId, instrument::name::InstrumentNameExchange};
use rustrade_integration::collection::snapshot::Snapshot;
use std::{
collections::BTreeMap,
future::Future,
num::NonZeroUsize,
ops::{Deref, DerefMut},
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
},
time::Duration,
};
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
pub(crate) const MAX_GAP_RETRIES: u32 = 5;
pub(crate) const GAP_RETRY_BASE_SECS: u64 = 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GapFailure {
Retry(Duration),
GivenUp,
}
pub(crate) trait PendingFills {
fn pending(&self, instrument: &InstrumentNameExchange) -> bool;
}
#[derive(Debug, Clone, Copy, Default)]
#[cfg_attr(
not(any(feature = "alpaca", feature = "hyperliquid", feature = "ibkr")),
allow(dead_code)
)]
pub(crate) struct NoPendingFills;
impl PendingFills for NoPendingFills {
fn pending(&self, _: &InstrumentNameExchange) -> bool {
false
}
}
pub(crate) const MAX_KNOWN_LIVE_ORDERS: usize = 4_096;
const RECENTLY_ENDED: NonZeroUsize = match NonZeroUsize::new(1_024) {
Some(capacity) => capacity,
None => panic!("a non-zero literal"),
};
pub(crate) const ORDER_LOOKUPS_IN_FLIGHT: usize = 8;
const LISTINGS_IN_FLIGHT: usize = 2;
pub(crate) const ORDER_CHECK_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Clone)]
pub(crate) struct SharedKnownLiveOrders(Arc<parking_lot::Mutex<KnownLiveOrders>>);
impl SharedKnownLiveOrders {
pub(crate) fn lock(&self) -> KnownLiveOrdersGuard<'_> {
KnownLiveOrdersGuard {
guard: self.0.lock(),
log: ForgottenLog::default(),
}
}
#[cfg_attr(not(feature = "binance"), allow(dead_code))]
pub(crate) fn sending(&self, cid: &ClientOrderId, kind: OrderKind) -> SendingKind {
let cid = (!kind.is_conditional_unresolved()).then(|| {
self.lock().sending(cid, kind);
cid.clone()
});
SendingKind {
known: self.clone(),
cid,
}
}
#[cfg(test)]
pub(crate) fn holders(&self) -> usize {
Arc::strong_count(&self.0)
}
}
pub(crate) struct KnownLiveOrdersGuard<'a> {
guard: parking_lot::MutexGuard<'a, KnownLiveOrders>,
log: ForgottenLog,
}
impl Deref for KnownLiveOrdersGuard<'_> {
type Target = KnownLiveOrders;
fn deref(&self) -> &KnownLiveOrders {
&self.guard
}
}
impl DerefMut for KnownLiveOrdersGuard<'_> {
fn deref_mut(&mut self) -> &mut KnownLiveOrders {
&mut self.guard
}
}
impl Drop for KnownLiveOrdersGuard<'_> {
fn drop(&mut self) {
if !self.guard.forgotten.is_empty() {
self.log = ForgottenLog {
exchange: Some(self.guard.exchange),
forgotten: std::mem::take(&mut self.guard.forgotten),
};
}
}
}
#[derive(Default)]
struct ForgottenLog {
exchange: Option<ExchangeId>,
forgotten: Vec<Forgotten>,
}
impl Drop for ForgottenLog {
fn drop(&mut self) {
let Some(exchange) = self.exchange else {
return;
};
for Forgotten { cid, first } in self.forgotten.drain(..) {
if first {
warn!(
%exchange,
%cid,
held = MAX_KNOWN_LIVE_ORDERS,
"Holding the most orders as live the client can, and forgetting the oldest as \
more arrive: if one ended while the stream was disconnected, a reconnect will \
not report it. Further orders forgotten are logged at debug"
);
} else {
debug!(%exchange, %cid, "Forgetting the oldest order held as live");
}
}
}
}
#[must_use = "dropped at once, it treats the placement's outcome as unknown"]
#[derive(Debug)]
pub(crate) struct SendingKind {
known: SharedKnownLiveOrders,
cid: Option<ClientOrderId>,
}
#[cfg_attr(not(feature = "binance"), allow(dead_code))]
impl SendingKind {
pub(crate) fn sent(mut self, state: &UnindexedOrderState) {
if let Some(cid) = self.cid.take() {
Self::release(&self.known, &cid, state.is_outcome_unknown());
}
}
fn release(known: &SharedKnownLiveOrders, cid: &ClientOrderId, outcome_unknown: bool) {
let (ignored, exchange) = {
let mut known = known.lock();
(known.sent(cid, outcome_unknown), known.exchange)
};
if ignored {
warn!(
%exchange,
%cid,
limit = MAX_KNOWN_LIVE_ORDERS,
"The kind of an order whose placement's outcome is unknown is past the limit of \
recalled kinds and is not kept: a report that cannot describe the order will \
leave its kind unresolved"
);
}
}
}
impl Drop for SendingKind {
fn drop(&mut self) {
if let Some(cid) = self.cid.take() {
Self::release(&self.known, &cid, true);
}
}
}
#[derive(Debug)]
struct Forgotten {
cid: ClientOrderId,
first: bool,
}
#[derive(Debug)]
struct KnownLive {
instrument: InstrumentNameExchange,
order_id: Option<OrderId>,
quantity: Decimal,
kind: OrderKind,
seq: u64,
}
#[derive(Debug)]
pub(crate) struct KnownLiveOrders {
exchange: ExchangeId,
orders: FnvHashMap<ClientOrderId, KnownLive>,
by_order_id: FnvHashMap<(InstrumentNameExchange, OrderId), ClientOrderId>,
by_instrument: OrdersByInstrument,
by_age: BTreeMap<u64, ClientOrderId>,
recently_ended: LruCache<ClientOrderId, ()>,
recalled: FnvHashMap<ClientOrderId, OrderKind>,
sending: FnvHashMap<ClientOrderId, OrderKind>,
next_seq: u64,
forgetting: bool,
forgotten: Vec<Forgotten>,
}
impl KnownLiveOrders {
fn new(exchange: ExchangeId) -> Self {
Self {
exchange,
orders: FnvHashMap::default(),
by_order_id: FnvHashMap::default(),
by_instrument: OrdersByInstrument::default(),
by_age: BTreeMap::new(),
recently_ended: LruCache::new(RECENTLY_ENDED),
recalled: FnvHashMap::default(),
sending: FnvHashMap::default(),
next_seq: 0,
forgetting: false,
forgotten: Vec::new(),
}
}
pub(crate) fn shared(exchange: ExchangeId) -> SharedKnownLiveOrders {
SharedKnownLiveOrders(Arc::new(parking_lot::Mutex::new(Self::new(exchange))))
}
pub(crate) fn live(
&mut self,
key: &UnindexedOrderKey,
quantity: Decimal,
kind: OrderKind,
open: &Open,
) {
let cid = &key.cid;
if open.filled_quantity >= quantity {
self.ended(cid);
return;
}
if self.recently_ended.contains(cid) {
return;
}
let order_id = open.id.assigned();
if let Some(known) = self.orders.get_mut(cid) {
known.quantity = quantity;
if known.kind.is_conditional_unresolved() && !kind.is_conditional_unresolved() {
known.kind = kind;
}
let moved = known.instrument != key.instrument;
let new_id = order_id.filter(|id| known.order_id.as_ref() != Some(*id));
if !moved && new_id.is_none() {
return;
}
if let Some(stale) = &known.order_id {
self.by_order_id
.remove(&(known.instrument.clone(), stale.clone()));
}
if moved {
self.by_instrument.remove(&known.instrument, cid);
self.by_instrument.insert(&key.instrument, cid);
known.instrument = key.instrument.clone();
}
if let Some(new_id) = new_id {
known.order_id = Some(new_id.clone());
}
if let Some(order_id) = &known.order_id {
self.by_order_id
.insert((key.instrument.clone(), order_id.clone()), cid.clone());
}
return;
}
if self.orders.len() >= MAX_KNOWN_LIVE_ORDERS {
self.forget_oldest();
} else if self.orders.len() <= MAX_KNOWN_LIVE_ORDERS * 3 / 4 {
self.forgetting = false;
}
let seq = self.next_seq;
self.next_seq += 1;
self.by_age.insert(seq, cid.clone());
self.by_instrument.insert(&key.instrument, cid);
if let Some(order_id) = order_id {
self.by_order_id
.insert((key.instrument.clone(), order_id.clone()), cid.clone());
}
let kind = match self.supplied_kind(cid) {
Some(supplied) if kind.is_conditional_unresolved() => supplied,
_ => kind,
};
self.orders.insert(
cid.clone(),
KnownLive {
instrument: key.instrument.clone(),
order_id: order_id.cloned(),
quantity,
kind,
seq,
},
);
}
#[cfg_attr(not(feature = "binance"), allow(dead_code))]
pub(crate) fn recall(
&mut self,
kinds: impl IntoIterator<Item = (ClientOrderId, OrderKind)>,
) -> usize {
let mut ignored = 0usize;
for (cid, kind) in kinds {
if kind.is_conditional_unresolved() || self.recently_ended.contains(&cid) {
continue;
}
self.resolve_held(&cid, kind);
if self.recalled.len() >= MAX_KNOWN_LIVE_ORDERS && !self.recalled.contains_key(&cid) {
ignored += 1;
continue;
}
self.recalled.insert(cid, kind);
}
ignored
}
fn resolve_held(&mut self, cid: &ClientOrderId, kind: OrderKind) {
if let Some(known) = self.orders.get_mut(cid)
&& known.kind.is_conditional_unresolved()
{
known.kind = kind;
}
}
fn sending(&mut self, cid: &ClientOrderId, kind: OrderKind) {
self.resolve_held(cid, kind);
self.sending.insert(cid.clone(), kind);
}
fn sent(&mut self, cid: &ClientOrderId, outcome_unknown: bool) -> bool {
match self.sending.remove(cid) {
Some(kind) if outcome_unknown => self.recall([(cid.clone(), kind)]) > 0,
_ => false,
}
}
pub(crate) fn placed(
&mut self,
key: &UnindexedOrderKey,
quantity: Decimal,
kind: OrderKind,
state: &UnindexedOrderState,
) {
match state {
OrderState::Active(ActiveOrderState::Open(open)) => {
self.live(key, quantity, kind, open);
}
OrderState::Active(_) => {}
OrderState::Inactive(_) if state.is_outcome_unknown() => {}
OrderState::Inactive(_) => {
self.ended(&key.cid);
}
}
}
#[cfg_attr(not(feature = "ibkr"), allow(dead_code))]
pub(crate) fn placing(&mut self, cid: &ClientOrderId) {
self.recently_ended.pop(cid);
}
pub(crate) fn ended(&mut self, cid: &ClientOrderId) -> bool {
self.recently_ended.put(cid.clone(), ());
self.recalled.remove(cid);
self.remove(cid)
}
pub(crate) fn observes<ExchangeKey, AssetKey, InstrumentKey>(
kind: &AccountEventKind<ExchangeKey, AssetKey, InstrumentKey>,
) -> bool {
matches!(
kind,
AccountEventKind::OrderSnapshot(_)
| AccountEventKind::OrderCancelled(_)
| AccountEventKind::Trade(_)
)
}
pub(crate) fn observe(&mut self, event: &UnindexedAccountEvent) {
match &event.kind {
AccountEventKind::OrderSnapshot(Snapshot(order)) => match &order.state {
OrderState::Active(ActiveOrderState::Open(open)) => {
self.live(&order.key, order.quantity, order.kind, open);
}
OrderState::Active(_) => {}
OrderState::Inactive(_) => {
self.ended(&order.key.cid);
}
},
AccountEventKind::OrderCancelled(response) => {
self.ended(&response.key.cid);
}
AccountEventKind::Trade(trade) => {
let Some(filled) = trade.order_filled_quantity else {
return;
};
let Some(cid) = self
.by_order_id
.get(&(trade.instrument.clone(), trade.order_id.clone()))
else {
return;
};
if self
.orders
.get(cid)
.is_some_and(|known| filled >= known.quantity)
{
let cid = cid.clone();
self.ended(&cid);
}
}
_ => {}
}
}
#[cfg_attr(not(feature = "binance"), allow(dead_code))]
pub(crate) fn kind_of(&self, cid: &ClientOrderId) -> Option<OrderKind> {
self.orders
.get(cid)
.map(|known| known.kind)
.or_else(|| self.supplied_kind(cid))
}
fn supplied_kind(&self, cid: &ClientOrderId) -> Option<OrderKind> {
self.sending
.get(cid)
.or_else(|| self.recalled.get(cid))
.copied()
}
#[cfg(test)]
pub(crate) fn contains(&self, cid: &ClientOrderId) -> bool {
self.orders.contains_key(cid)
}
#[cfg(test)]
fn assert_consistent(&self) {
assert_eq!(self.by_age.len(), self.orders.len(), "by_age");
for (seq, cid) in &self.by_age {
assert_eq!(self.orders.get(cid).map(|known| known.seq), Some(*seq));
}
for ((instrument, order_id), cid) in &self.by_order_id {
let known = &self.orders[cid];
assert_eq!(
(&known.instrument, known.order_id.as_ref()),
(instrument, Some(order_id))
);
}
let with_ids = self
.orders
.values()
.filter(|known| known.order_id.is_some());
assert_eq!(with_ids.count(), self.by_order_id.len(), "by_order_id");
for (instrument, cids) in &self.by_instrument.0 {
assert!(!cids.is_empty(), "by_instrument keeps no empty entry");
for cid in cids {
assert_eq!(&self.orders[cid].instrument, instrument);
}
}
let indexed: usize = self.by_instrument.0.values().map(FnvHashSet::len).sum();
assert_eq!(indexed, self.orders.len(), "by_instrument");
}
#[cfg_attr(
not(any(feature = "alpaca", feature = "hyperliquid", feature = "ibkr")),
allow(dead_code)
)]
pub(crate) fn instruments(&self) -> Vec<InstrumentNameExchange> {
self.by_instrument.0.keys().cloned().collect()
}
#[cfg_attr(not(any(feature = "alpaca", feature = "binance")), allow(dead_code))]
pub(crate) fn instruments_among(
&self,
instruments: &[InstrumentNameExchange],
) -> Vec<InstrumentNameExchange> {
instruments
.iter()
.filter(|instrument| self.by_instrument.0.contains_key(*instrument))
.cloned()
.collect()
}
pub(crate) fn keys_on(
&self,
exchange: ExchangeId,
instrument: &InstrumentNameExchange,
) -> Vec<UnindexedOrderKey> {
self.by_instrument
.0
.get(instrument)
.into_iter()
.flatten()
.map(|cid| UnindexedOrderKey {
exchange,
instrument: instrument.clone(),
strategy: StrategyId::unknown(),
cid: cid.clone(),
})
.collect()
}
fn remove(&mut self, cid: &ClientOrderId) -> bool {
let Some(known) = self.orders.remove(cid) else {
return false;
};
self.by_age.remove(&known.seq);
self.by_instrument.remove(&known.instrument, cid);
if let Some(order_id) = known.order_id {
self.by_order_id.remove(&(known.instrument, order_id));
}
true
}
fn forget_oldest(&mut self) {
let Some((_, cid)) = self.by_age.pop_first() else {
return;
};
self.remove(&cid);
let first = !self.forgetting;
self.forgetting = true;
self.forgotten.push(Forgotten { cid, first });
}
}
#[derive(Debug, Default)]
struct OrdersByInstrument(FnvHashMap<InstrumentNameExchange, FnvHashSet<ClientOrderId>>);
impl OrdersByInstrument {
fn insert(&mut self, instrument: &InstrumentNameExchange, cid: &ClientOrderId) {
self.0
.entry(instrument.clone())
.or_default()
.insert(cid.clone());
}
fn remove(&mut self, instrument: &InstrumentNameExchange, cid: &ClientOrderId) {
if let Some(cids) = self.0.get_mut(instrument) {
cids.remove(cid);
if cids.is_empty() {
self.0.remove(instrument);
}
}
}
}
#[derive(Debug, Default)]
struct InstrumentCheck {
failures: u32,
retry_at: Option<tokio::time::Instant>,
}
#[derive(Debug, Default)]
pub(crate) struct UncheckedOrders(FnvHashMap<InstrumentNameExchange, InstrumentCheck>);
impl UncheckedOrders {
pub(crate) fn open(&mut self, instruments: impl IntoIterator<Item = InstrumentNameExchange>) {
for instrument in instruments {
self.0.entry(instrument).or_default();
}
}
pub(crate) fn ready(
&self,
pending: &impl PendingFills,
now: tokio::time::Instant,
) -> Vec<InstrumentNameExchange> {
let mut ready: Vec<_> = self
.0
.iter()
.filter(|(instrument, check)| {
!pending.pending(instrument) && check.retry_at.is_none_or(|at| at <= now)
})
.map(|(instrument, _)| instrument.clone())
.collect();
ready.sort_unstable();
ready
}
pub(crate) fn next_due(&self, pending: &impl PendingFills) -> Option<tokio::time::Instant> {
self.0
.iter()
.filter(|(instrument, _)| !pending.pending(instrument))
.map(|(_, check)| check.retry_at.unwrap_or_else(tokio::time::Instant::now))
.min()
}
pub(crate) fn checked(&mut self, instrument: &InstrumentNameExchange) {
self.0.remove(instrument);
}
pub(crate) fn failed(
&mut self,
instrument: &InstrumentNameExchange,
now: tokio::time::Instant,
min_delay: Duration,
) -> Option<GapFailure> {
let check = self.0.get_mut(instrument)?;
check.failures += 1;
if check.failures > MAX_GAP_RETRIES {
self.0.remove(instrument);
return Some(GapFailure::GivenUp);
}
let delay = Duration::from_secs(GAP_RETRY_BASE_SECS << (check.failures - 1)).max(min_delay);
check.retry_at = Some(now + delay);
Some(GapFailure::Retry(delay))
}
pub(crate) fn hold_until(
&mut self,
instrument: &InstrumentNameExchange,
until: tokio::time::Instant,
) {
if let Some(check) = self.0.get_mut(instrument) {
check.retry_at = Some(check.retry_at.map_or(until, |retry_at| retry_at.max(until)));
}
}
#[cfg(test)]
pub(crate) fn contains(&self, instrument: &InstrumentNameExchange) -> bool {
self.0.contains_key(instrument)
}
#[cfg(test)]
pub(crate) fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
pub(crate) trait EventSender {
fn send_event(&self, event: UnindexedAccountEvent) -> bool;
}
impl EventSender for mpsc::UnboundedSender<UnindexedAccountEvent> {
fn send_event(&self, event: UnindexedAccountEvent) -> bool {
self.send(event).is_ok()
}
}
#[derive(Debug)]
pub(crate) enum OrderLookup {
Ended(Box<UnindexedInactiveOrder>),
NotEnded,
Unknown,
}
impl OrderLookup {
pub(crate) fn ended(self) -> Option<UnindexedInactiveOrder> {
match self {
Self::Ended(order) => Some(*order),
Self::NotEnded | Self::Unknown => None,
}
}
}
pub(crate) async fn fetch_ended_by_key<F, Fut>(
orders: &[UnindexedOrderKey],
lookup: F,
) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError>
where
F: FnMut(UnindexedOrderKey) -> Fut,
Fut: Future<Output = Result<OrderLookup, UnindexedClientError>>,
{
use futures::{StreamExt as _, TryStreamExt as _};
let mut asked = FnvHashSet::default();
let lookups: Vec<UnindexedOrderKey> = orders
.iter()
.filter(|key| asked.insert((&key.instrument, &key.cid)))
.cloned()
.collect();
let found: Vec<OrderLookup> = futures::stream::iter(lookups)
.map(lookup)
.buffered(ORDER_LOOKUPS_IN_FLIGHT)
.try_collect()
.await?;
let mut reported = FnvHashSet::default();
Ok(found
.into_iter()
.filter_map(OrderLookup::ended)
.filter(|order| reported.insert(order.key.cid.clone()))
.collect())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OpenListing {
#[cfg_attr(not(feature = "binance"), allow(dead_code))]
PerInstrument,
#[cfg_attr(
not(any(feature = "alpaca", feature = "hyperliquid", feature = "ibkr")),
allow(dead_code)
)]
Batched,
}
pub(crate) async fn recover_ended_orders<L, LFut, Q, QFut>(
exchange: ExchangeId,
known: &SharedKnownLiveOrders,
unchecked: &mut UncheckedOrders,
pending: &impl PendingFills,
tx: &impl EventSender,
listing: OpenListing,
list_open: L,
lookup: Q,
) where
L: Fn(Vec<InstrumentNameExchange>) -> LFut,
LFut: Future<Output = Result<FnvHashSet<ClientOrderId>, UnindexedClientError>>,
Q: Fn(UnindexedOrderKey) -> QFut,
QFut: Future<Output = Result<OrderLookup, UnindexedClientError>>,
{
use futures::StreamExt as _;
let ready = unchecked.ready(pending, tokio::time::Instant::now());
if ready.is_empty() {
return;
}
let mut held: Vec<(InstrumentNameExchange, Vec<UnindexedOrderKey>)> = {
let known = known.lock();
ready
.into_iter()
.map(|instrument| {
let keys = known.keys_on(exchange, &instrument);
(instrument, keys)
})
.collect()
};
held.retain(|(instrument, keys)| {
if keys.is_empty() {
unchecked.checked(instrument);
}
!keys.is_empty()
});
if held.is_empty() {
return;
}
info!(
%exchange,
instruments = held.len(),
orders = held.iter().map(|(_, keys)| keys.len()).sum::<usize>(),
"Checking how the orders held as live ended while disconnected"
);
let checks: Vec<Vec<(InstrumentNameExchange, Vec<UnindexedOrderKey>)>> = match listing {
OpenListing::PerInstrument => held.into_iter().map(|one| vec![one]).collect(),
OpenListing::Batched => vec![held],
};
let instruments: Vec<Vec<InstrumentNameExchange>> = checks
.iter()
.map(|check| {
check
.iter()
.map(|(instrument, _)| instrument.clone())
.collect()
})
.collect();
let mut settled = vec![false; checks.len()];
let started = AtomicUsize::new(0);
let throttled = Throttled::default();
let (list_open, lookup, throttled) = (&list_open, &lookup, &throttled);
let mut charged: Vec<&InstrumentNameExchange> = Vec::new();
let recovery = async {
let mut reported = 0u32;
let mut deferred = 0usize;
let mut runs =
futures::stream::iter(checks.into_iter().enumerate().map(|(index, check)| {
started.store(index + 1, Ordering::Relaxed);
async move {
if throttled.stopped() {
return (
index,
Err(UnindexedClientError::Api(ApiError::rate_limited(None))),
);
}
let (listed_on, keys): (Vec<_>, Vec<_>) = check.into_iter().unzip();
let listed = match list_open(listed_on).await {
Ok(listed) => listed,
Err(e) => {
throttled.note(&e);
return (index, Err(e));
}
};
let unlisted: Vec<UnindexedOrderKey> = keys
.into_iter()
.flatten()
.filter(|key| !listed.contains(&key.cid))
.collect();
let mut unsettled: FnvHashMap<InstrumentNameExchange, usize> =
FnvHashMap::default();
for key in &unlisted {
*unsettled.entry(key.instrument.clone()).or_default() += 1;
}
let mut lookups = futures::stream::iter(unlisted)
.take_while(|_| std::future::ready(!throttled.stopped()))
.map(|key| async move {
let found = lookup(key.clone()).await;
(key, found)
})
.buffer_unordered(ORDER_LOOKUPS_IN_FLIGHT);
let mut checked = CheckedListing::default();
while let Some((key, found)) = lookups.next().await {
if let Some(left) = unsettled.get_mut(&key.instrument) {
*left -= 1;
}
match found {
Ok(found) => match settle(exchange, known, key, found, tx) {
Some(sent) => checked.sent += sent,
None => {
checked.consumer_gone = true;
return (index, Ok(checked));
}
},
Err(e) => {
throttled.note(&e);
checked
.failed
.entry(key.instrument)
.or_insert_with(|| failure_reason(&e));
}
}
}
for (instrument, left) in unsettled {
if left > 0 {
checked
.failed
.entry(instrument)
.or_insert_with(|| RATE_LIMITED.to_owned());
}
}
(index, Ok(checked))
}
}))
.buffer_unordered(LISTINGS_IN_FLIGHT);
while let Some((index, checked)) = runs.next().await {
settled[index] = true;
match checked {
Ok(checked) => {
if checked.consumer_gone {
debug!(%exchange, "Order check: consumer dropped during recovery");
return;
}
reported += checked.sent;
for instrument in &instruments[index] {
match checked.failed.get(instrument) {
Some(reason) => {
deferred += usize::from(order_check_failed(
exchange,
unchecked,
instrument,
reason,
throttled.wait(),
));
charged.push(instrument);
}
None => unchecked.checked(instrument),
}
}
}
Err(e) => {
let reason = failure_reason(&e);
for instrument in &instruments[index] {
deferred += usize::from(order_check_failed(
exchange,
unchecked,
instrument,
&reason,
throttled.wait(),
));
charged.push(instrument);
}
}
}
}
if throttled.stopped() {
warn!(
%exchange,
reported,
deferred,
venue_wait_ms = u64::try_from(throttled.wait().as_millis()).unwrap_or(u64::MAX),
"Order check stopped: the venue is limiting requests, so the instruments not \
checked are retried later"
);
} else {
info!(%exchange, reported, "Order check complete");
}
};
if tokio::time::timeout(Duration::from_secs(ORDER_CHECK_TIMEOUT_SECS), recovery)
.await
.is_err()
{
let started = started.load(Ordering::Relaxed);
for (check, _) in instruments[..started]
.iter()
.zip(&settled)
.filter(|(_, settled)| !**settled)
{
for instrument in check {
order_check_failed(
exchange,
unchecked,
instrument,
"order check timed out",
throttled.wait(),
);
charged.push(instrument);
}
}
}
let venue_wait = throttled.wait();
if !venue_wait.is_zero() {
let until = tokio::time::Instant::now() + venue_wait;
for instrument in charged {
unchecked.hold_until(instrument, until);
}
}
}
const RATE_LIMITED: &str = "the venue is limiting requests";
fn is_rate_limit(error: &UnindexedClientError) -> bool {
matches!(error, UnindexedClientError::Api(ApiError::RateLimit { .. }))
}
#[derive(Debug, Default)]
struct Throttled {
stopped: AtomicBool,
wait_ms: AtomicU64,
}
impl Throttled {
fn note(&self, error: &UnindexedClientError) {
if let UnindexedClientError::Api(ApiError::RateLimit { retry_after, .. }) = error {
if let Some(wait) = retry_after {
let wait_ms = u64::try_from(wait.as_millis()).unwrap_or(u64::MAX);
self.wait_ms.fetch_max(wait_ms, Ordering::Relaxed);
}
self.stopped.store(true, Ordering::Relaxed);
}
}
fn stopped(&self) -> bool {
self.stopped.load(Ordering::Relaxed)
}
fn wait(&self) -> Duration {
Duration::from_millis(self.wait_ms.load(Ordering::Relaxed))
}
}
fn failure_reason(error: &UnindexedClientError) -> String {
if is_rate_limit(error) {
RATE_LIMITED.to_owned()
} else {
error.to_string()
}
}
#[derive(Debug, Default)]
struct CheckedListing {
sent: u32,
failed: FnvHashMap<InstrumentNameExchange, String>,
consumer_gone: bool,
}
fn settle(
exchange: ExchangeId,
known: &SharedKnownLiveOrders,
key: UnindexedOrderKey,
found: OrderLookup,
tx: &impl EventSender,
) -> Option<u32> {
let mut known = known.lock();
match found {
OrderLookup::Ended(order) => {
if !known.ended(&order.key.cid) {
return Some(0);
}
let event = UnindexedAccountEvent::new(
exchange,
AccountEventKind::OrderSnapshot(Snapshot::new(
(*order).map_state(OrderState::Inactive),
)),
);
tx.send_event(event).then_some(1)
}
OrderLookup::Unknown => {
let held = known.ended(&key.cid);
drop(known);
if held {
warn!(
%exchange,
instrument = %key.instrument,
cid = %key.cid,
"The venue does not know an order held as live; a reconnect no longer asks \
about it"
);
}
Some(0)
}
OrderLookup::NotEnded => Some(0),
}
}
fn order_check_failed(
exchange: ExchangeId,
unchecked: &mut UncheckedOrders,
instrument: &InstrumentNameExchange,
reason: &str,
venue_wait: Duration,
) -> bool {
let rate_limited = reason == RATE_LIMITED;
match unchecked.failed(instrument, tokio::time::Instant::now(), venue_wait) {
Some(GapFailure::Retry(delay)) => {
if rate_limited {
debug!(
%exchange,
%instrument,
retry_in_secs = delay.as_secs(),
reason,
"Could not check how the orders held as live ended, retrying later"
);
} else {
warn!(
%exchange,
%instrument,
retry_in_secs = delay.as_secs(),
reason,
"Could not check how the orders held as live ended, retrying later"
);
}
rate_limited
}
Some(GapFailure::GivenUp) => {
error!(
%exchange,
%instrument,
retries = MAX_GAP_RETRIES,
reason,
"Gave up checking how the orders held as live ended: an order that ended \
while disconnected stays live in engine state until the next reconnect checks it; \
reconcile with fetch_open_orders"
);
false
}
None => false,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] pub(crate) mod tests {
use super::*;
use crate::{
error::{ConnectivityError, OrderError},
order::{
Order, OrderKey, OrderKind, TimeInForce,
id::VenueOrderId,
request::UnindexedOrderResponseCancel,
state::{Cancelled, Expired, Filled, InactiveOrderState, OpenInFlight},
},
trade::{AssetFees, Trade, TradeId},
};
use chrono::Utc;
use rust_decimal_macros::dec;
use rustrade_instrument::{Side, asset::name::AssetNameExchange};
fn pending_on(instrument: &InstrumentNameExchange) -> FnvHashSet<InstrumentNameExchange> {
FnvHashSet::from_iter([instrument.clone()])
}
impl PendingFills for FnvHashSet<InstrumentNameExchange> {
fn pending(&self, instrument: &InstrumentNameExchange) -> bool {
self.contains(instrument)
}
}
fn key(instrument: &str, cid: &str) -> UnindexedOrderKey {
OrderKey::new(
ExchangeId::BinanceSpot,
InstrumentNameExchange::new(instrument),
StrategyId::unknown(),
ClientOrderId::new(cid),
)
}
fn open(order_id: &str, filled: Decimal) -> Open {
Open::new(
VenueOrderId::Assigned(OrderId::new(order_id)),
Utc::now(),
filled,
)
}
fn event(
kind: AccountEventKind<ExchangeId, AssetNameExchange, InstrumentNameExchange>,
) -> UnindexedAccountEvent {
UnindexedAccountEvent::new(ExchangeId::BinanceSpot, kind)
}
fn snapshot(
key: UnindexedOrderKey,
state: OrderState<AssetNameExchange, InstrumentNameExchange>,
) -> UnindexedAccountEvent {
event(AccountEventKind::OrderSnapshot(Snapshot(Order {
key,
side: Side::Buy,
price: Some(dec!(100)),
quantity: dec!(2),
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
state,
})))
}
fn fill(order_id: &str, order_filled: Option<Decimal>) -> UnindexedAccountEvent {
event(AccountEventKind::Trade(Trade::new(
TradeId::new("t"),
OrderId::new(order_id),
InstrumentNameExchange::new("BTCUSDT"),
StrategyId::unknown(),
Utc::now(),
Side::Buy,
dec!(100),
dec!(1),
order_filled,
AssetFees::new(AssetNameExchange::new("usdt"), Decimal::ZERO, None),
)))
}
#[test]
fn the_set_keeps_the_kind_an_order_was_first_learned_with() {
let trailing_stop = OrderKind::TrailingStop {
offset: dec!(1.5),
offset_type: crate::order::TrailingOffsetType::Percentage,
activation_price: None,
};
let mut known = KnownLiveOrders::new(ExchangeId::BinanceSpot);
let order = key("BTCUSDT", "trailing");
known.placed(
&order,
dec!(2),
trailing_stop,
&OrderState::active(open("7", Decimal::ZERO)),
);
assert_eq!(known.kind_of(&order.cid), Some(trailing_stop));
known.live(
&order,
dec!(2),
OrderKind::TrailingStop {
offset: dec!(150),
offset_type: crate::order::TrailingOffsetType::BasisPoints,
activation_price: None,
},
&open("7", dec!(1)),
);
assert_eq!(known.kind_of(&order.cid), Some(trailing_stop));
known.ended(&order.cid);
assert_eq!(known.kind_of(&order.cid), None);
}
fn unresolved() -> OrderKind {
OrderKind::ConditionalUnresolved {
family: crate::order::ConditionalFamily::StopLimit,
trigger_price: Some(dec!(96)),
}
}
fn trailing_fixed_limit() -> OrderKind {
OrderKind::TrailingStopFixedLimit {
offset: dec!(25),
offset_type: crate::order::TrailingOffsetType::BasisPoints,
activation_price: Some(dec!(96)),
}
}
#[test]
fn an_unresolved_kind_is_replaced_by_a_resolved_one_and_never_the_reverse() {
let mut known = KnownLiveOrders::new(ExchangeId::BinanceMargin);
let order = key("BTCUSDT", "stop");
known.live(&order, dec!(2), unresolved(), &open("7", Decimal::ZERO));
assert_eq!(known.kind_of(&order.cid), Some(unresolved()));
known.live(
&order,
dec!(2),
trailing_fixed_limit(),
&open("7", Decimal::ZERO),
);
assert_eq!(known.kind_of(&order.cid), Some(trailing_fixed_limit()));
known.live(&order, dec!(2), unresolved(), &open("7", dec!(1)));
assert_eq!(known.kind_of(&order.cid), Some(trailing_fixed_limit()));
}
#[test]
fn a_recalled_kind_stands_in_for_an_unresolved_one_until_the_order_ends() {
let mut known = KnownLiveOrders::new(ExchangeId::BinanceMargin);
let held = key("BTCUSDT", "held");
let new = key("BTCUSDT", "new");
let placed = key("BTCUSDT", "placed");
let fixed = OrderKind::StopLimit {
trigger_price: dec!(96),
};
known.live(&held, dec!(2), unresolved(), &open("1", Decimal::ZERO));
known.placed(
&placed,
dec!(2),
fixed,
&OrderState::active(open("3", Decimal::ZERO)),
);
known.recall([
(held.cid.clone(), trailing_fixed_limit()),
(new.cid.clone(), trailing_fixed_limit()),
(placed.cid.clone(), trailing_fixed_limit()),
]);
assert_eq!(known.kind_of(&held.cid), Some(trailing_fixed_limit()));
assert_eq!(known.kind_of(&new.cid), Some(trailing_fixed_limit()));
known.live(&new, dec!(2), unresolved(), &open("2", Decimal::ZERO));
assert_eq!(known.kind_of(&new.cid), Some(trailing_fixed_limit()));
assert_eq!(known.kind_of(&placed.cid), Some(fixed));
let placed_later = key("BTCUSDT", "placed later");
known.recall([(placed_later.cid.clone(), trailing_fixed_limit())]);
known.placed(
&placed_later,
dec!(2),
fixed,
&OrderState::active(open("4", Decimal::ZERO)),
);
assert_eq!(known.kind_of(&placed_later.cid), Some(fixed));
known.ended(&new.cid);
assert_eq!(known.kind_of(&new.cid), None);
assert!(!known.recalled.contains_key(&new.cid));
}
#[test]
fn a_recall_ignores_ended_orders_unresolved_kinds_and_entries_past_the_limit() {
let mut known = KnownLiveOrders::new(ExchangeId::BinanceMargin);
let ended = ClientOrderId::new("ended");
known.ended(&ended);
let ignored = known.recall([
(ended.clone(), trailing_fixed_limit()),
(ClientOrderId::new("unresolved"), unresolved()),
]);
assert!(known.recalled.is_empty());
assert_eq!(ignored, 0, "only what the limit leaves out is counted");
let ignored = known.recall((0..MAX_KNOWN_LIVE_ORDERS + 2).map(|n| {
(
ClientOrderId::new(format!("cid-{n}")),
trailing_fixed_limit(),
)
}));
assert_eq!(ignored, 2);
assert_eq!(known.recalled.len(), MAX_KNOWN_LIVE_ORDERS);
assert!(
!known
.recalled
.contains_key(&ClientOrderId::new(format!("cid-{MAX_KNOWN_LIVE_ORDERS}")))
);
known.recall([(ClientOrderId::new("cid-0"), unresolved())]);
let fixed = OrderKind::StopLimit {
trigger_price: dec!(1),
};
assert_eq!(known.recall([(ClientOrderId::new("cid-0"), fixed)]), 0);
assert_eq!(known.kind_of(&ClientOrderId::new("cid-0")), Some(fixed));
}
#[test]
fn a_kind_being_placed_describes_a_report_that_arrives_before_the_answer() {
let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
let order = key("BTCUSDT", "racing");
let sending = known.sending(&order.cid, trailing_fixed_limit());
assert_eq!(
known.lock().kind_of(&order.cid),
Some(trailing_fixed_limit())
);
known
.lock()
.live(&order, dec!(2), unresolved(), &open("7", Decimal::ZERO));
assert_eq!(
known.lock().kind_of(&order.cid),
Some(trailing_fixed_limit())
);
let answer = OrderState::active(open("7", Decimal::ZERO));
known
.lock()
.placed(&order, dec!(2), trailing_fixed_limit(), &answer);
sending.sent(&answer);
let held = known.lock();
assert!(held.sending.is_empty() && held.recalled.is_empty());
assert_eq!(held.kind_of(&order.cid), Some(trailing_fixed_limit()));
}
#[test]
fn a_kind_being_placed_resolves_a_held_unresolved_one() {
let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
let order = key("BTCUSDT", "held");
known
.lock()
.live(&order, dec!(2), unresolved(), &open("7", Decimal::ZERO));
let sending = known.sending(&order.cid, trailing_fixed_limit());
assert_eq!(
known.lock().kind_of(&order.cid),
Some(trailing_fixed_limit())
);
sending.sent(&OrderState::active(open("7", Decimal::ZERO)));
assert_eq!(
known.lock().kind_of(&order.cid),
Some(trailing_fixed_limit())
);
}
#[test]
fn a_placed_kind_is_kept_after_the_answer_only_when_the_outcome_is_unknown() {
let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
let cid = |name: &str| ClientOrderId::new(name);
known
.sending(&cid("rejected"), trailing_fixed_limit())
.sent(&OrderState::inactive(OrderError::Rejected(
ApiError::OrderRejected("refused".to_string()),
)));
assert_eq!(known.lock().kind_of(&cid("rejected")), None);
known
.sending(&cid("unknown"), trailing_fixed_limit())
.sent(&OrderState::inactive(OrderError::Connectivity(
ConnectivityError::Timeout,
)));
assert_eq!(
known.lock().kind_of(&cid("unknown")),
Some(trailing_fixed_limit())
);
known.lock().ended(&cid("unknown"));
assert_eq!(known.lock().kind_of(&cid("unknown")), None);
drop(known.sending(&cid("cancelled"), trailing_fixed_limit()));
assert_eq!(
known.lock().kind_of(&cid("cancelled")),
Some(trailing_fixed_limit())
);
let sending = known.sending(&cid("unresolved"), unresolved());
assert_eq!(known.lock().kind_of(&cid("unresolved")), None);
drop(sending);
assert_eq!(known.lock().kind_of(&cid("unresolved")), None);
assert!(known.lock().sending.is_empty());
}
#[test]
fn a_placed_kind_past_the_limit_is_warned_of_with_the_lock_released() {
let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
known.lock().recall((0..MAX_KNOWN_LIVE_ORDERS).map(|n| {
(
ClientOrderId::new(format!("cid-{n}")),
trailing_fixed_limit(),
)
}));
let past = ClientOrderId::new("past");
let seen = logged_with_lock_free(&known, || {
drop(known.sending(&past, trailing_fixed_limit()));
});
assert_eq!(seen, vec![(tracing::Level::WARN, true)]);
assert_eq!(known.lock().kind_of(&past), None);
let seen = logged_with_lock_free(&known, || {
known
.sending(&ClientOrderId::new("cid-0"), trailing_fixed_limit())
.sent(&OrderState::inactive(OrderError::Connectivity(
ConnectivityError::Timeout,
)));
});
assert_eq!(seen, vec![], "a kind already recalled is within the limit");
}
#[test]
fn a_new_order_under_an_ended_id_is_held_once_placing_it() {
let mut known = KnownLiveOrders::new(ExchangeId::BinanceSpot);
let reused = key("BTCUSDT", "reused");
known.ended(&reused.cid);
known.live(
&reused,
dec!(2),
OrderKind::Limit,
&open("1", Decimal::ZERO),
);
assert!(!known.contains(&reused.cid));
known.placing(&reused.cid);
known.live(
&reused,
dec!(2),
OrderKind::Limit,
&open("2", Decimal::ZERO),
);
assert!(known.contains(&reused.cid));
known.assert_consistent();
}
#[test]
fn the_stream_adds_a_live_order_and_drops_it_at_each_kind_of_end() {
let mut known = KnownLiveOrders::new(ExchangeId::BinanceSpot);
let cancelled = key("BTCUSDT", "cancelled");
let rejected = key("BTCUSDT", "rejected");
let inactive = key("BTCUSDT", "inactive");
for (key, id) in [(&cancelled, "1"), (&rejected, "2"), (&inactive, "3")] {
known.observe(&snapshot(
key.clone(),
OrderState::active(open(id, Decimal::ZERO)),
));
assert!(known.contains(&key.cid));
}
known.observe(&event(AccountEventKind::OrderCancelled(
UnindexedOrderResponseCancel {
key: cancelled.clone(),
state: Ok(Cancelled::new(
OrderId::new("1"),
Utc::now(),
Some(Decimal::ZERO),
)),
},
)));
known.observe(&event(AccountEventKind::OrderCancelled(
UnindexedOrderResponseCancel {
key: rejected.clone(),
state: Err(OrderError::Rejected(ApiError::OrderRejected("no".into()))),
},
)));
known.observe(&snapshot(
inactive.clone(),
OrderState::Inactive(InactiveOrderState::Cancelled(Cancelled::new(
OrderId::new("3"),
Utc::now(),
Some(Decimal::ZERO),
))),
));
assert!(
known
.instruments_among(&[InstrumentNameExchange::new("BTCUSDT")])
.is_empty(),
"every order ended"
);
}
#[test]
fn a_fill_drops_its_order_only_once_it_reports_the_whole_quantity() {
let mut known = KnownLiveOrders::new(ExchangeId::BinanceSpot);
let order = key("BTCUSDT", "a");
known.live(&order, dec!(2), OrderKind::Limit, &open("7", Decimal::ZERO));
known.observe(&fill("7", None));
assert!(known.contains(&order.cid), "no cumulative says nothing");
known.observe(&fill("7", Some(dec!(1))));
assert!(known.contains(&order.cid), "half filled is still live");
known.observe(&fill("8", Some(dec!(2))));
assert!(known.contains(&order.cid), "another order's fill");
known.assert_consistent();
known.observe(&fill("7", Some(dec!(2))));
assert!(!known.contains(&order.cid), "filled");
known.assert_consistent();
}
#[test]
fn a_report_without_the_venue_id_keeps_the_one_known() {
let mut known = KnownLiveOrders::new(ExchangeId::BinanceSpot);
let order = key("BTCUSDT", "a");
known.live(&order, dec!(2), OrderKind::Limit, &open("7", Decimal::ZERO));
known.live(
&order,
dec!(2),
OrderKind::Limit,
&Open::new(VenueOrderId::ClientAssigned, Utc::now(), dec!(1)),
);
known.assert_consistent();
known.observe(&fill("7", Some(dec!(2))));
assert!(!known.contains(&order.cid), "still found by its venue id");
known.assert_consistent();
}
#[test]
fn an_order_with_nothing_left_or_already_ended_is_not_added() {
let mut known = KnownLiveOrders::new(ExchangeId::BinanceSpot);
let filled = key("BTCUSDT", "filled");
known.live(&filled, dec!(2), OrderKind::Limit, &open("1", dec!(2)));
assert!(!known.contains(&filled.cid));
let late = key("BTCUSDT", "late");
known.live(&late, dec!(2), OrderKind::Limit, &open("2", Decimal::ZERO));
assert!(known.ended(&late.cid));
known.live(&late, dec!(2), OrderKind::Limit, &open("2", Decimal::ZERO));
assert!(
!known.contains(&late.cid),
"a late live report does not bring it back"
);
assert!(!known.ended(&late.cid), "and it is no longer held");
}
#[test]
fn a_placement_holds_an_open_order_and_settles_an_ended_one() {
let mut known = KnownLiveOrders::new(ExchangeId::BinanceSpot);
let open_order = key("BTCUSDT", "open");
known.placed(
&open_order,
dec!(2),
OrderKind::Limit,
&OrderState::active(open("1", Decimal::ZERO)),
);
assert!(known.contains(&open_order.cid));
let in_flight = key("BTCUSDT", "in-flight");
known.placed(
&in_flight,
dec!(2),
OrderKind::Limit,
&OrderState::active(OpenInFlight::new(Utc::now())),
);
assert!(
!known.contains(&in_flight.cid),
"not yet known to the venue"
);
let ended = [
(
"filled",
OrderState::fully_filled(Filled::new(OrderId::new("2"), Utc::now(), dec!(2), None)),
),
(
"expired",
OrderState::inactive(Expired::new(OrderId::new("3"), Utc::now(), Some(dec!(1)))),
),
(
"failed",
OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected("no".into()))),
),
];
for (cid, state) in ended {
let order = key("BTCUSDT", cid);
known.placed(&order, dec!(2), OrderKind::Limit, &state);
known.live(&order, dec!(2), OrderKind::Limit, &open("9", Decimal::ZERO));
assert!(
!known.contains(&order.cid),
"{cid}: a late live report does not bring it back"
);
}
known.assert_consistent();
}
#[test]
fn a_placement_with_an_unknown_outcome_is_held_once_reported_live() {
let mut known = KnownLiveOrders::new(ExchangeId::BinanceSpot);
let order = key("BTCUSDT", "unknown");
known.placed(
&order,
dec!(2),
OrderKind::Limit,
&OrderState::inactive(OrderError::Connectivity(ConnectivityError::Timeout)),
);
assert!(!known.contains(&order.cid), "not yet known to be live");
known.live(&order, dec!(2), OrderKind::Limit, &open("9", Decimal::ZERO));
assert!(known.contains(&order.cid), "held once reported live");
known.assert_consistent();
}
#[test]
fn a_live_report_re_indexes_only_what_changed() {
let mut known = KnownLiveOrders::new(ExchangeId::BinanceSpot);
let order = key("BTCUSDT", "a");
known.live(&order, dec!(2), OrderKind::Limit, &open("7", Decimal::ZERO));
known.live(&order, dec!(2), OrderKind::Limit, &open("7", dec!(1)));
known.assert_consistent();
known.live(&order, dec!(2), OrderKind::Limit, &open("8", dec!(1)));
known.assert_consistent();
known.observe(&fill("7", Some(dec!(2))));
assert!(known.contains(&order.cid), "no longer found by its old id");
let no_id = Open::new(VenueOrderId::ClientAssigned, Utc::now(), dec!(1));
known.live(&order, dec!(2), OrderKind::Limit, &no_id);
known.assert_consistent();
let moved = key("ETHUSDT", "a");
known.live(&moved, dec!(2), OrderKind::Limit, &no_id);
known.assert_consistent();
assert_eq!(
known.instruments(),
[InstrumentNameExchange::new("ETHUSDT")]
);
let back = key("BTCUSDT", "a");
known.live(&back, dec!(2), OrderKind::Limit, &open("8", dec!(1)));
known.assert_consistent();
assert_eq!(
known.instruments(),
[InstrumentNameExchange::new("BTCUSDT")]
);
known.observe(&fill("8", Some(dec!(2))));
assert!(
!known.contains(&order.cid),
"found by its id on its instrument"
);
}
pub(crate) fn logged_with_lock_free(
known: &SharedKnownLiveOrders,
f: impl FnOnce(),
) -> Vec<(tracing::Level, bool)> {
use std::sync::Mutex;
use tracing_subscriber::layer::SubscriberExt;
struct LockFree {
known: SharedKnownLiveOrders,
seen: Arc<Mutex<Vec<(tracing::Level, bool)>>>,
}
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for LockFree {
fn on_event(
&self,
event: &tracing::Event<'_>,
_: tracing_subscriber::layer::Context<'_, S>,
) {
let free = self.known.0.try_lock().is_some();
self.seen
.lock()
.unwrap()
.push((*event.metadata().level(), free));
}
}
let seen = Arc::new(Mutex::new(Vec::new()));
let layer = LockFree {
known: known.clone(),
seen: Arc::clone(&seen),
};
tracing::subscriber::with_default(tracing_subscriber::registry().with(layer), f);
seen.lock().unwrap().clone()
}
#[test]
fn forgetting_is_logged_with_the_lock_released() {
let known = KnownLiveOrders::shared(ExchangeId::BinanceSpot);
let seen = logged_with_lock_free(&known, || {
let mut held = known.lock();
for n in 0..MAX_KNOWN_LIVE_ORDERS + 2 {
held.live(
&key("BTCUSDT", &n.to_string()),
dec!(1),
OrderKind::Limit,
&open(&n.to_string(), Decimal::ZERO),
);
}
assert_eq!(held.forgotten.len(), 2, "kept while held");
});
assert_eq!(
seen,
[(tracing::Level::WARN, true), (tracing::Level::DEBUG, true)]
);
assert!(known.lock().forgotten.is_empty());
assert!(!known.lock().contains(&ClientOrderId::new("1")));
}
#[test]
fn an_order_the_venue_does_not_know_is_logged_with_the_lock_released() {
let known = KnownLiveOrders::shared(ExchangeId::BinanceSpot);
let order = key("BTCUSDT", "a");
known
.lock()
.live(&order, dec!(2), OrderKind::Limit, &open("7", Decimal::ZERO));
let (tx, _rx) = mpsc::unbounded_channel();
let seen = logged_with_lock_free(&known, || {
let sent = settle(
ExchangeId::BinanceSpot,
&known,
order.clone(),
OrderLookup::Unknown,
&tx,
);
assert_eq!(sent, Some(0));
});
assert_eq!(seen, [(tracing::Level::WARN, true)]);
assert!(!known.lock().contains(&order.cid));
}
#[test]
fn a_full_set_forgets_its_oldest_order() {
let mut known = KnownLiveOrders::new(ExchangeId::BinanceSpot);
for n in 0..=MAX_KNOWN_LIVE_ORDERS {
let instrument = if n == 0 { "ETHUSDT" } else { "BTCUSDT" };
known.live(
&key(instrument, &n.to_string()),
dec!(1),
OrderKind::Limit,
&open(&n.to_string(), Decimal::ZERO),
);
}
assert!(!known.contains(&ClientOrderId::new("0")));
assert!(known.contains(&ClientOrderId::new("1")));
assert_eq!(
known.instruments(),
[InstrumentNameExchange::new("BTCUSDT")]
);
assert!(known.ended(&ClientOrderId::new("2")));
known.live(
&key("BTCUSDT", "new"),
dec!(1),
OrderKind::Limit,
&open("new", Decimal::ZERO),
);
assert!(known.contains(&ClientOrderId::new("1")));
known.live(
&key("BTCUSDT", "newer"),
dec!(1),
OrderKind::Limit,
&open("newer", Decimal::ZERO),
);
assert!(
!known.contains(&ClientOrderId::new("1")),
"then the oldest again"
);
known.assert_consistent();
assert!(known.contains(&ClientOrderId::new(MAX_KNOWN_LIVE_ORDERS.to_string())));
assert_eq!(
known
.keys_on(
ExchangeId::BinanceSpot,
&InstrumentNameExchange::new("BTCUSDT")
)
.len(),
MAX_KNOWN_LIVE_ORDERS
);
}
#[test]
fn keys_on_names_only_that_instrument() {
let mut known = KnownLiveOrders::new(ExchangeId::BinanceSpot);
known.live(
&key("BTCUSDT", "a"),
dec!(1),
OrderKind::Limit,
&open("1", Decimal::ZERO),
);
known.live(
&key("ETHUSDT", "b"),
dec!(1),
OrderKind::Limit,
&open("2", Decimal::ZERO),
);
assert_eq!(
known.keys_on(
ExchangeId::BinanceSpot,
&InstrumentNameExchange::new("ETHUSDT")
),
[key("ETHUSDT", "b")]
);
let (btc, eth, sol) = (
InstrumentNameExchange::new("BTCUSDT"),
InstrumentNameExchange::new("ETHUSDT"),
InstrumentNameExchange::new("SOLUSDT"),
);
assert_eq!(
known.instruments_among(&[sol, eth.clone()]),
[eth],
"only those asked about that hold an order"
);
assert_eq!(known.instruments_among(std::slice::from_ref(&btc)), [btc]);
known.assert_consistent();
}
#[test]
fn an_instrument_stops_being_held_with_its_last_order() {
let mut known = KnownLiveOrders::new(ExchangeId::BinanceSpot);
let (btc, eth) = (
InstrumentNameExchange::new("BTCUSDT"),
InstrumentNameExchange::new("ETHUSDT"),
);
known.live(
&key("BTCUSDT", "a"),
dec!(1),
OrderKind::Limit,
&open("1", Decimal::ZERO),
);
known.live(
&key("BTCUSDT", "b"),
dec!(1),
OrderKind::Limit,
&open("2", Decimal::ZERO),
);
known.ended(&ClientOrderId::new("a"));
assert_eq!(
known.instruments(),
std::slice::from_ref(&btc),
"b still holds it"
);
known.assert_consistent();
known.live(
&key("ETHUSDT", "b"),
dec!(1),
OrderKind::Limit,
&open("2", Decimal::ZERO),
);
assert_eq!(
known.instruments(),
std::slice::from_ref(ð),
"b moved instrument"
);
assert!(known.keys_on(ExchangeId::BinanceSpot, &btc).is_empty());
assert_eq!(
known.keys_on(ExchangeId::BinanceSpot, ð),
[key("ETHUSDT", "b")]
);
known.assert_consistent();
known.ended(&ClientOrderId::new("b"));
assert!(known.instruments().is_empty());
assert!(known.instruments_among(&[btc, eth]).is_empty());
known.assert_consistent();
}
#[test]
fn an_instrument_with_a_fill_gap_waits_for_it() {
let btc = InstrumentNameExchange::new("BTCUSDT");
let eth = InstrumentNameExchange::new("ETHUSDT");
let pending = pending_on(&btc);
let mut unchecked = UncheckedOrders::default();
unchecked.open([btc.clone(), eth.clone()]);
assert_eq!(
unchecked.ready(&pending, tokio::time::Instant::now()),
std::slice::from_ref(ð)
);
unchecked.checked(ð);
assert!(unchecked.contains(&btc), "BTCUSDT still waits");
assert_eq!(
unchecked.next_due(&pending),
None,
"on its gap, not on a timer"
);
let pending = NoPendingFills;
assert_eq!(
unchecked.ready(&pending, tokio::time::Instant::now()),
std::slice::from_ref(&btc)
);
assert!(unchecked.next_due(&pending).is_some(), "due now");
unchecked.checked(&btc);
assert!(unchecked.is_empty());
}
#[test]
fn a_due_retry_on_an_instrument_back_on_a_gap_does_not_wake_the_loop() {
let btc = InstrumentNameExchange::new("BTCUSDT");
let mut unchecked = UncheckedOrders::default();
unchecked.open([btc.clone()]);
let start = tokio::time::Instant::now();
unchecked.failed(&btc, start, Duration::ZERO);
let pending = pending_on(&btc);
let later = start + Duration::from_secs(GAP_RETRY_BASE_SECS * 2);
assert!(unchecked.ready(&pending, later).is_empty());
assert_eq!(unchecked.next_due(&pending), None);
assert_eq!(
unchecked.ready(&NoPendingFills, later),
std::slice::from_ref(&btc),
"due again once the gap is settled"
);
}
#[test]
fn instruments_are_retried_on_their_own_schedules() {
let (btc, eth) = (
InstrumentNameExchange::new("BTCUSDT"),
InstrumentNameExchange::new("ETHUSDT"),
);
let mut unchecked = UncheckedOrders::default();
unchecked.open([btc.clone(), eth.clone()]);
let now = tokio::time::Instant::now();
unchecked.failed(&btc, now, Duration::ZERO);
unchecked.failed(&btc, now, Duration::ZERO);
unchecked.failed(ð, now, Duration::ZERO);
let pending = NoPendingFills;
let base = Duration::from_secs(GAP_RETRY_BASE_SECS);
assert_eq!(
unchecked.ready(&pending, now + base),
std::slice::from_ref(ð)
);
assert_eq!(unchecked.next_due(&pending), Some(now + base));
unchecked.open([btc.clone()]);
assert!(
unchecked.ready(&pending, now + base).len() == 1,
"a reconnect does not bring BTCUSDT's retry forward"
);
assert_eq!(unchecked.ready(&pending, now + base * 2).len(), 2);
}
#[test]
fn a_failed_check_backs_off_and_is_given_up_after_its_retries() {
let mut unchecked = UncheckedOrders::default();
unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
let btc = InstrumentNameExchange::new("BTCUSDT");
let pending = NoPendingFills;
let now = tokio::time::Instant::now();
for retry in 0..MAX_GAP_RETRIES {
let delay = Duration::from_secs(GAP_RETRY_BASE_SECS << retry);
assert_eq!(
unchecked.failed(&btc, now, Duration::ZERO),
Some(GapFailure::Retry(delay))
);
assert_eq!(unchecked.next_due(&pending), Some(now + delay));
assert!(unchecked.ready(&pending, now).is_empty(), "waits");
assert_eq!(unchecked.ready(&pending, now + delay).len(), 1);
}
assert_eq!(
unchecked.failed(&btc, now, Duration::ZERO),
Some(GapFailure::GivenUp)
);
assert!(unchecked.is_empty());
assert_eq!(unchecked.next_due(&pending), None);
assert_eq!(
unchecked.failed(&btc, now, Duration::ZERO),
None,
"no longer waiting"
);
}
fn cancelled_order(key: &UnindexedOrderKey) -> UnindexedInactiveOrder {
Order {
key: key.clone(),
side: Side::Buy,
price: Some(dec!(100)),
quantity: dec!(1),
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
state: InactiveOrderState::Cancelled(Cancelled::new(
OrderId::new("1"),
Utc::now(),
Some(Decimal::ZERO),
)),
}
}
#[tokio::test]
async fn each_order_is_looked_up_once_and_reported_under_the_first_key_that_finds_it() {
let wrong = key("ETHUSDT", "a");
let right = key("BTCUSDT", "a");
let mut repeated = key("BTCUSDT", "a");
repeated.strategy = StrategyId::new("other");
let live = key("BTCUSDT", "live");
let asked = std::sync::Mutex::new(Vec::new());
let ended = fetch_ended_by_key(
&[wrong.clone(), right.clone(), repeated, live.clone(), live],
|key| {
asked.lock().unwrap().push(key.clone());
async move {
Ok(if key.instrument.name() == "BTCUSDT" && key.cid.0 == "a" {
OrderLookup::Ended(Box::new(cancelled_order(&key)))
} else if key.cid.0 == "a" {
OrderLookup::Unknown
} else {
OrderLookup::NotEnded
})
}
},
)
.await
.unwrap();
let reported: Vec<_> = ended.iter().map(|order| &order.key).collect();
assert_eq!(reported, [&right], "under the key that found it, once");
assert_eq!(
*asked.lock().unwrap(),
[wrong, right, key("BTCUSDT", "live")],
"a repeated instrument and cid is not asked again"
);
}
#[tokio::test]
async fn one_failed_lookup_fails_the_call() {
let result = fetch_ended_by_key(
&[key("BTCUSDT", "a"), key("BTCUSDT", "b")],
|key| async move {
if key.cid.0 == "b" {
Err(UnindexedClientError::Api(ApiError::rate_limited(None)))
} else {
Ok(OrderLookup::Ended(Box::new(cancelled_order(&key))))
}
},
)
.await;
assert!(matches!(
result,
Err(UnindexedClientError::Api(ApiError::RateLimit { .. }))
));
}
#[tokio::test]
async fn nothing_asked_is_nothing_looked_up() {
let ended = fetch_ended_by_key(&[], |_| async {
panic!("nothing to look up");
})
.await
.unwrap();
assert!(ended.is_empty());
}
fn held_on(instruments: &[&str]) -> (SharedKnownLiveOrders, UncheckedOrders) {
let known = KnownLiveOrders::shared(ExchangeId::BinanceSpot);
let mut unchecked = UncheckedOrders::default();
for (n, instrument) in instruments.iter().enumerate() {
known.lock().live(
&key(instrument, instrument),
dec!(1),
OrderKind::Limit,
&open(&n.to_string(), Decimal::ZERO),
);
unchecked.open([InstrumentNameExchange::new(*instrument)]);
}
(known, unchecked)
}
async fn check_with_slow_instruments(
known: &SharedKnownLiveOrders,
unchecked: &mut UncheckedOrders,
) -> Vec<UnindexedAccountEvent> {
let (tx, mut rx) = mpsc::unbounded_channel();
recover_ended_orders(
ExchangeId::BinanceSpot,
known,
unchecked,
&NoPendingFills,
&tx,
OpenListing::PerInstrument,
|instruments: Vec<InstrumentNameExchange>| async move {
if instruments
.iter()
.any(|instrument| instrument.name().starts_with("SLOW"))
{
std::future::pending::<()>().await;
}
Ok(FnvHashSet::default())
},
|key: UnindexedOrderKey| async move {
Ok(OrderLookup::Ended(Box::new(cancelled_order(&key))))
},
)
.await;
std::iter::from_fn(|| rx.try_recv().ok()).collect()
}
#[tokio::test(start_paused = true)]
async fn a_timed_out_instrument_waits_for_the_venues_wait() {
let (known, mut unchecked) = held_on(&["AAA", "SLOW"]);
let (tx, _rx) = mpsc::unbounded_channel();
let long = Duration::from_secs(GAP_RETRY_BASE_SECS * 10);
recover_ended_orders(
ExchangeId::BinanceSpot,
&known,
&mut unchecked,
&NoPendingFills,
&tx,
OpenListing::PerInstrument,
|instruments: Vec<InstrumentNameExchange>| async move {
if instruments[0].name().as_str() == "SLOW" {
std::future::pending::<()>().await;
}
tokio::task::yield_now().await;
Err(UnindexedClientError::Api(ApiError::rate_limited(Some(
long,
))))
},
|_: UnindexedOrderKey| async { panic!("nothing listed, nothing looked up") },
)
.await;
let timed_out = tokio::time::Instant::now();
assert!(
unchecked
.ready(&NoPendingFills, timed_out + long - Duration::from_secs(1))
.is_empty(),
"retried before the venue's wait"
);
assert_eq!(
unchecked.ready(&NoPendingFills, timed_out + long * 2),
[
InstrumentNameExchange::new("AAA"),
InstrumentNameExchange::new("SLOW")
]
);
}
#[tokio::test(start_paused = true)]
async fn a_timed_out_pass_charges_only_the_instruments_it_started() {
let (known, mut unchecked) = held_on(&["SLOWA", "SLOWB", "XFAST"]);
let start = tokio::time::Instant::now();
let sent = check_with_slow_instruments(&known, &mut unchecked).await;
assert!(sent.is_empty(), "XFAST never started: {sent:?}");
let pending = NoPendingFills;
let after = start + Duration::from_secs(ORDER_CHECK_TIMEOUT_SECS);
assert_eq!(
unchecked.ready(&pending, after),
[InstrumentNameExchange::new("XFAST")],
"only the one never started is due at once"
);
let retry = after + Duration::from_secs(GAP_RETRY_BASE_SECS);
assert_eq!(unchecked.ready(&pending, retry).len(), 3);
for instrument in ["SLOWA", "SLOWB"] {
assert_eq!(
unchecked.failed(
&InstrumentNameExchange::new(instrument),
retry,
Duration::ZERO
),
Some(GapFailure::Retry(Duration::from_secs(
GAP_RETRY_BASE_SECS * 2
))),
"{instrument} has failed once already"
);
}
assert!(known.lock().contains(&ClientOrderId::new("XFAST")));
}
#[tokio::test(start_paused = true)]
async fn a_timed_out_pass_keeps_what_it_finished() {
let (known, mut unchecked) = held_on(&["AFAST", "SLOWB", "SLOWC"]);
let sent = check_with_slow_instruments(&known, &mut unchecked).await;
let [event] = sent.as_slice() else {
panic!("AFAST's order: {sent:?}");
};
let AccountEventKind::OrderSnapshot(Snapshot(order)) = &event.kind else {
panic!("an order snapshot: {event:?}");
};
assert_eq!(order.key.cid, ClientOrderId::new("AFAST"));
assert!(!unchecked.contains(&InstrumentNameExchange::new("AFAST")));
assert!(!known.lock().contains(&ClientOrderId::new("AFAST")));
assert!(
unchecked
.ready(&NoPendingFills, tokio::time::Instant::now())
.is_empty(),
"SLOWB and SLOWC both started, so both wait for a retry"
);
known.lock().assert_consistent();
}
#[tokio::test]
async fn a_failed_lookup_in_a_batch_charges_only_its_instrument() {
let known = KnownLiveOrders::shared(ExchangeId::BinanceSpot);
for (instrument, cid, id) in [
("BTCUSDT", "btc-ended", "1"),
("BTCUSDT", "btc-failing", "2"),
("ETHUSDT", "eth-ended", "3"),
] {
known.lock().live(
&key(instrument, cid),
dec!(1),
OrderKind::Limit,
&open(id, Decimal::ZERO),
);
}
let mut unchecked = UncheckedOrders::default();
unchecked.open(known.lock().instruments());
let (tx, mut rx) = mpsc::unbounded_channel();
let listings = AtomicUsize::new(0);
recover_ended_orders(
ExchangeId::BinanceSpot,
&known,
&mut unchecked,
&NoPendingFills,
&tx,
OpenListing::Batched,
|instruments: Vec<InstrumentNameExchange>| {
listings.fetch_add(1, Ordering::Relaxed);
assert_eq!(instruments.len(), 2, "one listing of both");
async { Ok(FnvHashSet::default()) }
},
|key: UnindexedOrderKey| async move {
if key.cid.0 == "btc-failing" {
Err(UnindexedClientError::Connectivity(
ConnectivityError::Timeout,
))
} else {
Ok(OrderLookup::Ended(Box::new(cancelled_order(&key))))
}
},
)
.await;
assert_eq!(listings.load(Ordering::Relaxed), 1);
let mut sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
.map(|event| match event.kind {
AccountEventKind::OrderSnapshot(Snapshot(order)) => order.key.cid,
other => panic!("an order snapshot: {other:?}"),
})
.collect();
sent.sort();
assert_eq!(
sent,
[
ClientOrderId::new("btc-ended"),
ClientOrderId::new("eth-ended")
]
);
let btc = InstrumentNameExchange::new("BTCUSDT");
assert!(!unchecked.contains(&InstrumentNameExchange::new("ETHUSDT")));
assert!(unchecked.contains(&btc), "BTCUSDT waits for a retry");
assert_eq!(
unchecked.failed(&btc, tokio::time::Instant::now(), Duration::ZERO),
Some(GapFailure::Retry(Duration::from_secs(
GAP_RETRY_BASE_SECS * 2
))),
"and has failed once already"
);
let known = known.lock();
assert!(known.contains(&ClientOrderId::new("btc-failing")));
assert!(!known.contains(&ClientOrderId::new("btc-ended")));
known.assert_consistent();
}
fn failed_once(unchecked: &mut UncheckedOrders, instrument: &str) -> bool {
unchecked.failed(
&InstrumentNameExchange::new(instrument),
tokio::time::Instant::now(),
Duration::ZERO,
) == Some(GapFailure::Retry(Duration::from_secs(
GAP_RETRY_BASE_SECS * 2,
)))
}
#[tokio::test]
async fn a_rate_limited_lookup_stops_the_whole_check() {
let (known, mut unchecked) = held_on(&["AAA", "BBB", "CCC"]);
let (tx, mut rx) = mpsc::unbounded_channel();
let (listings, lookups) = (AtomicUsize::new(0), AtomicUsize::new(0));
recover_ended_orders(
ExchangeId::BinanceSpot,
&known,
&mut unchecked,
&NoPendingFills,
&tx,
OpenListing::PerInstrument,
|_| {
listings.fetch_add(1, Ordering::Relaxed);
async { Ok(FnvHashSet::default()) }
},
|_| {
lookups.fetch_add(1, Ordering::Relaxed);
async { Err(UnindexedClientError::Api(ApiError::rate_limited(None))) }
},
)
.await;
assert_eq!(lookups.load(Ordering::Relaxed), 1);
assert_eq!(listings.load(Ordering::Relaxed), 1);
assert!(rx.try_recv().is_err(), "nothing reported");
for instrument in ["AAA", "BBB", "CCC"] {
assert!(failed_once(&mut unchecked, instrument), "{instrument}");
}
known.lock().assert_consistent();
}
#[tokio::test]
async fn a_rate_limit_hint_longer_than_the_backoff_holds_the_retry_back() {
let (known, mut unchecked) = held_on(&["AAA", "BBB"]);
let (tx, _rx) = mpsc::unbounded_channel();
let wait = Duration::from_secs(GAP_RETRY_BASE_SECS * 10);
let started = tokio::time::Instant::now();
recover_ended_orders(
ExchangeId::BinanceSpot,
&known,
&mut unchecked,
&NoPendingFills,
&tx,
OpenListing::PerInstrument,
|_| async { Ok(FnvHashSet::default()) },
move |_| async move {
Err(UnindexedClientError::Api(ApiError::rate_limited(Some(
wait,
))))
},
)
.await;
let due = unchecked.next_due(&NoPendingFills).unwrap();
assert!(due >= started + wait, "retried before the venue's wait");
assert!(
unchecked
.ready(&NoPendingFills, started + wait - Duration::from_secs(1))
.is_empty()
);
assert_eq!(
unchecked.ready(&NoPendingFills, tokio::time::Instant::now() + wait),
[
InstrumentNameExchange::new("AAA"),
InstrumentNameExchange::new("BBB")
]
);
}
#[tokio::test]
async fn a_longer_wait_read_later_holds_back_every_instrument_charged() {
let long = Duration::from_secs(GAP_RETRY_BASE_SECS * 10);
let first_failures = [
UnindexedClientError::Api(ApiError::rate_limited(Some(Duration::from_secs(1)))),
UnindexedClientError::Connectivity(ConnectivityError::Timeout),
];
for first in first_failures {
let (known, mut unchecked) = held_on(&["AAA", "BBB", "CCC"]);
let (tx, _rx) = mpsc::unbounded_channel();
let started = tokio::time::Instant::now();
recover_ended_orders(
ExchangeId::BinanceSpot,
&known,
&mut unchecked,
&NoPendingFills,
&tx,
OpenListing::PerInstrument,
|instruments: Vec<InstrumentNameExchange>| {
let first = first.clone();
async move {
if instruments[0].name().as_str() == "AAA" {
tokio::task::yield_now().await;
return Err(first);
}
for _ in 0..3 {
tokio::task::yield_now().await;
}
Err(UnindexedClientError::Api(ApiError::rate_limited(Some(
long,
))))
}
},
|_: UnindexedOrderKey| async { panic!("nothing listed, nothing looked up") },
)
.await;
assert!(
unchecked
.ready(&NoPendingFills, started + long - Duration::from_secs(1))
.is_empty(),
"{first:?}: retried before the longest wait"
);
assert_eq!(
unchecked
.ready(&NoPendingFills, tokio::time::Instant::now() + long)
.len(),
3,
"{first:?}"
);
}
}
#[test]
fn a_retry_waits_the_longer_of_the_backoff_and_the_minimum() {
let (_, mut unchecked) = held_on(&["AAA"]);
let aaa = InstrumentNameExchange::new("AAA");
let now = tokio::time::Instant::now();
let backoff = Duration::from_secs(GAP_RETRY_BASE_SECS);
assert_eq!(
unchecked.failed(&aaa, now, backoff * 3),
Some(GapFailure::Retry(backoff * 3))
);
assert_eq!(
unchecked.failed(&aaa, now, Duration::from_secs(1)),
Some(GapFailure::Retry(backoff * 2)),
"a shorter minimum leaves the backoff"
);
}
#[test]
fn a_check_stopped_by_the_rate_limit_keeps_the_longest_wait() {
let throttled = Throttled::default();
assert!(!throttled.stopped());
throttled.note(&UnindexedClientError::Connectivity(
ConnectivityError::Timeout,
));
assert!(!throttled.stopped(), "only a rate limit stops the check");
throttled.note(&UnindexedClientError::Api(ApiError::rate_limited(None)));
assert!(throttled.stopped());
assert_eq!(throttled.wait(), Duration::ZERO);
for secs in [300, 900, 60] {
throttled.note(&UnindexedClientError::Api(ApiError::rate_limited(Some(
Duration::from_secs(secs),
))));
}
assert_eq!(throttled.wait(), Duration::from_secs(900));
}
#[tokio::test]
async fn a_rate_limited_batch_charges_the_lookups_it_did_not_settle() {
let (known, mut unchecked) = held_on(&["AAA", "BBB"]);
let (tx, mut rx) = mpsc::unbounded_channel();
let lookups = AtomicUsize::new(0);
recover_ended_orders(
ExchangeId::BinanceSpot,
&known,
&mut unchecked,
&NoPendingFills,
&tx,
OpenListing::Batched,
|_| async { Ok(FnvHashSet::default()) },
|_| {
lookups.fetch_add(1, Ordering::Relaxed);
async { Err(UnindexedClientError::Api(ApiError::rate_limited(None))) }
},
)
.await;
assert!(lookups.load(Ordering::Relaxed) <= 2);
assert!(rx.try_recv().is_err(), "nothing reported");
for instrument in ["AAA", "BBB"] {
assert!(failed_once(&mut unchecked, instrument), "{instrument}");
}
assert_eq!(known.lock().instruments().len(), 2, "both still held");
}
#[tokio::test]
async fn a_rate_limited_lookup_settles_those_in_flight_and_starts_no_more() {
let names: Vec<String> = (0..ORDER_LOOKUPS_IN_FLIGHT + 2)
.map(|n| format!("I{n}"))
.collect();
let (known, mut unchecked) = held_on(&names.iter().map(String::as_str).collect::<Vec<_>>());
let (tx, mut rx) = mpsc::unbounded_channel();
let lookups = AtomicUsize::new(0);
recover_ended_orders(
ExchangeId::BinanceSpot,
&known,
&mut unchecked,
&NoPendingFills,
&tx,
OpenListing::Batched,
|_| async { Ok(FnvHashSet::default()) },
|key: UnindexedOrderKey| {
let first = lookups.fetch_add(1, Ordering::Relaxed) == 0;
async move {
tokio::task::yield_now().await;
if first {
return Err(UnindexedClientError::Api(ApiError::rate_limited(None)));
}
tokio::task::yield_now().await;
Ok(OrderLookup::Ended(Box::new(cancelled_order(&key))))
}
},
)
.await;
assert_eq!(lookups.load(Ordering::Relaxed), ORDER_LOOKUPS_IN_FLIGHT);
let reported = std::iter::from_fn(|| rx.try_recv().ok()).count();
assert_eq!(
reported,
ORDER_LOOKUPS_IN_FLIGHT - 1,
"those in flight are settled"
);
let retried = names
.iter()
.filter(|name| failed_once(&mut unchecked, name))
.count();
assert_eq!(retried, 3, "the refused one and the two never asked about");
assert_eq!(known.lock().instruments().len(), 3);
known.lock().assert_consistent();
}
#[tokio::test]
async fn a_rate_limited_listing_stops_the_whole_check() {
let (known, mut unchecked) = held_on(&["AAA", "BBB", "CCC"]);
let (tx, _rx) = mpsc::unbounded_channel();
let listings = AtomicUsize::new(0);
recover_ended_orders(
ExchangeId::BinanceSpot,
&known,
&mut unchecked,
&NoPendingFills,
&tx,
OpenListing::PerInstrument,
|_| {
listings.fetch_add(1, Ordering::Relaxed);
async { Err(UnindexedClientError::Api(ApiError::rate_limited(None))) }
},
|_: UnindexedOrderKey| async { panic!("nothing listed, nothing looked up") },
)
.await;
assert_eq!(listings.load(Ordering::Relaxed), 1);
for instrument in ["AAA", "BBB", "CCC"] {
assert!(failed_once(&mut unchecked, instrument), "{instrument}");
}
}
#[tokio::test(start_paused = true)]
async fn a_timed_out_batch_charges_every_instrument_in_it() {
let (known, mut unchecked) = held_on(&["AAA", "BBB"]);
let (tx, mut rx) = mpsc::unbounded_channel();
recover_ended_orders(
ExchangeId::BinanceSpot,
&known,
&mut unchecked,
&NoPendingFills,
&tx,
OpenListing::Batched,
|_: Vec<InstrumentNameExchange>| async {
std::future::pending::<()>().await;
Ok(FnvHashSet::default())
},
|_: UnindexedOrderKey| async { Ok(OrderLookup::NotEnded) },
)
.await;
assert!(rx.try_recv().is_err(), "nothing reported");
let now = tokio::time::Instant::now();
assert!(
unchecked.ready(&NoPendingFills, now).is_empty(),
"both wait"
);
for instrument in ["AAA", "BBB"] {
assert_eq!(
unchecked.failed(
&InstrumentNameExchange::new(instrument),
now,
Duration::ZERO
),
Some(GapFailure::Retry(Duration::from_secs(
GAP_RETRY_BASE_SECS * 2
))),
"{instrument} has failed once"
);
}
}
}