use crate::{
AccountEventKind, UnindexedAccountEvent,
error::{ApiError, ConnectivityError, OrderError, UnindexedClientError, UnindexedOrderError},
order::{
Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType,
id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
request::UnindexedOrderResponseCancel,
state::{Cancelled, Open, OrderState},
},
trade::{AssetFees, Trade, TradeId},
};
pub(crate) use crate::client::dedup::{
SharedDedupCache, dedup_key_from_event, is_duplicate, new_dedup_cache,
};
use binance_sdk::common::errors::{ConnectorError, WebsocketError};
use chrono::{DateTime, TimeZone, Utc};
use rust_decimal::Decimal;
use rustrade_instrument::{
Side, asset::name::AssetNameExchange, exchange::ExchangeId,
instrument::name::InstrumentNameExchange,
};
use smol_str::format_smolstr;
use std::{
pin::Pin,
str::FromStr,
sync::Arc,
task::{Context, Poll},
time::Duration,
};
use tracing::{debug, trace, warn};
pub(crate) struct AbortOnDropStream<S> {
inner: S,
handle: tokio::task::JoinHandle<()>,
}
impl<S> AbortOnDropStream<S> {
pub(crate) fn new(inner: S, handle: tokio::task::JoinHandle<()>) -> Self {
Self { inner, handle }
}
}
impl<S: futures::Stream + Unpin> futures::Stream for AbortOnDropStream<S> {
type Item = S::Item;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
Pin::new(&mut self.inner).poll_next(cx)
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
}
impl<S> Drop for AbortOnDropStream<S> {
fn drop(&mut self) {
self.handle.abort();
}
}
pub(crate) const INITIAL_BACKOFF_MS: u64 = 1_000;
pub(crate) const MAX_BACKOFF_MS: u64 = 30_000;
pub(crate) const MAX_RECONNECT_ATTEMPTS: u32 = 10;
pub(crate) const HEARTBEAT_TIMEOUT_SECS: u64 = 30;
const _: () = assert!(
HEARTBEAT_TIMEOUT_SECS <= i64::MAX as u64,
"HEARTBEAT_TIMEOUT_SECS overflows i64"
);
pub(crate) const FILL_RECOVERY_TIMEOUT_SECS: u64 = 30;
pub(crate) const CONNECT_TIMEOUT_SECS: u64 = 15;
const _: () = assert!(
CONNECT_TIMEOUT_SECS <= i64::MAX as u64,
"CONNECT_TIMEOUT_SECS overflows i64"
);
pub(crate) const SIGNAL_RECOVERY_LOOKBACK_MS: i64 = 500;
pub(crate) const BINANCE_MAX_TRADES: usize = 1000;
const _: () = assert!(
BINANCE_MAX_TRADES <= i32::MAX as usize,
"BINANCE_MAX_TRADES overflows i32"
);
pub(crate) const DEFAULT_RATE_LIMIT_DELAY_SECS: u64 = 10;
pub(crate) const MAX_RATE_LIMIT_RETRIES: u32 = 3;
pub(crate) struct RateLimitTracker {
blocked_until: parking_lot::Mutex<Option<tokio::time::Instant>>,
}
impl RateLimitTracker {
pub(crate) fn new() -> Self {
Self {
blocked_until: parking_lot::Mutex::new(None),
}
}
pub(crate) async fn wait_if_blocked(&self) {
loop {
let deadline = *self.blocked_until.lock();
match deadline {
None => return,
Some(until) => {
let now = tokio::time::Instant::now();
if until <= now {
return;
}
#[allow(clippy::cast_possible_truncation)]
let delay_ms = (until - now).as_millis() as u64;
debug!(
delay_ms,
"Binance REST rate-limited, waiting before request"
);
tokio::time::sleep_until(until).await;
}
}
}
}
pub(crate) fn on_rate_limited(&self, retry_after: Option<Duration>) {
let delay = retry_after.unwrap_or(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
let new_deadline = tokio::time::Instant::now() + delay;
let mut guard = self.blocked_until.lock();
let was_blocked = guard.is_some();
*guard = Some(guard.map_or(new_deadline, |existing| existing.max(new_deadline)));
if was_blocked {
debug!(
delay_secs = delay.as_secs(),
"Binance rate-limit cooldown extended"
);
} else {
warn!(
delay_secs = delay.as_secs(),
"Binance entering rate-limit degradation mode"
);
}
}
}
pub(crate) fn is_rate_limit_error(e: &anyhow::Error) -> bool {
for cause in e.chain() {
let msg = cause.to_string();
if msg.contains("Too many requests")
|| msg.contains("been banned for exceeding rate limits")
|| contains_error_code(&msg, "-1015")
|| contains_error_code(&msg, "-1003")
{
return true;
}
}
false
}
pub(crate) fn is_api_rejection_error(e: &anyhow::Error) -> bool {
e.downcast_ref::<WebsocketError>()
.is_some_and(|we| matches!(we, WebsocketError::ResponseError { .. }))
}
pub(crate) struct ExponentialBackoff {
attempt: u32,
max_attempts: u32,
initial_ms: u64,
max_ms: u64,
}
impl ExponentialBackoff {
pub(crate) fn new() -> Self {
Self {
attempt: 0,
max_attempts: MAX_RECONNECT_ATTEMPTS,
initial_ms: INITIAL_BACKOFF_MS,
max_ms: MAX_BACKOFF_MS,
}
}
pub(crate) fn reset(&mut self) {
self.attempt = 0;
}
pub(crate) fn attempts(&self) -> u32 {
self.attempt
}
pub(crate) async fn wait(&mut self) -> bool {
if self.attempt >= self.max_attempts {
return false;
}
let delay_ms = self
.initial_ms
.saturating_mul(2u64.saturating_pow(self.attempt))
.min(self.max_ms);
self.attempt += 1;
debug!(
attempt = self.attempt,
max = self.max_attempts,
delay_ms,
"Binance reconnect backoff"
);
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
true
}
}
pub(crate) fn parse_side(s: &str) -> Option<Side> {
match s {
"BUY" => Some(Side::Buy),
"SELL" => Some(Side::Sell),
_ => {
warn!(side = s, "unknown Binance order side");
None
}
}
}
pub(crate) fn parse_order_kind(t: &str) -> Option<OrderKind> {
match t {
"MARKET" => Some(OrderKind::Market),
"STOP_LOSS" | "TAKE_PROFIT" => {
warn!(
order_type = t,
"dropping conditional Binance order type (no OrderKind equivalent)"
);
None
}
"LIMIT" | "LIMIT_MAKER" | "STOP_LOSS_LIMIT" | "TAKE_PROFIT_LIMIT" => Some(OrderKind::Limit),
_ => {
warn!(order_type = t, "unsupported Binance order type");
None
}
}
}
pub(crate) fn parse_time_in_force(tif: &str) -> TimeInForce {
match tif {
"GTC" => TimeInForce::GoodUntilCancelled { post_only: false },
"GTX" => TimeInForce::GoodUntilCancelled { post_only: true },
"IOC" => TimeInForce::ImmediateOrCancel,
"FOK" => TimeInForce::FillOrKill,
"GTD" => TimeInForce::GoodUntilEndOfDay,
_ => {
warn!(
time_in_force = tif,
"unknown Binance TimeInForce, defaulting to GTC"
);
TimeInForce::GoodUntilCancelled { post_only: false }
}
}
}
pub(crate) trait BinanceOrderFields {
fn order_id(&self) -> Option<i64>;
fn client_order_id(&self) -> Option<&str>;
fn side(&self) -> Option<&str>;
fn price(&self) -> Option<&str>;
fn orig_qty(&self) -> Option<&str>;
fn executed_qty(&self) -> Option<&str>;
fn order_type(&self) -> Option<&str>;
fn time_in_force(&self) -> Option<&str>;
fn time(&self) -> Option<i64>;
fn update_time(&self) -> Option<i64>;
fn symbol(&self) -> Option<&str>;
fn status(&self) -> Option<&str>;
}
macro_rules! impl_binance_order_fields {
($($t:ty),* $(,)?) => {
$(
impl BinanceOrderFields for $t {
fn order_id(&self) -> Option<i64> { self.order_id }
fn client_order_id(&self) -> Option<&str> { self.client_order_id.as_deref() }
fn side(&self) -> Option<&str> { self.side.as_deref() }
fn price(&self) -> Option<&str> { self.price.as_deref() }
fn orig_qty(&self) -> Option<&str> { self.orig_qty.as_deref() }
fn executed_qty(&self) -> Option<&str> { self.executed_qty.as_deref() }
fn order_type(&self) -> Option<&str> { self.r#type.as_deref() }
fn time_in_force(&self) -> Option<&str> { self.time_in_force.as_deref() }
fn time(&self) -> Option<i64> { self.time }
fn update_time(&self) -> Option<i64> { self.update_time }
fn symbol(&self) -> Option<&str> { self.symbol.as_deref() }
fn status(&self) -> Option<&str> { self.status.as_deref() }
}
)*
};
}
impl_binance_order_fields!(
binance_sdk::spot::rest_api::AllOrdersResponseInner,
binance_sdk::spot::rest_api::GetOpenOrdersResponseInner,
binance_sdk::margin_trading::rest_api::QueryMarginAccountsOpenOrdersResponseInner,
);
fn rest_order_is_open(status: &str) -> bool {
matches!(status, "NEW" | "PARTIALLY_FILLED" | "PENDING_NEW")
}
pub(crate) fn convert_open_order<T: BinanceOrderFields>(
o: &T,
exchange: ExchangeId,
instrument: &InstrumentNameExchange,
) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
let order_id_raw = match o.order_id() {
Some(id) => id,
None => {
warn!(%exchange, %instrument, "Binance open order missing orderId");
return None;
}
};
match o.status() {
Some(status) if rest_order_is_open(status) => {}
Some(status) => {
warn!(%exchange, %instrument, order_id = %order_id_raw, status, "Binance order is not live, not converting it to an open order");
return None;
}
None => {
warn!(%exchange, %instrument, order_id = %order_id_raw, "Binance open order missing status");
return None;
}
}
let order_id = OrderId(format_smolstr!("{}", order_id_raw));
if o.client_order_id().is_none() {
warn!(%exchange, %instrument, order_id = %order_id_raw, "Binance open order missing clientOrderId, using orderId as fallback — order may not reconcile with engine state");
}
let cid = ClientOrderId::new(
o.client_order_id()
.unwrap_or(&format_smolstr!("{}", order_id_raw)),
);
let side = match o.side() {
Some(s) => parse_side(s)?,
None => {
warn!(%exchange, %instrument, order_id = %order_id_raw, "Binance open order missing side");
return None;
}
};
let price = o.price().and_then(|s| Decimal::from_str(s).ok());
let quantity = match o.orig_qty().and_then(|s| Decimal::from_str(s).ok()) {
Some(v) => v,
None => {
warn!(%exchange, %instrument, order_id = %order_id_raw, "Binance open order missing/unparseable origQty");
return None;
}
};
let filled_qty = match o.executed_qty() {
Some(s) => match Decimal::from_str(s) {
Ok(v) => v,
Err(_) => {
warn!(%exchange, %instrument, order_id = %order_id_raw, executed_qty = s, "Binance open order unparseable executedQty, defaulting to 0");
Decimal::ZERO
}
},
None => Decimal::ZERO,
};
let kind = match o.order_type() {
Some(t) => parse_order_kind(t)?,
None => {
warn!(%exchange, %instrument, order_id = %order_id_raw, "Binance open order missing type");
return None;
}
};
let time_in_force = parse_time_in_force(o.time_in_force().unwrap_or("GTC"));
let time_exchange = match o
.update_time()
.or_else(|| o.time())
.and_then(|ms| Utc.timestamp_millis_opt(ms).single())
{
Some(ts) => ts,
None => {
warn!(%exchange, %instrument, order_id = %order_id_raw, "Binance open order missing/unparseable time, using now");
Utc::now()
}
};
Some(Order {
key: OrderKey::new(
exchange,
instrument.clone(),
StrategyId::unknown(),
cid,
),
side,
price,
quantity,
kind,
time_in_force,
state: Open::new(VenueOrderId::Assigned(order_id), time_exchange, filled_qty),
})
}
#[derive(Debug)]
pub(crate) struct OpenOrderListing {
pub(crate) orders: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
pub(crate) complete: bool,
}
pub(crate) fn convert_open_order_listing<T: BinanceOrderFields>(
rows: &[T],
exchange: ExchangeId,
instrument: &InstrumentNameExchange,
) -> OpenOrderListing {
let mut complete = true;
let orders = rows
.iter()
.filter_map(|row| {
let order = convert_open_order(row, exchange, instrument);
complete &= order.is_some() && row.client_order_id().is_some();
order
})
.collect();
OpenOrderListing { orders, complete }
}
pub(crate) fn convert_open_order_owned_symbol<T: BinanceOrderFields>(
o: &T,
exchange: ExchangeId,
) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
let instrument = match o.symbol() {
Some(s) => InstrumentNameExchange::new(s),
None => {
warn!(%exchange, "Binance open order missing symbol in return-all query, dropping order");
return None;
}
};
convert_open_order(o, exchange, &instrument)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MyTradesFrom {
Time(i64),
Order(i64),
}
pub(crate) trait BinanceExecutionFields {
fn id(&self) -> Option<i64>;
fn order_id(&self) -> Option<i64>;
fn qty(&self) -> Option<&str>;
}
macro_rules! impl_binance_execution_fields {
($($t:ty),* $(,)?) => {
$(
impl BinanceExecutionFields for $t {
fn id(&self) -> Option<i64> { self.id }
fn order_id(&self) -> Option<i64> { self.order_id }
fn qty(&self) -> Option<&str> { self.qty.as_deref() }
}
)*
};
}
impl_binance_execution_fields!(
binance_sdk::spot::rest_api::MyTradesResponseInner,
binance_sdk::margin_trading::rest_api::QueryMarginAccountsTradeListResponseInner,
);
pub(crate) const ORDER_EXECUTIONS_BUDGET: Duration =
Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS / 2);
pub(crate) const ORDER_EXECUTIONS_IN_FLIGHT: usize = 4;
pub(crate) async fn recovered_order_totals<T, F, Fut>(
exchange: ExchangeId,
instrument: &InstrumentNameExchange,
recovered: &[T],
deadline: tokio::time::Instant,
fetch: F,
) -> fnv::FnvHashMap<i64, Decimal>
where
T: BinanceExecutionFields,
F: Fn(i64) -> Fut,
Fut: Future<Output = Result<Vec<T>, UnindexedClientError>>,
{
use futures::StreamExt as _;
let mut order_ids: Vec<i64> = recovered.iter().filter_map(|t| t.order_id()).collect();
order_ids.sort_unstable();
order_ids.dedup();
let fetch = &fetch;
let mut lookups = futures::stream::iter(order_ids.iter().copied())
.map(|order_id| async move { (order_id, fetch(order_id).await) })
.buffer_unordered(ORDER_EXECUTIONS_IN_FLIGHT);
let mut totals = fnv::FnvHashMap::default();
let mut settled = 0;
loop {
let (order_id, walk) = match tokio::time::timeout_at(deadline, lookups.next()).await {
Ok(Some(lookup)) => lookup,
Ok(None) => break,
Err(_elapsed) => {
warn!(
%exchange, %instrument,
orders_left = order_ids.len() - settled,
budget = ?ORDER_EXECUTIONS_BUDGET,
"fill recovery ran out of time reading orders' executions; the remaining \
orders' recovered fills carry no cumulative filled quantity"
);
break;
}
};
settled += 1;
let executions = match walk {
Ok(executions) => executions,
Err(error) => {
warn!(
%exchange, %instrument, order_id, %error,
"fill recovery could not read the order's executions; its recovered fills \
carry no cumulative filled quantity"
);
continue;
}
};
match order_running_totals(order_id, &executions) {
Some(running) => totals.extend(running),
None => warn!(
%exchange, %instrument, order_id, executions = executions.len(),
"fill recovery read an order's executions it cannot sum (a missing id or size, \
or another order's execution); its recovered fills carry no cumulative filled \
quantity"
),
}
}
totals
}
pub(crate) fn order_running_totals<T: BinanceExecutionFields>(
order_id: i64,
executions: &[T],
) -> Option<Vec<(i64, Decimal)>> {
let mut sized = executions
.iter()
.map(|execution| {
if execution.order_id() != Some(order_id) {
return None;
}
let qty = Decimal::from_str(execution.qty()?).ok()?;
Some((execution.id()?, qty))
})
.collect::<Option<Vec<_>>>()?;
sized.sort_unstable_by_key(|&(id, _)| id);
sized.dedup_by_key(|&mut (id, _)| id);
let mut cumulative = Decimal::ZERO;
Some(
sized
.into_iter()
.map(|(id, qty)| {
cumulative += qty;
(id, cumulative)
})
.collect(),
)
}
pub(crate) trait BinanceExecutionReportFields {
fn execution_type(&self) -> Option<&str>;
fn order_status(&self) -> Option<&str>;
fn symbol(&self) -> Option<&str>;
fn side(&self) -> Option<&str>;
fn order_id(&self) -> Option<i64>;
fn client_order_id(&self) -> Option<&str>;
fn transaction_time(&self) -> Option<i64>;
fn trade_id(&self) -> Option<i64>;
fn last_executed_price(&self) -> Option<&str>;
fn last_executed_quantity(&self) -> Option<&str>;
fn commission_amount(&self) -> Option<&str>;
fn commission_asset(&self) -> Option<&str>;
fn cumulative_filled_quantity(&self) -> Option<&str>;
fn reject_reason(&self) -> Option<&str>;
fn order_type(&self) -> Option<&str>;
fn price(&self) -> Option<&str>;
fn order_quantity(&self) -> Option<&str>;
fn time_in_force(&self) -> Option<&str>;
}
macro_rules! impl_binance_execution_report_fields {
($($t:ty),* $(,)?) => {
$(
impl BinanceExecutionReportFields for $t {
fn execution_type(&self) -> Option<&str> { self.x.as_deref() }
fn order_status(&self) -> Option<&str> { self.x_uppercase.as_deref() }
fn symbol(&self) -> Option<&str> { self.s.as_deref() }
fn side(&self) -> Option<&str> { self.s_uppercase.as_deref() }
fn order_id(&self) -> Option<i64> { self.i }
fn client_order_id(&self) -> Option<&str> { self.c.as_deref() }
fn transaction_time(&self) -> Option<i64> { self.t_uppercase }
fn trade_id(&self) -> Option<i64> { self.t }
fn last_executed_price(&self) -> Option<&str> { self.l_uppercase.as_deref() }
fn last_executed_quantity(&self) -> Option<&str> { self.l.as_deref() }
fn commission_amount(&self) -> Option<&str> { self.n.as_deref() }
fn commission_asset(&self) -> Option<&str> { self.n_uppercase.as_deref() }
fn cumulative_filled_quantity(&self) -> Option<&str> { self.z.as_deref() }
fn reject_reason(&self) -> Option<&str> { self.r.as_deref() }
fn order_type(&self) -> Option<&str> { self.o.as_deref() }
fn price(&self) -> Option<&str> { self.p.as_deref() }
fn order_quantity(&self) -> Option<&str> { self.q.as_deref() }
fn time_in_force(&self) -> Option<&str> { self.f.as_deref() }
}
)*
};
}
impl_binance_execution_report_fields!(
binance_sdk::spot::websocket_api::ExecutionReport,
binance_sdk::margin_trading::websocket_streams::ExecutionReport,
);
fn trade_order_is_live(status: &str) -> bool {
matches!(status, "PARTIALLY_FILLED" | "FILLED")
}
#[allow(clippy::cognitive_complexity)]
pub(crate) fn convert_execution_report<T: BinanceExecutionReportFields>(
report: &T,
exchange: ExchangeId,
buf: &mut Vec<UnindexedAccountEvent>,
) {
let Some(exec_type) = report.execution_type() else {
warn!(%exchange, "Binance executionReport missing execution type (x), dropping");
return;
};
let Some(symbol) = report.symbol().map(InstrumentNameExchange::new) else {
warn!(%exchange, "Binance executionReport missing symbol (s), dropping");
return;
};
let Some(order_id_raw) = report.order_id() else {
warn!(%exchange, %symbol, "Binance executionReport missing orderId (i), dropping");
return;
};
let order_id = OrderId(format_smolstr!("{order_id_raw}"));
let cid = match report.client_order_id() {
Some(c) => ClientOrderId::new(c),
None => ClientOrderId::new(order_id.0.as_str()),
};
let time_exchange = match report
.transaction_time()
.and_then(|ms| Utc.timestamp_millis_opt(ms).single())
{
Some(t) => t,
None => {
warn!(%exchange, %symbol, "Binance executionReport missing/unparseable transaction time (T), using now");
Utc::now()
}
};
match exec_type {
"NEW" => {
buf.extend(convert_order_snapshot(
report,
exchange,
symbol,
cid,
order_id,
time_exchange,
));
}
"TRADE" => {
let Some(trade_id) = report.trade_id() else {
warn!(%exchange, %symbol, "Binance TRADE event missing trade ID (t), dropping");
return;
};
let trade_id = TradeId(format_smolstr!("{trade_id}"));
let Some(side) = report.side().and_then(parse_side) else {
warn!(%exchange, %symbol, "Binance TRADE event missing/unknown side (S), dropping");
return;
};
let last_price = match report.last_executed_price() {
Some(s) => match Decimal::from_str(s) {
Ok(v) => v,
Err(e) => {
warn!(%exchange, %symbol, error = %e, raw = s, "Binance TRADE event unparseable last price (L), dropping fill");
return;
}
},
None => {
warn!(%exchange, %symbol, "Binance TRADE event missing last price (L), dropping fill");
return;
}
};
let last_qty = match report.last_executed_quantity() {
Some(s) => match Decimal::from_str(s) {
Ok(v) => v,
Err(e) => {
warn!(%exchange, %symbol, error = %e, raw = s, "Binance TRADE event unparseable last qty (l), dropping fill");
return;
}
},
None => {
warn!(%exchange, %symbol, "Binance TRADE event missing last qty (l), dropping fill");
return;
}
};
let commission = match Decimal::from_str(report.commission_amount().unwrap_or("0")) {
Ok(v) => v,
Err(e) => {
warn!(%exchange, %symbol, error = %e, "Binance TRADE event unparseable commission (n), defaulting to 0");
Decimal::ZERO
}
};
let fee_asset = report
.commission_asset()
.map(AssetNameExchange::from)
.unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
let order_filled_quantity = report
.cumulative_filled_quantity()
.and_then(|s| Decimal::from_str(s).ok());
let trade = Trade::new(
trade_id,
order_id.clone(),
symbol.clone(),
StrategyId::unknown(), time_exchange,
side,
last_price,
last_qty,
order_filled_quantity,
AssetFees::new(fee_asset, commission, None),
);
buf.push(UnindexedAccountEvent::new(
exchange,
AccountEventKind::Trade(trade),
));
let order_status = report.order_status().unwrap_or_default();
if trade_order_is_live(order_status) {
buf.extend(convert_order_snapshot(
report,
exchange,
symbol,
cid,
order_id,
time_exchange,
));
} else {
trace!(
%exchange,
%symbol,
status = order_status,
"Binance TRADE for an order the exchange no longer reports as working — \
emitting the execution without an order snapshot"
);
}
}
"CANCELED" | "EXPIRED" | "EXPIRED_IN_MATCH" => {
buf.push(cancelled_event(
report,
exchange,
symbol,
cid,
order_id,
time_exchange,
));
}
"REJECTED" => {
let reject_reason = report.reject_reason().unwrap_or("unknown");
warn!(
%exchange, %symbol, %order_id, reason = reject_reason,
"Binance order REJECTED by matching engine"
);
let response = UnindexedOrderResponseCancel {
key: OrderKey::new(exchange, symbol, StrategyId::unknown(), cid),
state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
reject_reason.to_string(),
))),
};
buf.push(UnindexedAccountEvent::new(
exchange,
AccountEventKind::OrderCancelled(response),
));
}
"REPLACE" => {
buf.push(cancelled_event(
report,
exchange,
symbol,
cid,
order_id,
time_exchange,
));
}
_ => {
trace!(%exchange, exec_type, "Binance ignoring execution type");
}
}
}
fn cancelled_event<T: BinanceExecutionReportFields>(
report: &T,
exchange: ExchangeId,
symbol: InstrumentNameExchange,
cid: ClientOrderId,
order_id: OrderId,
time_exchange: DateTime<Utc>,
) -> UnindexedAccountEvent {
let filled_qty = report
.cumulative_filled_quantity()
.and_then(|s| Decimal::from_str(s).ok())
.unwrap_or(Decimal::ZERO);
let response = UnindexedOrderResponseCancel {
key: OrderKey::new(exchange, symbol, StrategyId::unknown(), cid),
state: Ok(Cancelled::new(order_id, time_exchange, filled_qty)),
};
UnindexedAccountEvent::new(exchange, AccountEventKind::OrderCancelled(response))
}
fn convert_order_snapshot<T: BinanceExecutionReportFields>(
report: &T,
exchange: ExchangeId,
symbol: InstrumentNameExchange,
cid: ClientOrderId,
order_id: OrderId,
time_exchange: DateTime<Utc>,
) -> Option<UnindexedAccountEvent> {
let Some(side) = report.side().and_then(parse_side) else {
warn!(%exchange, %symbol, "Binance order report missing/unknown side (S), dropping snapshot");
return None;
};
let kind = parse_order_kind(report.order_type().unwrap_or("LIMIT"))?;
let price: Option<Decimal> = match (report.price(), &kind) {
(Some(p), _) => match Decimal::from_str(p) {
Ok(v) if !v.is_zero() => Some(v),
Ok(_) => {
if matches!(
kind,
OrderKind::Limit
| OrderKind::StopLimit { .. }
| OrderKind::TakeProfitLimit { .. }
| OrderKind::TrailingStopLimit { .. }
) {
trace!(%exchange, %symbol, %kind, "Binance order report has zero price (p) on limit-type order, treating as no limit price");
}
None
}
Err(e) => {
warn!(%exchange, %symbol, price = p, error = %e, "Binance order report unparseable price (p), dropping snapshot");
return None;
}
},
(
None,
OrderKind::Market
| OrderKind::Stop { .. }
| OrderKind::TakeProfit { .. }
| OrderKind::TrailingStop { .. },
) => {
None
}
(
None,
OrderKind::Limit
| OrderKind::StopLimit { .. }
| OrderKind::TakeProfitLimit { .. }
| OrderKind::TrailingStopLimit { .. },
) => {
warn!(%exchange, %symbol, "Binance limit-type order report missing price (p), dropping snapshot");
return None;
}
};
let quantity = match report.order_quantity() {
Some(q) => match Decimal::from_str(q) {
Ok(v) => v,
Err(e) => {
warn!(%exchange, %symbol, qty = q, error = %e, "Binance order report unparseable quantity (q), dropping snapshot");
return None;
}
},
None => {
warn!(%exchange, %symbol, "Binance order report missing quantity (q), dropping snapshot");
return None;
}
};
let time_in_force = parse_time_in_force(report.time_in_force().unwrap_or("GTC"));
let filled_qty = report
.cumulative_filled_quantity()
.and_then(|s| Decimal::from_str(s).ok())
.unwrap_or(Decimal::ZERO);
let order = Order {
key: OrderKey::new(
exchange,
symbol,
StrategyId::unknown(), cid,
),
side,
price,
quantity,
kind,
time_in_force,
state: OrderState::active(Open::new(
VenueOrderId::Assigned(order_id),
time_exchange,
filled_qty,
)),
};
Some(UnindexedAccountEvent::new(
exchange,
AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot::new(
order,
)),
))
}
fn contains_ignore_case(haystack: &str, needle: &str) -> bool {
haystack
.as_bytes()
.windows(needle.len())
.any(|w| w.eq_ignore_ascii_case(needle.as_bytes()))
}
pub(crate) fn contains_error_code(msg: &str, code: &str) -> bool {
let code_len = code.len();
let mut start = 0;
while let Some(rel) = msg[start..].find(code) {
let pos = start + rel;
let prefix_ok = pos == 0 || !msg[..pos].ends_with(|c: char| c.is_ascii_digit());
let suffix_ok = !msg[pos + code_len..].starts_with(|c: char| c.is_ascii_digit());
if prefix_ok && suffix_ok {
return true;
}
start = pos + 1;
}
false
}
pub(crate) fn parse_binance_api_error(
error_msg: String,
instrument: &InstrumentNameExchange,
) -> ApiError<AssetNameExchange, InstrumentNameExchange> {
if contains_error_code(&error_msg, "-1002") || contains_error_code(&error_msg, "-2015") {
return ApiError::Unauthenticated(error_msg);
}
if contains_error_code(&error_msg, "-1003") || contains_error_code(&error_msg, "-1015") {
return ApiError::RateLimit;
}
if contains_error_code(&error_msg, "-2011") {
return ApiError::OrderAlreadyCancelled;
}
if contains_error_code(&error_msg, "-2013") {
return ApiError::OrderAlreadyCancelled;
}
if contains_error_code(&error_msg, "-1121") {
return ApiError::InstrumentInvalid(instrument.clone(), error_msg);
}
if contains_error_code(&error_msg, "-2010") {
return ApiError::BalanceInsufficient(
AssetNameExchange::new(instrument.name().as_str()),
error_msg,
);
}
if contains_ignore_case(&error_msg, "insufficient")
|| contains_ignore_case(&error_msg, "not enough")
{
ApiError::BalanceInsufficient(
AssetNameExchange::new(instrument.name().as_str()),
error_msg,
)
} else if contains_ignore_case(&error_msg, "rate limit") {
ApiError::RateLimit
} else if contains_ignore_case(&error_msg, "unknown order") {
ApiError::OrderRejected(error_msg)
} else if contains_ignore_case(&error_msg, "invalid symbol") {
ApiError::InstrumentInvalid(instrument.clone(), error_msg)
} else {
ApiError::OrderRejected(error_msg)
}
}
pub(crate) fn connectivity_error(e: anyhow::Error) -> UnindexedClientError {
let msg = format!("{e:#}");
if contains_error_code(&msg, "-1002")
|| contains_error_code(&msg, "-2015")
|| contains_ignore_case(&msg, "invalid api-key")
|| contains_ignore_case(&msg, "invalid signature")
|| contains_ignore_case(&msg, "signature for this request is not valid")
{
return UnindexedClientError::Api(ApiError::Unauthenticated(msg));
}
UnindexedClientError::Connectivity(ConnectivityError::Socket(msg))
}
pub(crate) async fn rest_call_with_retry<R, T>(
rest: &Arc<R>,
rate_limiter: &RateLimitTracker,
mut make_call: impl FnMut(
Arc<R>,
)
-> Pin<Box<dyn std::future::Future<Output = anyhow::Result<T>> + Send>>,
) -> anyhow::Result<T>
where
R: Send + Sync,
{
for attempt in 0..=MAX_RATE_LIMIT_RETRIES {
rate_limiter.wait_if_blocked().await;
match make_call(Arc::clone(rest)).await {
Ok(v) => return Ok(v),
Err(e) if is_rate_limit_error(&e) && attempt < MAX_RATE_LIMIT_RETRIES => {
let delay = Duration::from_secs(2u64.saturating_pow(attempt).min(30));
warn!(
attempt = attempt.saturating_add(1),
max = MAX_RATE_LIMIT_RETRIES,
delay_secs = delay.as_secs(),
"Binance REST rate-limited, retrying"
);
rate_limiter.on_rate_limited(Some(delay));
}
Err(e) => return Err(e),
}
}
unreachable!("Binance REST retries exhausted: loop invariant violated")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BinanceOrderType {
Market,
Limit,
LimitMaker,
StopLoss,
StopLossLimit,
TakeProfit,
TakeProfitLimit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BinanceTimeInForce {
Gtc,
Ioc,
Fok,
}
pub(crate) fn classify_order_kind_tif(
kind: OrderKind,
tif: TimeInForce,
) -> Option<(BinanceOrderType, Option<BinanceTimeInForce>)> {
match kind {
OrderKind::Market => Some((BinanceOrderType::Market, None)),
OrderKind::Limit => match tif {
TimeInForce::GoodUntilCancelled { post_only: false } => {
Some((BinanceOrderType::Limit, Some(BinanceTimeInForce::Gtc)))
}
TimeInForce::GoodUntilCancelled { post_only: true } => {
Some((BinanceOrderType::LimitMaker, None))
}
TimeInForce::FillOrKill => {
Some((BinanceOrderType::Limit, Some(BinanceTimeInForce::Fok)))
}
TimeInForce::ImmediateOrCancel => {
Some((BinanceOrderType::Limit, Some(BinanceTimeInForce::Ioc)))
}
TimeInForce::GoodUntilEndOfDay
| TimeInForce::GoodTillDate { .. }
| TimeInForce::AtOpen
| TimeInForce::AtClose => {
warn!(time_in_force = ?tif, "Binance does not support this TimeInForce");
None
}
},
OrderKind::Stop { .. } => Some((BinanceOrderType::StopLoss, None)),
OrderKind::StopLimit { .. } => match tif {
TimeInForce::GoodUntilCancelled { post_only: false } => Some((
BinanceOrderType::StopLossLimit,
Some(BinanceTimeInForce::Gtc),
)),
TimeInForce::FillOrKill => Some((
BinanceOrderType::StopLossLimit,
Some(BinanceTimeInForce::Fok),
)),
TimeInForce::ImmediateOrCancel => Some((
BinanceOrderType::StopLossLimit,
Some(BinanceTimeInForce::Ioc),
)),
_ => {
warn!(time_in_force = ?tif, "Binance StopLimit does not support this TimeInForce");
None
}
},
OrderKind::TakeProfit { .. } => Some((BinanceOrderType::TakeProfit, None)),
OrderKind::TakeProfitLimit { .. } => match tif {
TimeInForce::GoodUntilCancelled { post_only: false } => Some((
BinanceOrderType::TakeProfitLimit,
Some(BinanceTimeInForce::Gtc),
)),
TimeInForce::FillOrKill => Some((
BinanceOrderType::TakeProfitLimit,
Some(BinanceTimeInForce::Fok),
)),
TimeInForce::ImmediateOrCancel => Some((
BinanceOrderType::TakeProfitLimit,
Some(BinanceTimeInForce::Ioc),
)),
_ => {
warn!(time_in_force = ?tif, "Binance TakeProfitLimit does not support this TimeInForce");
None
}
},
OrderKind::TrailingStop { offset_type, .. } => match offset_type {
TrailingOffsetType::BasisPoints | TrailingOffsetType::Percentage => {
Some((BinanceOrderType::StopLoss, None))
}
TrailingOffsetType::Absolute => {
warn!(
"Binance TrailingStop does not support Absolute offset; \
convert to basis points: (absolute / price) * 10000"
);
None
}
},
OrderKind::TrailingStopLimit { .. } => {
warn!("Binance does not support TrailingStopLimit orders");
None
}
}
}
pub(crate) fn classify_rest_order_error(
e: &anyhow::Error,
instrument: &InstrumentNameExchange,
) -> OrderError<AssetNameExchange, InstrumentNameExchange> {
let Some(ce) = e.downcast_ref::<ConnectorError>() else {
return OrderError::Connectivity(ConnectivityError::Socket(format!("{e:#}")));
};
match ce {
ConnectorError::TooManyRequestsError { .. } | ConnectorError::RateLimitBanError { .. } => {
OrderError::Rejected(ApiError::RateLimit)
}
ConnectorError::UnauthorizedError { msg, code }
| ConnectorError::ForbiddenError { msg, code } => {
let msg_with_code = code.map_or_else(|| msg.clone(), |c| format!("{c} {msg}"));
OrderError::Rejected(ApiError::Unauthenticated(msg_with_code))
}
ConnectorError::ServerError { msg, .. } | ConnectorError::NetworkError(msg) => {
OrderError::Connectivity(ConnectivityError::Socket(msg.clone()))
}
ConnectorError::BadRequestError { msg, code }
| ConnectorError::NotFoundError { msg, code }
| ConnectorError::ConnectorClientError { msg, code } => {
const TRANSPORT_PREFIXES: [&str; 4] = [
"HTTP request failed",
"Failed to get response bytes",
"Failed to decompress gzip response",
"Failed to convert response to UTF-8",
];
if code.is_none() && TRANSPORT_PREFIXES.iter().any(|p| msg.starts_with(p)) {
return OrderError::Connectivity(ConnectivityError::Socket(msg.clone()));
}
let msg_with_code = code.map_or_else(|| msg.clone(), |c| format!("{c} {msg}"));
OrderError::Rejected(parse_binance_api_error(msg_with_code, instrument))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone, Copy)]
struct Execution {
id: Option<i64>,
order_id: Option<i64>,
qty: Option<&'static str>,
}
impl BinanceExecutionFields for Execution {
fn id(&self) -> Option<i64> {
self.id
}
fn order_id(&self) -> Option<i64> {
self.order_id
}
fn qty(&self) -> Option<&str> {
self.qty
}
}
fn execution(id: i64, order_id: i64, qty: &'static str) -> Execution {
Execution {
id: Some(id),
order_id: Some(order_id),
qty: Some(qty),
}
}
fn btcusdt() -> InstrumentNameExchange {
InstrumentNameExchange::new("BTCUSDT")
}
#[test]
fn running_totals_accumulate_in_trade_id_order_counting_a_repeat_once() {
let executions = [
execution(12, 7, "0.5"),
execution(10, 7, "1"),
execution(12, 7, "0.5"),
execution(15, 7, "2"),
];
assert_eq!(
order_running_totals(7, &executions),
Some(vec![
(10, Decimal::ONE),
(12, Decimal::new(15, 1)),
(15, Decimal::new(35, 1)),
])
);
}
#[test]
fn running_totals_refuse_a_walk_holding_another_orders_execution() {
let executions = [execution(10, 7, "1"), execution(11, 8, "5")];
assert_eq!(order_running_totals(7, &executions), None);
}
#[test]
fn running_totals_refuse_a_walk_with_an_unreadable_execution() {
let unsized_execution = Execution {
qty: None,
..execution(11, 7, "0")
};
let unparseable = execution(11, 7, "abc");
let unidentified = Execution {
id: None,
..execution(11, 7, "1")
};
for broken in [unsized_execution, unparseable, unidentified] {
assert_eq!(
order_running_totals(7, &[execution(10, 7, "1"), broken]),
None,
"{broken:?}",
);
}
}
#[tokio::test]
async fn a_recovered_fill_counts_what_its_order_filled_before_the_window() {
let recovered = [execution(12, 7, "2")];
let order_walk = vec![execution(10, 7, "1"), execution(12, 7, "2")];
let totals = recovered_order_totals(
ExchangeId::BinanceSpot,
&btcusdt(),
&recovered,
tokio::time::Instant::now() + Duration::from_secs(5),
|order_id| {
assert_eq!(order_id, 7);
let walk = order_walk.clone();
async move { Ok(walk) }
},
)
.await;
assert_eq!(totals.get(&12), Some(&Decimal::new(3, 0)));
}
#[tokio::test]
async fn an_order_whose_walk_fails_is_left_out_and_the_others_are_not() {
let recovered = [execution(20, 1, "1"), execution(21, 2, "1")];
let totals = recovered_order_totals(
ExchangeId::BinanceMargin,
&btcusdt(),
&recovered,
tokio::time::Instant::now() + Duration::from_secs(5),
|order_id| async move {
match order_id {
1 => Err(connectivity_error(anyhow::anyhow!("connection reset"))),
_ => Ok(vec![execution(21, 2, "1")]),
}
},
)
.await;
assert_eq!(totals.get(&20), None);
assert_eq!(totals.get(&21), Some(&Decimal::ONE));
}
#[tokio::test(start_paused = true)]
async fn lookups_stop_at_the_deadline_keeping_the_walks_that_answered() {
let recovered = [execution(20, 1, "1"), execution(21, 2, "1")];
let started = tokio::time::Instant::now();
let deadline = started + Duration::from_secs(5);
let totals = recovered_order_totals(
ExchangeId::BinanceSpot,
&btcusdt(),
&recovered,
deadline,
|order_id| async move {
if order_id == 1 {
std::future::pending::<()>().await;
}
Ok(vec![execution(21, 2, "1")])
},
)
.await;
assert_eq!(totals.get(&20), None);
assert_eq!(totals.get(&21), Some(&Decimal::ONE));
assert_eq!(tokio::time::Instant::now(), deadline);
}
#[tokio::test(start_paused = true)]
async fn lookups_run_side_by_side_up_to_the_in_flight_bound() {
let recovered: Vec<Execution> = (1_i64..)
.take(ORDER_EXECUTIONS_IN_FLIGHT + 1)
.map(|order_id| execution(100 + order_id, order_id, "1"))
.collect();
let over_bound = recovered.last().and_then(|t| t.id);
let totals = recovered_order_totals(
ExchangeId::BinanceSpot,
&btcusdt(),
&recovered,
tokio::time::Instant::now() + Duration::from_secs(5),
|order_id| async move {
tokio::time::sleep(Duration::from_secs(4)).await;
Ok(vec![execution(100 + order_id, order_id, "1")])
},
)
.await;
assert_eq!(totals.len(), ORDER_EXECUTIONS_IN_FLIGHT, "{totals:?}");
assert!(
over_bound.is_some_and(|id| !totals.contains_key(&id)),
"{totals:?}"
);
}
fn classify(
msg: &str,
code: Option<i64>,
) -> OrderError<AssetNameExchange, InstrumentNameExchange> {
let err = anyhow::Error::new(ConnectorError::ConnectorClientError {
msg: msg.to_string(),
code,
});
classify_rest_order_error(&err, &InstrumentNameExchange::new("BTCUSDT"))
}
#[test]
fn codeless_transport_failures_map_to_connectivity() {
for msg in [
"HTTP request failed: connection reset",
"Failed to get response bytes: error reading body",
"Failed to decompress gzip response",
"Failed to convert response to UTF-8: invalid utf-8 sequence",
] {
assert!(
matches!(classify(msg, None), OrderError::Connectivity(_)),
"expected Connectivity for {msg:?}"
);
}
}
#[test]
fn coded_error_maps_to_rejection() {
assert!(matches!(
classify("Account has insufficient balance.", Some(-2010)),
OrderError::Rejected(_)
));
}
#[test]
fn codeless_non_transport_status_error_maps_to_rejection() {
assert!(matches!(
classify("Conflict", None),
OrderError::Rejected(_)
));
}
fn classify_err(ce: ConnectorError) -> OrderError<AssetNameExchange, InstrumentNameExchange> {
classify_rest_order_error(
&anyhow::Error::new(ce),
&InstrumentNameExchange::new("BTCUSDT"),
)
}
#[test]
fn rate_limit_variants_map_to_rejection_ratelimit() {
for ce in [
ConnectorError::TooManyRequestsError {
msg: "Too many requests.".to_string(),
code: Some(-1003),
},
ConnectorError::RateLimitBanError {
msg: "IP banned.".to_string(),
code: Some(-1003),
},
] {
assert!(matches!(
classify_err(ce),
OrderError::Rejected(ApiError::RateLimit)
));
}
}
#[test]
fn auth_variants_map_to_unauthenticated() {
for ce in [
ConnectorError::UnauthorizedError {
msg: "bad key".to_string(),
code: Some(-2014),
},
ConnectorError::ForbiddenError {
msg: "forbidden".to_string(),
code: None,
},
] {
assert!(matches!(
classify_err(ce),
OrderError::Rejected(ApiError::Unauthenticated(_))
));
}
}
#[test]
fn server_and_network_variants_map_to_connectivity() {
for ce in [
ConnectorError::ServerError {
msg: "internal error".to_string(),
status_code: Some(503),
},
ConnectorError::NetworkError("connection reset".to_string()),
] {
assert!(matches!(classify_err(ce), OrderError::Connectivity(_)));
}
}
}