use std::str::FromStr;
use anyhow::Context;
use nautilus_core::nanos::UnixNanos;
use nautilus_model::{
data::{
Bar, BarSpecification, BarType, BookOrder, OrderBookDelta, OrderBookDeltas, QuoteTick,
TradeTick,
},
enums::{
AggressorSide, AssetClass, BarAggregation, BookAction, LiquiditySide, OrderSide,
OrderStatus, OrderType, RecordFlag, TimeInForce, TriggerType,
},
identifiers::{AccountId, InstrumentId, OrderListId, Symbol, TradeId, Venue, VenueOrderId},
instruments::{
Instrument, any::InstrumentAny, crypto_future::CryptoFuture,
crypto_perpetual::CryptoPerpetual, currency_pair::CurrencyPair,
perpetual_contract::PerpetualContract,
},
reports::{FillReport, OrderStatusReport},
types::{Currency, Money, Price, Quantity},
};
use rust_decimal::Decimal;
use serde_json::Value;
use crate::{
common::{
consts::BINANCE,
encoder::decode_client_order_id,
enums::{
BinanceContractStatus, BinanceKlineInterval, BinanceProductType, BinanceTradingStatus,
},
symbol::format_instrument_id,
},
futures::http::models::{BinanceFuturesCoinSymbol, BinanceFuturesUsdSymbol},
spot::{
http::models::{
BinanceAccountTrade, BinanceKlines, BinanceLotSizeFilterSbe, BinanceNewOrderResponse,
BinanceOrderResponse, BinancePriceFilterSbe, BinanceSymbolJson, BinanceSymbolSbe,
BinanceTrades,
},
sbe::spot::{
order_side::OrderSide as SbeOrderSide, order_status::OrderStatus as SbeOrderStatus,
order_type::OrderType as SbeOrderType, time_in_force::TimeInForce as SbeTimeInForce,
},
},
};
const CONTRACT_TYPE_PERPETUAL: &str = "PERPETUAL";
const CONTRACT_TYPE_TRADIFI_PERPETUAL: &str = "TRADIFI_PERPETUAL";
const CONTRACT_TYPE_CURRENT_MONTH: &str = "CURRENT_MONTH";
const CONTRACT_TYPE_NEXT_MONTH: &str = "NEXT_MONTH";
const CONTRACT_TYPE_CURRENT_QUARTER: &str = "CURRENT_QUARTER";
const CONTRACT_TYPE_NEXT_QUARTER: &str = "NEXT_QUARTER";
pub(crate) fn parse_millis(value: i64, field: &str) -> anyhow::Result<UnixNanos> {
parse_timestamp(value, UnixNanos::from_millis_checked(value), field)
}
pub(crate) fn parse_micros(value: i64, field: &str) -> anyhow::Result<UnixNanos> {
parse_timestamp(value, UnixNanos::from_micros_checked(value), field)
}
fn parse_timestamp(
value: i64,
timestamp: Option<UnixNanos>,
field: &str,
) -> anyhow::Result<UnixNanos> {
timestamp.ok_or_else(|| {
if value < 0 {
anyhow::anyhow!("invalid negative Binance {field} timestamp: {value}")
} else {
anyhow::anyhow!("Binance {field} timestamp is outside the UnixNanos range: {value}")
}
})
}
pub(crate) fn parse_millis_or_init(value: i64, field: &str, ts_init: UnixNanos) -> UnixNanos {
timestamp_or_init(parse_millis(value, field), ts_init)
}
pub(crate) fn parse_micros_or_init(value: i64, field: &str, ts_init: UnixNanos) -> UnixNanos {
timestamp_or_init(parse_micros(value, field), ts_init)
}
fn timestamp_or_init(timestamp: anyhow::Result<UnixNanos>, ts_init: UnixNanos) -> UnixNanos {
match timestamp {
Ok(timestamp) => timestamp,
Err(e) => {
log::warn!("{e}; using initialization timestamp");
ts_init
}
}
}
fn parse_tradifi_asset_class(symbol: &BinanceFuturesUsdSymbol) -> anyhow::Result<AssetClass> {
let underlying_type = symbol.underlying_type.as_deref().with_context(|| {
format!(
"Missing underlying type for TRADIFI_PERPETUAL symbol '{}'",
symbol.symbol
)
})?;
match underlying_type {
"EQUITY" | "KR_EQUITY" | "HK_EQUITY" | "PREMARKET" => Ok(AssetClass::Equity),
"COMMODITY" => Ok(AssetClass::Commodity),
_ => anyhow::bail!(
"Unsupported underlying type '{underlying_type}' for TRADIFI_PERPETUAL symbol '{}'",
symbol.symbol
),
}
}
pub fn get_currency(code: &str) -> Currency {
Currency::get_or_create_crypto(code)
}
fn get_filter<'a>(filters: &'a [Value], filter_type: &str) -> Option<&'a Value> {
filters.iter().find(|f| {
f.get("filterType")
.and_then(|v| v.as_str())
.is_some_and(|t| t == filter_type)
})
}
fn parse_filter_string(filter: &Value, field: &str) -> anyhow::Result<String> {
filter
.get(field)
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| anyhow::anyhow!("Missing field '{field}' in filter"))
}
fn parse_filter_price(filter: &Value, field: &str) -> anyhow::Result<Price> {
let value = parse_filter_string(filter, field)?;
Price::from_str(&value).map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
}
fn parse_filter_quantity(filter: &Value, field: &str) -> anyhow::Result<Quantity> {
let value = parse_filter_string(filter, field)?;
Quantity::from_str(&value)
.map_err(|e| anyhow::anyhow!("Failed to parse {field}='{value}': {e}"))
}
fn parse_futures_min_notional(filters: &[Value], currency: Currency) -> Option<Money> {
let filter = get_filter(filters, "MIN_NOTIONAL")?;
let raw = filter.get("notional").and_then(|v| v.as_str())?;
let amount = f64::from_str(raw).ok()?;
if amount <= 0.0 {
return None;
}
Some(Money::new(amount, currency))
}
#[must_use]
pub(crate) fn parse_quantity_at_precision(raw: &str, precision: u8) -> Option<Quantity> {
let decimal = Decimal::from_str(raw).ok()?;
if !decimal.is_sign_positive() || decimal.is_zero() {
return None;
}
Quantity::from_decimal_dp(decimal, precision).ok()
}
#[must_use]
pub(crate) fn parse_price_at_precision(raw: &str, precision: u8) -> Option<Price> {
let decimal = Decimal::from_str(raw).ok()?;
if !decimal.is_sign_positive() || decimal.is_zero() {
return None;
}
Price::from_decimal_dp(decimal, precision).ok()
}
pub(crate) fn parse_required_decimal(raw: &str, field: &str) -> anyhow::Result<Decimal> {
Decimal::from_str(raw).map_err(|e| anyhow::anyhow!("invalid {field}='{raw}': {e}"))
}
pub(crate) fn parse_required_quantity_at_precision(
raw: &str,
precision: u8,
field: &str,
) -> anyhow::Result<Quantity> {
let decimal = parse_required_decimal(raw, field)?;
Quantity::from_decimal_dp(decimal, precision)
.map_err(|e| anyhow::anyhow!("invalid {field}='{raw}' at precision {precision}: {e}"))
}
pub(crate) fn parse_required_price_at_precision(
raw: &str,
precision: u8,
field: &str,
) -> anyhow::Result<Price> {
let decimal = parse_required_decimal(raw, field)?;
Price::from_decimal_dp(decimal, precision)
.map_err(|e| anyhow::anyhow!("invalid {field}='{raw}' at precision {precision}: {e}"))
}
#[must_use]
pub(crate) fn quantity_at_precision(quantity: Quantity, precision: u8) -> Option<Quantity> {
Quantity::from_decimal_dp(quantity.as_decimal(), precision).ok()
}
#[must_use]
pub(crate) fn price_at_precision(price: Price, precision: u8) -> Option<Price> {
Price::from_decimal_dp(price.as_decimal(), precision).ok()
}
pub fn parse_usdm_instrument(
symbol: &BinanceFuturesUsdSymbol,
ts_event: UnixNanos,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
parse_usdm_instrument_with_fees(symbol, None, None, ts_event, ts_init)
}
pub(crate) fn parse_usdm_instrument_with_fees(
symbol: &BinanceFuturesUsdSymbol,
maker_fee: Option<Decimal>,
taker_fee: Option<Decimal>,
ts_event: UnixNanos,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
enum ContractKind {
CryptoPerpetual,
TradFi(AssetClass),
Delivery,
}
let contract_kind = match symbol.contract_type.as_str() {
CONTRACT_TYPE_PERPETUAL => ContractKind::CryptoPerpetual,
CONTRACT_TYPE_TRADIFI_PERPETUAL => ContractKind::TradFi(parse_tradifi_asset_class(symbol)?),
CONTRACT_TYPE_CURRENT_MONTH
| CONTRACT_TYPE_NEXT_MONTH
| CONTRACT_TYPE_CURRENT_QUARTER
| CONTRACT_TYPE_NEXT_QUARTER => ContractKind::Delivery,
_ => anyhow::bail!(
"Unsupported USD-M contract type '{}' for symbol '{}'",
symbol.contract_type,
symbol.symbol,
),
};
if symbol.status != BinanceTradingStatus::Trading {
anyhow::bail!(
"Symbol '{}' is not trading (status: {:?})",
symbol.symbol,
symbol.status
);
}
let quote_currency = get_currency(symbol.quote_asset.as_str());
let settlement_currency = get_currency(symbol.margin_asset.as_str());
let instrument_id = format_instrument_id(&symbol.symbol, BinanceProductType::UsdM);
let raw_symbol = Symbol::new(symbol.symbol.as_str());
let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
.context("Missing PRICE_FILTER in symbol filters")?;
let tick_size = parse_filter_price(price_filter, "tickSize")?;
if tick_size.is_zero() {
anyhow::bail!(
"Invalid tickSize of 0 for symbol '{}', cannot create instrument",
symbol.symbol,
);
}
let max_price = parse_filter_price(price_filter, "maxPrice").ok();
let min_price = parse_filter_price(price_filter, "minPrice").ok();
let lot_filter =
get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
let min_notional = parse_futures_min_notional(&symbol.filters, quote_currency);
let default_margin = Decimal::new(1, 1);
match contract_kind {
ContractKind::TradFi(asset_class) => {
let instrument = PerpetualContract::builder()
.instrument_id(instrument_id)
.raw_symbol(raw_symbol)
.underlying(symbol.base_asset)
.asset_class(asset_class)
.quote_currency(quote_currency)
.settlement_currency(settlement_currency)
.is_inverse(false)
.price_precision(tick_size.precision)
.size_precision(step_size.precision)
.price_increment(tick_size)
.size_increment(step_size)
.lot_size(step_size)
.maybe_max_quantity(max_quantity)
.maybe_min_quantity(min_quantity)
.maybe_min_notional(min_notional)
.maybe_max_price(max_price)
.maybe_min_price(min_price)
.margin_init(default_margin)
.margin_maint(default_margin)
.maybe_maker_fee(maker_fee)
.maybe_taker_fee(taker_fee)
.ts_event(ts_event)
.ts_init(ts_init)
.build()?;
Ok(InstrumentAny::PerpetualContract(instrument))
}
ContractKind::CryptoPerpetual => {
let instrument = CryptoPerpetual::new(
instrument_id,
raw_symbol,
get_currency(symbol.base_asset.as_str()),
quote_currency,
settlement_currency,
false,
tick_size.precision,
step_size.precision,
tick_size,
step_size,
None,
Some(step_size),
max_quantity,
min_quantity,
None,
min_notional,
max_price,
min_price,
Some(default_margin),
Some(default_margin),
maker_fee,
taker_fee,
None,
None,
ts_event,
ts_init,
);
Ok(InstrumentAny::CryptoPerpetual(instrument))
}
ContractKind::Delivery => {
let activation_ns = parse_millis(symbol.onboard_date, "Futures onboardDate")?;
let expiration_ns = parse_millis(symbol.delivery_date, "Futures deliveryDate")?;
let instrument = CryptoFuture::new(
instrument_id,
raw_symbol,
get_currency(symbol.base_asset.as_str()),
quote_currency,
settlement_currency,
false,
activation_ns,
expiration_ns,
tick_size.precision,
step_size.precision,
tick_size,
step_size,
None,
Some(step_size),
max_quantity,
min_quantity,
None,
min_notional,
max_price,
min_price,
Some(default_margin),
Some(default_margin),
maker_fee,
taker_fee,
None,
None,
ts_event,
ts_init,
);
Ok(InstrumentAny::CryptoFuture(instrument))
}
}
}
pub fn parse_coinm_instrument(
symbol: &BinanceFuturesCoinSymbol,
ts_event: UnixNanos,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
parse_coinm_instrument_with_fees(symbol, None, None, ts_event, ts_init)
}
pub(crate) fn parse_coinm_instrument_with_fees(
symbol: &BinanceFuturesCoinSymbol,
maker_fee: Option<Decimal>,
taker_fee: Option<Decimal>,
ts_event: UnixNanos,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
let is_perpetual = symbol.contract_type == CONTRACT_TYPE_PERPETUAL;
let is_delivery = matches!(
symbol.contract_type.as_str(),
CONTRACT_TYPE_CURRENT_QUARTER | CONTRACT_TYPE_NEXT_QUARTER
);
if !is_perpetual && !is_delivery {
anyhow::bail!(
"Unsupported COIN-M contract type '{}' for symbol '{}'",
symbol.contract_type,
symbol.symbol,
);
}
if symbol.contract_status != Some(BinanceContractStatus::Trading) {
anyhow::bail!(
"Symbol '{}' is not trading (status: {:?})",
symbol.symbol,
symbol.contract_status
);
}
let base_currency = get_currency(symbol.base_asset.as_str());
let quote_currency = get_currency(symbol.quote_asset.as_str());
let settlement_currency = get_currency(symbol.margin_asset.as_str());
let instrument_id = format_instrument_id(&symbol.symbol, BinanceProductType::CoinM);
let raw_symbol = Symbol::new(symbol.symbol.as_str());
let price_filter = get_filter(&symbol.filters, "PRICE_FILTER")
.context("Missing PRICE_FILTER in symbol filters")?;
let tick_size = parse_filter_price(price_filter, "tickSize")?;
if tick_size.is_zero() {
anyhow::bail!(
"Invalid tickSize of 0 for symbol '{}', cannot create instrument",
symbol.symbol,
);
}
let max_price = parse_filter_price(price_filter, "maxPrice").ok();
let min_price = parse_filter_price(price_filter, "minPrice").ok();
let lot_filter =
get_filter(&symbol.filters, "LOT_SIZE").context("Missing LOT_SIZE in symbol filters")?;
let step_size = parse_filter_quantity(lot_filter, "stepSize")?;
let max_quantity = parse_filter_quantity(lot_filter, "maxQty").ok();
let min_quantity = parse_filter_quantity(lot_filter, "minQty").ok();
let multiplier = Quantity::from(symbol.contract_size);
let min_notional = parse_futures_min_notional(&symbol.filters, quote_currency);
let default_margin = Decimal::new(1, 1);
if is_perpetual {
let instrument = CryptoPerpetual::new(
instrument_id,
raw_symbol,
base_currency,
quote_currency,
settlement_currency,
true,
tick_size.precision,
step_size.precision,
tick_size,
step_size,
Some(multiplier),
Some(step_size),
max_quantity,
min_quantity,
None,
min_notional,
max_price,
min_price,
Some(default_margin),
Some(default_margin),
maker_fee,
taker_fee,
None,
None,
ts_event,
ts_init,
);
Ok(InstrumentAny::CryptoPerpetual(instrument))
} else {
let activation_ns = parse_millis(symbol.onboard_date, "Futures onboardDate")?;
let expiration_ns = parse_millis(symbol.delivery_date, "Futures deliveryDate")?;
let instrument = CryptoFuture::new(
instrument_id,
raw_symbol,
base_currency,
quote_currency,
settlement_currency,
true,
activation_ns,
expiration_ns,
tick_size.precision,
step_size.precision,
tick_size,
step_size,
Some(multiplier),
Some(step_size),
max_quantity,
min_quantity,
None,
min_notional,
max_price,
min_price,
Some(default_margin),
Some(default_margin),
maker_fee,
taker_fee,
None,
None,
ts_event,
ts_init,
);
Ok(InstrumentAny::CryptoFuture(instrument))
}
}
const SBE_STATUS_TRADING: u8 = 0;
fn sbe_mantissa_precision(mantissa: i64, exponent: i8) -> u8 {
if mantissa == 0 {
return 0;
}
let mut m = mantissa.abs();
let mut trailing_zeros: i8 = 0;
while m > 0 && m % 10 == 0 {
m /= 10;
trailing_zeros += 1;
}
(-exponent - trailing_zeros).max(0) as u8
}
fn parse_sbe_price_filter(
filter: &BinancePriceFilterSbe,
) -> anyhow::Result<(Price, Option<Price>, Option<Price>)> {
let precision = sbe_mantissa_precision(filter.tick_size, filter.price_exponent);
let tick_size =
Price::from_mantissa_exponent_checked(filter.tick_size, filter.price_exponent, precision)?;
let max_price = if filter.max_price != 0 {
Some(Price::from_mantissa_exponent_checked(
filter.max_price,
filter.price_exponent,
precision,
)?)
} else {
None
};
let min_price = if filter.min_price != 0 {
Some(Price::from_mantissa_exponent_checked(
filter.min_price,
filter.price_exponent,
precision,
)?)
} else {
None
};
Ok((tick_size, max_price, min_price))
}
fn parse_sbe_lot_size_filter(
filter: &BinanceLotSizeFilterSbe,
) -> anyhow::Result<(Quantity, Option<Quantity>, Option<Quantity>)> {
let precision = sbe_mantissa_precision(filter.step_size, filter.qty_exponent);
let step_size = Quantity::from_mantissa_exponent_checked(
filter.step_size as u64,
filter.qty_exponent,
precision,
)?;
let max_qty = if filter.max_qty != 0 {
Some(Quantity::from_mantissa_exponent_checked(
filter.max_qty as u64,
filter.qty_exponent,
precision,
)?)
} else {
None
};
let min_qty = if filter.min_qty != 0 {
Some(Quantity::from_mantissa_exponent_checked(
filter.min_qty as u64,
filter.qty_exponent,
precision,
)?)
} else {
None
};
Ok((step_size, max_qty, min_qty))
}
pub fn parse_spot_instrument_sbe(
symbol: &BinanceSymbolSbe,
ts_event: UnixNanos,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
parse_spot_instrument_sbe_with_fees(symbol, None, None, ts_event, ts_init)
}
pub(crate) fn parse_spot_instrument_sbe_with_fees(
symbol: &BinanceSymbolSbe,
maker_fee: Option<Decimal>,
taker_fee: Option<Decimal>,
ts_event: UnixNanos,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
if symbol.status != SBE_STATUS_TRADING {
anyhow::bail!(
"Symbol '{}' is not trading (status: {})",
symbol.symbol,
symbol.status
);
}
let base_currency = get_currency(&symbol.base_asset);
let quote_currency = get_currency(&symbol.quote_asset);
let instrument_id = InstrumentId::new(
Symbol::from_str_unchecked(&symbol.symbol),
Venue::new(BINANCE),
);
let raw_symbol = Symbol::new(&symbol.symbol);
let price_filter = symbol
.filters
.price_filter
.as_ref()
.context("Missing PRICE_FILTER in symbol filters")?;
let (tick_size, max_price, min_price) = parse_sbe_price_filter(price_filter)?;
let lot_filter = symbol
.filters
.lot_size_filter
.as_ref()
.context("Missing LOT_SIZE in symbol filters")?;
let (step_size, max_quantity, min_quantity) = parse_sbe_lot_size_filter(lot_filter)?;
let default_margin = Decimal::new(1, 0);
let instrument = CurrencyPair::new(
instrument_id,
raw_symbol,
base_currency,
quote_currency,
tick_size.precision,
step_size.precision,
tick_size,
step_size,
None, Some(step_size),
max_quantity,
min_quantity,
None, None, max_price,
min_price,
Some(default_margin),
Some(default_margin),
maker_fee,
taker_fee,
None, None, ts_event,
ts_init,
);
Ok(InstrumentAny::CurrencyPair(instrument))
}
pub(crate) fn parse_spot_instrument_json_with_fees(
symbol: &BinanceSymbolJson,
maker_fee: Option<Decimal>,
taker_fee: Option<Decimal>,
ts_event: UnixNanos,
ts_init: UnixNanos,
) -> anyhow::Result<InstrumentAny> {
anyhow::ensure!(
symbol.status == "TRADING",
"Symbol '{}' is not trading (status: {})",
symbol.symbol,
symbol.status,
);
let price_filter = symbol
.filters
.iter()
.find(|filter| filter.filter_type == "PRICE_FILTER")
.context("Missing PRICE_FILTER in symbol filters")?;
let lot_filter = symbol
.filters
.iter()
.find(|filter| filter.filter_type == "LOT_SIZE")
.context("Missing LOT_SIZE in symbol filters")?;
let tick_size = decimal_price(
price_filter
.tick_size
.as_deref()
.context("Missing PRICE_FILTER tickSize")?,
)?;
anyhow::ensure!(!tick_size.is_zero(), "Invalid tickSize of 0");
let step_size = decimal_quantity(
lot_filter
.step_size
.as_deref()
.context("Missing LOT_SIZE stepSize")?,
)?;
anyhow::ensure!(!step_size.is_zero(), "Invalid stepSize of 0");
let instrument = CurrencyPair::new(
InstrumentId::new(
Symbol::from_str_unchecked(&symbol.symbol),
Venue::new(BINANCE),
),
Symbol::new(&symbol.symbol),
get_currency(&symbol.base_asset),
get_currency(&symbol.quote_asset),
tick_size.precision,
step_size.precision,
tick_size,
step_size,
None,
Some(step_size),
optional_decimal_quantity(lot_filter.max_qty.as_deref(), step_size.precision)?,
optional_decimal_quantity(lot_filter.min_qty.as_deref(), step_size.precision)?,
None,
None,
optional_decimal_price(price_filter.max_price.as_deref(), tick_size.precision)?,
optional_decimal_price(price_filter.min_price.as_deref(), tick_size.precision)?,
Some(Decimal::ONE),
Some(Decimal::ONE),
maker_fee,
taker_fee,
None,
None,
ts_event,
ts_init,
);
Ok(InstrumentAny::CurrencyPair(instrument))
}
fn decimal_price(value: &str) -> anyhow::Result<Price> {
let decimal = Decimal::from_str_exact(value)?.normalize();
let precision = u8::try_from(decimal.scale()).context("price precision exceeds u8")?;
Ok(Price::from_decimal_dp(decimal, precision)?)
}
fn decimal_quantity(value: &str) -> anyhow::Result<Quantity> {
let decimal = Decimal::from_str_exact(value)?.normalize();
let precision = u8::try_from(decimal.scale()).context("quantity precision exceeds u8")?;
Ok(Quantity::from_decimal_dp(decimal, precision)?)
}
fn optional_decimal_price(value: Option<&str>, precision: u8) -> anyhow::Result<Option<Price>> {
let Some(value) = value else {
return Ok(None);
};
let decimal = Decimal::from_str_exact(value)?;
if decimal.is_zero() {
return Ok(None);
}
Ok(Some(Price::from_decimal_dp(decimal, precision)?))
}
fn optional_decimal_quantity(
value: Option<&str>,
precision: u8,
) -> anyhow::Result<Option<Quantity>> {
let Some(value) = value else {
return Ok(None);
};
let decimal = Decimal::from_str_exact(value)?;
if decimal.is_zero() {
return Ok(None);
}
Ok(Some(Quantity::from_decimal_dp(decimal, precision)?))
}
pub fn parse_spot_trades_sbe(
trades: &BinanceTrades,
instrument: &InstrumentAny,
ts_init: UnixNanos,
) -> anyhow::Result<Vec<TradeTick>> {
let instrument_id = instrument.id();
let price_precision = instrument.price_precision();
let size_precision = instrument.size_precision();
let mut result = Vec::with_capacity(trades.trades.len());
for trade in &trades.trades {
let price = Price::from_mantissa_exponent(
trade.price_mantissa,
trades.price_exponent,
price_precision,
);
let size = Quantity::from_mantissa_exponent(
trade.qty_mantissa as u64,
trades.qty_exponent,
size_precision,
);
let aggressor_side = if trade.is_buyer_maker {
AggressorSide::Seller
} else {
AggressorSide::Buyer
};
let ts_event = parse_micros(trade.time, "Spot SBE trade time")?;
let tick = TradeTick::new(
instrument_id,
price,
size,
aggressor_side,
TradeId::new(trade.id.to_string()),
ts_event,
ts_init,
);
result.push(tick);
}
Ok(result)
}
#[must_use]
pub const fn map_order_status_sbe(status: SbeOrderStatus) -> OrderStatus {
match status {
SbeOrderStatus::New => OrderStatus::Accepted,
SbeOrderStatus::PendingNew => OrderStatus::Submitted,
SbeOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
SbeOrderStatus::Filled => OrderStatus::Filled,
SbeOrderStatus::Canceled => OrderStatus::Canceled,
SbeOrderStatus::PendingCancel => OrderStatus::PendingCancel,
SbeOrderStatus::Rejected => OrderStatus::Rejected,
SbeOrderStatus::Expired | SbeOrderStatus::ExpiredInMatch => OrderStatus::Expired,
SbeOrderStatus::Unknown | SbeOrderStatus::NonRepresentable | SbeOrderStatus::NullVal => {
OrderStatus::Initialized
}
}
}
#[must_use]
pub const fn map_order_type_sbe(order_type: SbeOrderType) -> OrderType {
match order_type {
SbeOrderType::Market => OrderType::Market,
SbeOrderType::Limit | SbeOrderType::LimitMaker => OrderType::Limit,
SbeOrderType::StopLoss | SbeOrderType::TakeProfit => OrderType::StopMarket,
SbeOrderType::StopLossLimit | SbeOrderType::TakeProfitLimit => OrderType::StopLimit,
SbeOrderType::NonRepresentable | SbeOrderType::NullVal => OrderType::Market,
}
}
#[must_use]
pub const fn map_order_side_sbe(side: SbeOrderSide) -> OrderSide {
match side {
SbeOrderSide::Buy => OrderSide::Buy,
SbeOrderSide::Sell => OrderSide::Sell,
SbeOrderSide::NonRepresentable | SbeOrderSide::NullVal => OrderSide::NoOrderSide,
}
}
#[must_use]
pub const fn map_time_in_force_sbe(tif: SbeTimeInForce) -> TimeInForce {
match tif {
SbeTimeInForce::Gtc => TimeInForce::Gtc,
SbeTimeInForce::Ioc => TimeInForce::Ioc,
SbeTimeInForce::Fok => TimeInForce::Fok,
SbeTimeInForce::NonRepresentable | SbeTimeInForce::NullVal => TimeInForce::Gtc,
}
}
pub fn parse_order_status_report_sbe(
order: &BinanceOrderResponse,
account_id: AccountId,
instrument: &InstrumentAny,
broker_id: &str,
ts_init: UnixNanos,
) -> anyhow::Result<OrderStatusReport> {
let instrument_id = instrument.id();
let price_precision = instrument.price_precision();
let size_precision = instrument.size_precision();
let price = if order.price_mantissa != 0 {
Some(Price::from_mantissa_exponent(
order.price_mantissa,
order.price_exponent,
price_precision,
))
} else {
None
};
let quantity = Quantity::from_mantissa_exponent(
order.orig_qty_mantissa as u64,
order.qty_exponent,
size_precision,
);
let filled_qty = Quantity::from_mantissa_exponent(
order.executed_qty_mantissa as u64,
order.qty_exponent,
size_precision,
);
let avg_px = if order.executed_qty_mantissa > 0 {
let quote_exp = (order.price_exponent as i32) + (order.qty_exponent as i32);
let cum_quote_dec = Decimal::new(order.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
let filled_dec = Decimal::new(
order.executed_qty_mantissa,
(-order.qty_exponent as i32) as u32,
);
let avg_dec = cum_quote_dec / filled_dec;
Some(
Price::from_decimal_dp(avg_dec, price_precision)
.unwrap_or(Price::zero(price_precision)),
)
} else {
None
};
let trigger_price = order.stop_price_mantissa.and_then(|mantissa| {
if mantissa != 0 {
Some(Price::from_mantissa_exponent(
mantissa,
order.price_exponent,
price_precision,
))
} else {
None
}
});
let order_status = map_order_status_sbe(order.status);
let order_type = map_order_type_sbe(order.order_type);
let order_side = map_order_side_sbe(order.side);
let time_in_force = map_time_in_force_sbe(order.time_in_force);
let trigger_type = if trigger_price.is_some() {
Some(TriggerType::LastPrice)
} else {
None
};
let ts_event = parse_micros(order.update_time, "Spot SBE order update time")?;
let order_list_id = order.order_list_id.and_then(|id| {
if id > 0 {
Some(OrderListId::new(id.to_string()))
} else {
None
}
});
let post_only = order.order_type == SbeOrderType::LimitMaker;
let ts_accepted = parse_micros(order.time, "Spot SBE order time")?;
let mut report = OrderStatusReport::new(
account_id,
instrument_id,
Some(decode_client_order_id(&order.client_order_id, broker_id)?),
VenueOrderId::new(order.order_id.to_string()),
order_side,
order_type,
time_in_force,
order_status,
quantity,
filled_qty,
ts_accepted,
ts_event,
ts_init,
None, );
if let Some(p) = price {
report = report.with_price(p);
}
if let Some(ap) = avg_px {
report = report.with_avg_px(ap.as_decimal());
}
if let Some(tp) = trigger_price {
report = report.with_trigger_price(tp);
}
if let Some(tt) = trigger_type {
report = report.with_trigger_type(tt);
}
if let Some(oli) = order_list_id {
report = report.with_order_list_id(oli);
}
if post_only {
report = report.with_post_only(true);
}
Ok(report)
}
pub fn parse_new_order_response_sbe(
response: &BinanceNewOrderResponse,
account_id: AccountId,
instrument: &InstrumentAny,
broker_id: &str,
ts_init: UnixNanos,
) -> anyhow::Result<OrderStatusReport> {
let instrument_id = instrument.id();
let price_precision = instrument.price_precision();
let size_precision = instrument.size_precision();
let price = if response.price_mantissa != 0 {
Some(Price::from_mantissa_exponent(
response.price_mantissa,
response.price_exponent,
price_precision,
))
} else {
None
};
let quantity = Quantity::from_mantissa_exponent(
response.orig_qty_mantissa as u64,
response.qty_exponent,
size_precision,
);
let filled_qty = Quantity::from_mantissa_exponent(
response.executed_qty_mantissa as u64,
response.qty_exponent,
size_precision,
);
let avg_px = if response.executed_qty_mantissa > 0 {
let quote_exp = (response.price_exponent as i32) + (response.qty_exponent as i32);
let cum_quote_dec =
Decimal::new(response.cummulative_quote_qty_mantissa, (-quote_exp) as u32);
let filled_dec = Decimal::new(
response.executed_qty_mantissa,
(-response.qty_exponent as i32) as u32,
);
let avg_dec = cum_quote_dec / filled_dec;
Some(
Price::from_decimal_dp(avg_dec, price_precision)
.unwrap_or(Price::zero(price_precision)),
)
} else {
None
};
let trigger_price = response.stop_price_mantissa.and_then(|mantissa| {
if mantissa != 0 {
Some(Price::from_mantissa_exponent(
mantissa,
response.price_exponent,
price_precision,
))
} else {
None
}
});
let order_status = map_order_status_sbe(response.status);
let order_type = map_order_type_sbe(response.order_type);
let order_side = map_order_side_sbe(response.side);
let time_in_force = map_time_in_force_sbe(response.time_in_force);
let trigger_type = if trigger_price.is_some() {
Some(TriggerType::LastPrice)
} else {
None
};
let ts_event = parse_micros(response.transact_time, "Spot SBE transaction time")?;
let ts_accepted = ts_event;
let order_list_id = response.order_list_id.and_then(|id| {
if id > 0 {
Some(OrderListId::new(id.to_string()))
} else {
None
}
});
let post_only = response.order_type == SbeOrderType::LimitMaker;
let mut report = OrderStatusReport::new(
account_id,
instrument_id,
Some(decode_client_order_id(
&response.client_order_id,
broker_id,
)?),
VenueOrderId::new(response.order_id.to_string()),
order_side,
order_type,
time_in_force,
order_status,
quantity,
filled_qty,
ts_accepted,
ts_event,
ts_init,
None,
);
if let Some(p) = price {
report = report.with_price(p);
}
if let Some(ap) = avg_px {
report = report.with_avg_px(ap.as_decimal());
}
if let Some(tp) = trigger_price {
report = report.with_trigger_price(tp);
}
if let Some(tt) = trigger_type {
report = report.with_trigger_type(tt);
}
if let Some(oli) = order_list_id {
report = report.with_order_list_id(oli);
}
if post_only {
report = report.with_post_only(true);
}
Ok(report)
}
pub fn parse_fill_report_sbe(
trade: &BinanceAccountTrade,
account_id: AccountId,
instrument: &InstrumentAny,
commission_currency: Currency,
ts_init: UnixNanos,
) -> anyhow::Result<FillReport> {
let instrument_id = instrument.id();
let price_precision = instrument.price_precision();
let size_precision = instrument.size_precision();
let last_px =
Price::from_mantissa_exponent(trade.price_mantissa, trade.price_exponent, price_precision);
let last_qty = Quantity::from_mantissa_exponent(
trade.qty_mantissa as u64,
trade.qty_exponent,
size_precision,
);
let comm_exp = trade.commission_exponent as i32;
let comm_dec = Decimal::new(trade.commission_mantissa, (-comm_exp) as u32);
let commission = Money::from_decimal(comm_dec, commission_currency)?;
let order_side = if trade.is_buyer {
OrderSide::Buy
} else {
OrderSide::Sell
};
let liquidity_side = if trade.is_maker {
LiquiditySide::Maker
} else {
LiquiditySide::Taker
};
let ts_event = parse_micros(trade.time, "Spot SBE account trade time")?;
Ok(FillReport::new(
account_id,
instrument_id,
VenueOrderId::new(trade.order_id.to_string()),
TradeId::new(trade.id.to_string()),
order_side,
last_qty,
last_px,
commission,
liquidity_side,
None, None, ts_event,
ts_init,
None, ))
}
pub fn parse_klines_to_binance_bars(
klines: &BinanceKlines,
bar_type: BarType,
instrument: &InstrumentAny,
ts_init: UnixNanos,
) -> anyhow::Result<Vec<crate::common::bar::BinanceBar>> {
let price_precision = instrument.price_precision();
let size_precision = instrument.size_precision();
let mut bars = Vec::with_capacity(klines.klines.len());
for kline in &klines.klines {
let open =
Price::from_mantissa_exponent(kline.open_price, klines.price_exponent, price_precision);
let high =
Price::from_mantissa_exponent(kline.high_price, klines.price_exponent, price_precision);
let low =
Price::from_mantissa_exponent(kline.low_price, klines.price_exponent, price_precision);
let close = Price::from_mantissa_exponent(
kline.close_price,
klines.price_exponent,
price_precision,
);
let volume_mantissa = i128::from_le_bytes(kline.volume);
let volume_dec =
Decimal::from_i128_with_scale(volume_mantissa, (-klines.qty_exponent as i32) as u32);
let volume = Quantity::from_decimal_dp(volume_dec, size_precision)?;
let quote_volume = Decimal::from_i128_with_scale(
i128::from_le_bytes(kline.quote_volume),
(-klines.price_exponent as i32) as u32,
);
let taker_buy_base_volume = Decimal::from_i128_with_scale(
i128::from_le_bytes(kline.taker_buy_base_volume),
(-klines.qty_exponent as i32) as u32,
);
let taker_buy_quote_volume = Decimal::from_i128_with_scale(
i128::from_le_bytes(kline.taker_buy_quote_volume),
(-klines.price_exponent as i32) as u32,
);
let count = u64::try_from(kline.num_trades).map_err(|_| {
anyhow::anyhow!("invalid negative kline trade count {}", kline.num_trades)
})?;
let ts_event = parse_micros(kline.close_time, "Spot SBE kline close time")?;
let bar = crate::common::bar::BinanceBar::new(
bar_type,
open,
high,
low,
close,
volume,
quote_volume,
count,
taker_buy_base_volume,
taker_buy_quote_volume,
ts_event,
ts_init,
);
bars.push(bar);
}
Ok(bars)
}
pub fn parse_klines_to_bars(
klines: &BinanceKlines,
bar_type: BarType,
instrument: &InstrumentAny,
ts_init: UnixNanos,
) -> anyhow::Result<Vec<Bar>> {
Ok(
parse_klines_to_binance_bars(klines, bar_type, instrument, ts_init)?
.into_iter()
.map(|bar| bar.bar())
.collect(),
)
}
pub fn bar_spec_to_binance_interval(
bar_spec: BarSpecification,
) -> anyhow::Result<BinanceKlineInterval> {
let step = bar_spec.step.get();
let interval = match bar_spec.aggregation {
BarAggregation::Second => match step {
1 => BinanceKlineInterval::Second1,
_ => anyhow::bail!("Unsupported second interval: {step}s"),
},
BarAggregation::Minute => match step {
1 => BinanceKlineInterval::Minute1,
3 => BinanceKlineInterval::Minute3,
5 => BinanceKlineInterval::Minute5,
15 => BinanceKlineInterval::Minute15,
30 => BinanceKlineInterval::Minute30,
_ => anyhow::bail!("Unsupported minute interval: {step}m"),
},
BarAggregation::Hour => match step {
1 => BinanceKlineInterval::Hour1,
2 => BinanceKlineInterval::Hour2,
4 => BinanceKlineInterval::Hour4,
6 => BinanceKlineInterval::Hour6,
8 => BinanceKlineInterval::Hour8,
12 => BinanceKlineInterval::Hour12,
_ => anyhow::bail!("Unsupported hour interval: {step}h"),
},
BarAggregation::Day => match step {
1 => BinanceKlineInterval::Day1,
3 => BinanceKlineInterval::Day3,
_ => anyhow::bail!("Unsupported day interval: {step}d"),
},
BarAggregation::Week => match step {
1 => BinanceKlineInterval::Week1,
_ => anyhow::bail!("Unsupported week interval: {step}w"),
},
BarAggregation::Month => match step {
1 => BinanceKlineInterval::Month1,
_ => anyhow::bail!("Unsupported month interval: {step}M"),
},
agg => anyhow::bail!("Unsupported bar aggregation for Binance: {agg:?}"),
};
Ok(interval)
}
pub(crate) fn quote_to_l1_deltas(quote: QuoteTick, sequence: u64) -> OrderBookDeltas {
let bid_action = if quote.bid_size.is_zero() {
BookAction::Delete
} else {
BookAction::Update
};
let ask_action = if quote.ask_size.is_zero() {
BookAction::Delete
} else {
BookAction::Update
};
let bid = OrderBookDelta::new(
quote.instrument_id,
bid_action,
BookOrder::new(OrderSide::Buy, quote.bid_price, quote.bid_size, 0),
RecordFlag::F_MBP as u8,
sequence,
quote.ts_event,
quote.ts_init,
);
let ask = OrderBookDelta::new(
quote.instrument_id,
ask_action,
BookOrder::new(OrderSide::Sell, quote.ask_price, quote.ask_size, 0),
RecordFlag::F_MBP as u8 | RecordFlag::F_LAST as u8,
sequence,
quote.ts_event,
quote.ts_init,
);
OrderBookDeltas::new(quote.instrument_id, vec![bid, ask])
}
#[cfg(test)]
mod tests {
use nautilus_model::identifiers::ClientOrderId;
use rstest::rstest;
use rust_decimal_macros::dec;
use serde_json::json;
use ustr::Ustr;
use super::*;
use crate::common::{
consts::BINANCE_NAUTILUS_SPOT_BROKER_ID,
enums::{BinanceContractStatus, BinanceTradingStatus},
};
#[rstest]
fn test_quote_to_l1_deltas_maps_all_fields() {
let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
let ts_event = UnixNanos::from(1_700_000_000_000_000_001u64);
let ts_init = UnixNanos::from(1_700_000_000_000_000_002u64);
let quote = QuoteTick::new(
instrument_id,
Price::from("42000.01"),
Price::from("42000.02"),
Quantity::from("1.23456"),
Quantity::from("2.34567"),
ts_event,
ts_init,
);
let expected = OrderBookDeltas::new(
instrument_id,
vec![
OrderBookDelta::new(
instrument_id,
BookAction::Update,
BookOrder::new(
OrderSide::Buy,
Price::from("42000.01"),
Quantity::from("1.23456"),
0,
),
RecordFlag::F_MBP as u8,
12345,
ts_event,
ts_init,
),
OrderBookDelta::new(
instrument_id,
BookAction::Update,
BookOrder::new(
OrderSide::Sell,
Price::from("42000.02"),
Quantity::from("2.34567"),
0,
),
RecordFlag::F_MBP as u8 | RecordFlag::F_LAST as u8,
12345,
ts_event,
ts_init,
),
],
);
let actual = quote_to_l1_deltas(quote, 12345);
assert_eq!(actual, expected);
}
#[rstest]
fn test_quote_to_l1_deltas_deletes_empty_sides() {
let instrument_id = InstrumentId::from("BTCUSDT.BINANCE");
let quote = QuoteTick::new(
instrument_id,
Price::from("42000.01"),
Price::from("42000.02"),
Quantity::from("0.00000"),
Quantity::from("0.00000"),
UnixNanos::from(1_700_000_000_000_000_001u64),
UnixNanos::from(1_700_000_000_000_000_002u64),
);
let actual = quote_to_l1_deltas(quote, 12345);
assert_eq!(actual.deltas[0].action, BookAction::Delete);
assert_eq!(actual.deltas[1].action, BookAction::Delete);
}
#[rstest]
#[case::positive("0.001", 8, Some(Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 8).unwrap()))]
#[case::trailing_zero("0.00100000", 8, Some(Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 8).unwrap()))]
#[case::zero("0", 8, None)]
#[case::negative("-1", 8, None)]
#[case::empty("", 8, None)]
#[case::garbage("abc", 8, None)]
fn test_parse_quantity_at_precision(
#[case] raw: &str,
#[case] precision: u8,
#[case] expected: Option<Quantity>,
) {
assert_eq!(parse_quantity_at_precision(raw, precision), expected);
}
#[rstest]
#[case::positive("7100.50", 2, Some(Price::from_decimal_dp(Decimal::from_str("7100.50").unwrap(), 2).unwrap()))]
#[case::high_precision("0.000000001", 9, Some(Price::from_decimal_dp(Decimal::from_str("0.000000001").unwrap(), 9).unwrap()))]
#[case::zero("0", 2, None)]
#[case::negative("-100", 2, None)]
#[case::empty("", 2, None)]
fn test_parse_price_at_precision(
#[case] raw: &str,
#[case] precision: u8,
#[case] expected: Option<Price>,
) {
assert_eq!(parse_price_at_precision(raw, precision), expected);
}
#[rstest]
fn test_quantity_at_precision_re_precisions_via_decimal() {
let original = Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 3).unwrap();
let widened = quantity_at_precision(original, 8).unwrap();
let expected = Quantity::from_decimal_dp(Decimal::from_str("0.001").unwrap(), 8).unwrap();
assert_eq!(widened, expected);
}
#[rstest]
fn test_price_at_precision_re_precisions_via_decimal() {
let original = Price::from_decimal_dp(Decimal::from_str("7100.5").unwrap(), 1).unwrap();
let widened = price_at_precision(original, 8).unwrap();
let expected = Price::from_decimal_dp(Decimal::from_str("7100.5").unwrap(), 8).unwrap();
assert_eq!(widened, expected);
}
fn sample_usdm_symbol() -> BinanceFuturesUsdSymbol {
BinanceFuturesUsdSymbol {
symbol: Ustr::from("BTCUSDT"),
pair: Ustr::from("BTCUSDT"),
contract_type: "PERPETUAL".to_string(),
delivery_date: 4133404800000,
onboard_date: 1569398400000,
status: BinanceTradingStatus::Trading,
maint_margin_percent: "2.5000".to_string(),
required_margin_percent: "5.0000".to_string(),
base_asset: Ustr::from("BTC"),
quote_asset: Ustr::from("USDT"),
margin_asset: Ustr::from("USDT"),
price_precision: 2,
quantity_precision: 3,
base_asset_precision: 8,
quote_precision: 8,
underlying_type: Some("COIN".to_string()),
underlying_sub_type: vec!["PoW".to_string()],
settle_plan: None,
trigger_protect: Some("0.0500".to_string()),
liquidation_fee: Some("0.012500".to_string()),
market_take_bound: Some("0.05".to_string()),
order_types: vec!["LIMIT".to_string(), "MARKET".to_string()],
time_in_force: vec!["GTC".to_string(), "IOC".to_string()],
filters: vec![
json!({
"filterType": "PRICE_FILTER",
"tickSize": "0.10",
"maxPrice": "4529764",
"minPrice": "556.80"
}),
json!({
"filterType": "LOT_SIZE",
"stepSize": "0.001",
"maxQty": "1000",
"minQty": "0.001"
}),
json!({
"filterType": "MIN_NOTIONAL",
"notional": "5"
}),
],
}
}
fn sample_tradifi_usdm_symbol(
symbol: &str,
underlying: &str,
underlying_type: Option<&str>,
) -> BinanceFuturesUsdSymbol {
let mut definition = sample_usdm_symbol();
definition.symbol = Ustr::from(symbol);
definition.pair = Ustr::from(symbol);
definition.contract_type = CONTRACT_TYPE_TRADIFI_PERPETUAL.to_string();
definition.base_asset = Ustr::from(underlying);
definition.underlying_type = underlying_type.map(str::to_string);
definition
}
fn sample_coinm_symbol() -> BinanceFuturesCoinSymbol {
BinanceFuturesCoinSymbol {
symbol: Ustr::from("BTCUSD_PERP"),
pair: Ustr::from("BTCUSD"),
contract_type: "PERPETUAL".to_string(),
delivery_date: 4_133_404_800_000,
onboard_date: 1_569_398_400_000,
contract_status: Some(BinanceContractStatus::Trading),
contract_size: 100,
maint_margin_percent: "2.5000".to_string(),
required_margin_percent: "5.0000".to_string(),
base_asset: Ustr::from("BTC"),
quote_asset: Ustr::from("USD"),
margin_asset: Ustr::from("BTC"),
price_precision: 1,
quantity_precision: 0,
base_asset_precision: 8,
quote_precision: 8,
equal_qty_precision: None,
trigger_protect: Some("0.0500".to_string()),
liquidation_fee: Some("0.012500".to_string()),
market_take_bound: Some("0.05".to_string()),
order_types: vec!["LIMIT".to_string(), "MARKET".to_string()],
time_in_force: vec!["GTC".to_string(), "IOC".to_string()],
filters: vec![
json!({
"filterType": "PRICE_FILTER",
"tickSize": "0.10",
"maxPrice": "1000000",
"minPrice": "0.10"
}),
json!({
"filterType": "LOT_SIZE",
"stepSize": "1",
"maxQty": "1000",
"minQty": "1"
}),
json!({
"filterType": "MIN_NOTIONAL",
"notional": "1"
}),
],
}
}
fn sample_spot_symbol_sbe() -> BinanceSymbolSbe {
BinanceSymbolSbe {
symbol: "ETHUSDT".to_string(),
base_asset: "ETH".to_string(),
quote_asset: "USDT".to_string(),
base_asset_precision: 8,
quote_asset_precision: 8,
status: SBE_STATUS_TRADING,
order_types: 0,
iceberg_allowed: true,
oco_allowed: true,
oto_allowed: false,
quote_order_qty_market_allowed: true,
allow_trailing_stop: true,
cancel_replace_allowed: true,
amend_allowed: true,
is_spot_trading_allowed: true,
is_margin_trading_allowed: false,
filters: crate::spot::http::models::BinanceSymbolFiltersSbe {
price_filter: Some(BinancePriceFilterSbe {
price_exponent: -8,
min_price: 1_000_000,
max_price: 100_000_000_000_000,
tick_size: 1_000_000,
}),
lot_size_filter: Some(BinanceLotSizeFilterSbe {
qty_exponent: -8,
min_qty: 10_000,
max_qty: 900_000_000_000,
step_size: 10_000,
}),
},
permissions: vec![vec!["SPOT".to_string()]],
}
}
fn sample_spot_instrument() -> InstrumentAny {
let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
parse_spot_instrument_sbe(&sample_spot_symbol_sbe(), ts, ts).unwrap()
}
fn sample_account_id() -> AccountId {
AccountId::from("BINANCE-SPOT-001")
}
#[rstest]
fn test_parse_usdm_perpetual() {
let symbol = sample_usdm_symbol();
let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
let result = parse_usdm_instrument(&symbol, ts, ts);
assert!(result.is_ok(), "Failed: {:?}", result.err());
let instrument = result.unwrap();
match instrument {
InstrumentAny::CryptoPerpetual(perp) => {
assert_eq!(perp.id.to_string(), "BTCUSDT-PERP.BINANCE");
assert_eq!(perp.raw_symbol.to_string(), "BTCUSDT");
assert_eq!(perp.base_currency.code.as_str(), "BTC");
assert_eq!(perp.quote_currency.code.as_str(), "USDT");
assert_eq!(perp.settlement_currency.code.as_str(), "USDT");
assert!(!perp.is_inverse);
assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
assert_eq!(
perp.min_notional,
Some(Money::new(5.0, perp.quote_currency)),
);
}
other => panic!("Expected CryptoPerpetual, was {other:?}"),
}
}
#[rstest]
fn test_parse_usdm_perpetual_populates_fees() {
let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
let instrument = parse_usdm_instrument_with_fees(
&sample_usdm_symbol(),
Some(dec!(0.00016)),
Some(dec!(0.0004)),
ts,
ts,
)
.unwrap();
let InstrumentAny::CryptoPerpetual(perpetual) = instrument else {
panic!("expected CryptoPerpetual, was {instrument:?}");
};
assert_eq!(perpetual.maker_fee, dec!(0.00016));
assert_eq!(perpetual.taker_fee, dec!(0.0004));
}
#[rstest]
#[case::equity("SNDKUSDT", "SNDK", "EQUITY", AssetClass::Equity)]
#[case::korean_equity("005930USDT", "005930", "KR_EQUITY", AssetClass::Equity)]
#[case::hong_kong_equity("0700USDT", "0700", "HK_EQUITY", AssetClass::Equity)]
#[case::premarket("SPCXUSDT", "SPCX", "PREMARKET", AssetClass::Equity)]
#[case::commodity("XAUUSDT", "XAU", "COMMODITY", AssetClass::Commodity)]
fn test_parse_usdm_tradifi_perpetual(
#[case] raw_symbol: &str,
#[case] underlying: &str,
#[case] underlying_type: &str,
#[case] expected_asset_class: AssetClass,
) {
let symbol = sample_tradifi_usdm_symbol(raw_symbol, underlying, Some(underlying_type));
let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
let instrument = parse_usdm_instrument(&symbol, ts, ts).unwrap();
match instrument {
InstrumentAny::PerpetualContract(perp) => {
assert_eq!(perp.id.to_string(), format!("{raw_symbol}-PERP.BINANCE"));
assert_eq!(perp.raw_symbol.to_string(), raw_symbol);
assert_eq!(perp.underlying, Ustr::from(underlying));
assert_eq!(perp.asset_class, expected_asset_class);
assert_eq!(perp.base_currency, None);
assert_eq!(perp.quote_currency.code.as_str(), "USDT");
assert_eq!(perp.settlement_currency.code.as_str(), "USDT");
assert!(!perp.is_inverse);
assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
assert_eq!(perp.size_increment, Quantity::from_str("0.001").unwrap());
assert_eq!(
perp.min_notional,
Some(Money::new(5.0, perp.quote_currency)),
);
}
other => panic!("Expected PerpetualContract, was {other:?}"),
}
}
#[rstest]
#[case::missing(
None,
"Missing underlying type for TRADIFI_PERPETUAL symbol 'SNDKUSDT'"
)]
#[case::unknown(
Some("INDEX"),
"Unsupported underlying type 'INDEX' for TRADIFI_PERPETUAL symbol 'SNDKUSDT'"
)]
fn test_parse_usdm_tradifi_perpetual_rejects_invalid_underlying_type(
#[case] underlying_type: Option<&str>,
#[case] expected_error: &str,
) {
let symbol = sample_tradifi_usdm_symbol("SNDKUSDT", "SNDK", underlying_type);
let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
let error = parse_usdm_instrument(&symbol, ts, ts).unwrap_err();
assert_eq!(error.to_string(), expected_error);
}
#[rstest]
#[case::current_month(CONTRACT_TYPE_CURRENT_MONTH)]
#[case::next_month(CONTRACT_TYPE_NEXT_MONTH)]
#[case::current_quarter(CONTRACT_TYPE_CURRENT_QUARTER)]
#[case::next_quarter(CONTRACT_TYPE_NEXT_QUARTER)]
fn test_parse_usdm_delivery(#[case] contract_type: &str) {
let mut symbol = sample_usdm_symbol();
symbol.symbol = Ustr::from("BTCUSDT_260925");
symbol.contract_type = contract_type.to_string();
symbol.onboard_date = 1_774_598_400_000;
symbol.delivery_date = 1_790_323_200_000;
let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
let result = parse_usdm_instrument(&symbol, ts, ts).unwrap();
let InstrumentAny::CryptoFuture(future) = result else {
panic!("Expected CryptoFuture, was {result:?}");
};
assert_eq!(future.id.to_string(), "BTCUSDT_260925.BINANCE");
assert_eq!(future.raw_symbol.to_string(), "BTCUSDT_260925");
assert_eq!(future.underlying.code.as_str(), "BTC");
assert_eq!(future.quote_currency.code.as_str(), "USDT");
assert_eq!(future.settlement_currency.code.as_str(), "USDT");
assert!(!future.is_inverse);
assert_eq!(
future.activation_ns,
UnixNanos::from_millis(1_774_598_400_000)
);
assert_eq!(
future.expiration_ns,
UnixNanos::from_millis(1_790_323_200_000)
);
assert_eq!(future.price_increment, Price::from_str("0.10").unwrap());
assert_eq!(future.size_increment, Quantity::from_str("0.001").unwrap());
assert_eq!(future.multiplier, Quantity::from(1));
assert_eq!(
future.max_quantity,
Some(Quantity::from_str("1000").unwrap())
);
assert_eq!(
future.min_quantity,
Some(Quantity::from_str("0.001").unwrap())
);
assert_eq!(
future.min_notional,
Some(Money::new(5.0, future.quote_currency)),
);
assert_eq!(future.max_price, Some(Price::from_str("4529764").unwrap()));
assert_eq!(future.min_price, Some(Price::from_str("556.80").unwrap()));
}
#[rstest]
fn test_parse_usdm_unsupported_contract_type_fails() {
let mut symbol = sample_usdm_symbol();
symbol.contract_type = "UNKNOWN".to_string();
let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
let error = parse_usdm_instrument(&symbol, ts, ts).unwrap_err();
assert!(
error
.to_string()
.contains("Unsupported USD-M contract type")
);
}
#[rstest]
fn test_parse_missing_price_filter_fails() {
let mut symbol = sample_usdm_symbol();
symbol.filters = vec![json!({
"filterType": "LOT_SIZE",
"stepSize": "0.001",
"maxQty": "1000",
"minQty": "0.001"
})];
let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
let result = parse_usdm_instrument(&symbol, ts, ts);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Missing PRICE_FILTER")
);
}
#[rstest]
fn test_parse_coinm_perpetual() {
let symbol = sample_coinm_symbol();
let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
let result = parse_coinm_instrument(&symbol, ts, ts).unwrap();
match result {
InstrumentAny::CryptoPerpetual(perp) => {
assert_eq!(perp.id.to_string(), "BTCUSD_PERP.BINANCE");
assert_eq!(perp.raw_symbol.to_string(), "BTCUSD_PERP");
assert_eq!(perp.base_currency.code.as_str(), "BTC");
assert_eq!(perp.quote_currency.code.as_str(), "USD");
assert_eq!(perp.settlement_currency.code.as_str(), "BTC");
assert!(perp.is_inverse);
assert_eq!(perp.price_increment, Price::from_str("0.10").unwrap());
assert_eq!(perp.size_increment, Quantity::from_str("1").unwrap());
assert_eq!(
perp.min_notional,
Some(Money::new(1.0, perp.quote_currency)),
);
}
other => panic!("Expected CryptoPerpetual, was {other:?}"),
}
}
#[rstest]
fn test_parse_coinm_delivery_populates_fees() {
let mut symbol = sample_coinm_symbol();
symbol.symbol = Ustr::from("BTCUSD_260925");
symbol.contract_type = CONTRACT_TYPE_CURRENT_QUARTER.to_string();
let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
let instrument = parse_coinm_instrument_with_fees(
&symbol,
Some(dec!(0.00014)),
Some(dec!(0.00035)),
ts,
ts,
)
.unwrap();
let InstrumentAny::CryptoFuture(future) = instrument else {
panic!("expected CryptoFuture, was {instrument:?}");
};
assert_eq!(future.maker_fee, dec!(0.00014));
assert_eq!(future.taker_fee, dec!(0.00035));
}
#[rstest]
#[case::current_quarter(CONTRACT_TYPE_CURRENT_QUARTER)]
#[case::next_quarter(CONTRACT_TYPE_NEXT_QUARTER)]
fn test_parse_coinm_delivery(#[case] contract_type: &str) {
let mut symbol = sample_coinm_symbol();
symbol.symbol = Ustr::from("BTCUSD_260925");
symbol.contract_type = contract_type.to_string();
symbol.onboard_date = 1_774_598_400_000;
symbol.delivery_date = 1_790_323_200_000;
let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
let result = parse_coinm_instrument(&symbol, ts, ts).unwrap();
let InstrumentAny::CryptoFuture(future) = result else {
panic!("Expected CryptoFuture, was {result:?}");
};
assert_eq!(future.id.to_string(), "BTCUSD_260925.BINANCE");
assert_eq!(future.raw_symbol.to_string(), "BTCUSD_260925");
assert_eq!(future.underlying.code.as_str(), "BTC");
assert_eq!(future.quote_currency.code.as_str(), "USD");
assert_eq!(future.settlement_currency.code.as_str(), "BTC");
assert!(future.is_inverse);
assert_eq!(
future.activation_ns,
UnixNanos::from_millis(1_774_598_400_000)
);
assert_eq!(
future.expiration_ns,
UnixNanos::from_millis(1_790_323_200_000)
);
assert_eq!(future.price_increment, Price::from_str("0.10").unwrap());
assert_eq!(future.size_increment, Quantity::from_str("1").unwrap());
assert_eq!(future.multiplier, Quantity::from(100));
assert_eq!(
future.max_quantity,
Some(Quantity::from_str("1000").unwrap())
);
assert_eq!(future.min_quantity, Some(Quantity::from_str("1").unwrap()));
assert_eq!(future.max_price, Some(Price::from_str("1000000").unwrap()));
assert_eq!(future.min_price, Some(Price::from_str("0.10").unwrap()));
}
#[rstest]
fn test_parse_coinm_month_contract_fails() {
let mut symbol = sample_coinm_symbol();
symbol.contract_type = CONTRACT_TYPE_CURRENT_MONTH.to_string();
let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
let error = parse_coinm_instrument(&symbol, ts, ts).unwrap_err();
assert!(
error
.to_string()
.contains("Unsupported COIN-M contract type")
);
}
#[rstest]
fn test_parse_spot_instrument_sbe() {
let symbol = sample_spot_symbol_sbe();
let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
let result = parse_spot_instrument_sbe(&symbol, ts, ts).unwrap();
match result {
InstrumentAny::CurrencyPair(pair) => {
assert_eq!(pair.id.to_string(), "ETHUSDT.BINANCE");
assert_eq!(pair.raw_symbol.to_string(), "ETHUSDT");
assert_eq!(pair.base_currency.code.as_str(), "ETH");
assert_eq!(pair.quote_currency.code.as_str(), "USDT");
assert_eq!(pair.price_increment, Price::from_str("0.01").unwrap());
assert_eq!(pair.size_increment, Quantity::from_str("0.0001").unwrap());
}
other => panic!("Expected CurrencyPair, was {other:?}"),
}
}
#[rstest]
fn test_parse_spot_instrument_populates_fees() {
let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
let instrument = parse_spot_instrument_sbe_with_fees(
&sample_spot_symbol_sbe(),
Some(dec!(0.0008)),
Some(dec!(0.0011)),
ts,
ts,
)
.unwrap();
let InstrumentAny::CurrencyPair(pair) = instrument else {
panic!("expected CurrencyPair, was {instrument:?}");
};
assert_eq!(pair.maker_fee, dec!(0.0008));
assert_eq!(pair.taker_fee, dec!(0.0011));
}
#[rstest]
fn test_parse_spot_trades_sbe() {
let instrument = sample_spot_instrument();
let trades = BinanceTrades {
price_exponent: -2,
qty_exponent: -4,
trades: vec![
crate::spot::http::models::BinanceTrade {
id: 1,
price_mantissa: 12_345,
qty_mantissa: 25_000,
quote_qty_mantissa: 0,
time: 1_700_000_000_000_000,
is_buyer_maker: false,
is_best_match: true,
},
crate::spot::http::models::BinanceTrade {
id: 2,
price_mantissa: 12_340,
qty_mantissa: 10_000,
quote_qty_mantissa: 0,
time: 1_700_000_000_500_000,
is_buyer_maker: true,
is_best_match: true,
},
],
};
let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
let result = parse_spot_trades_sbe(&trades, &instrument, ts_init).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].instrument_id, instrument.id());
assert_eq!(result[0].price.as_f64(), 123.45);
assert_eq!(result[0].size.as_f64(), 2.5);
assert_eq!(result[0].aggressor_side, AggressorSide::Buyer);
assert_eq!(result[0].trade_id, TradeId::new("1"));
assert_eq!(
result[0].ts_event,
UnixNanos::from(1_700_000_000_000_000_000u64)
);
assert_eq!(result[0].ts_init, ts_init);
assert_eq!(result[1].aggressor_side, AggressorSide::Seller);
}
#[rstest]
fn test_parse_order_status_report_sbe() {
let instrument = sample_spot_instrument();
let order = BinanceOrderResponse {
price_exponent: -2,
qty_exponent: -4,
order_id: 42,
order_list_id: Some(77),
price_mantissa: 12_345,
orig_qty_mantissa: 25_000,
executed_qty_mantissa: 10_000,
cummulative_quote_qty_mantissa: 123_450_000,
status: SbeOrderStatus::PartiallyFilled,
time_in_force: SbeTimeInForce::Gtc,
order_type: SbeOrderType::LimitMaker,
side: SbeOrderSide::Buy,
stop_price_mantissa: None,
iceberg_qty_mantissa: None,
time: 1_700_000_000_000_000,
update_time: 1_700_000_000_100_000,
is_working: true,
working_time: Some(1_700_000_000_050_000),
orig_quote_order_qty_mantissa: 0,
self_trade_prevention_mode:
crate::spot::sbe::spot::self_trade_prevention_mode::SelfTradePreventionMode::None,
client_order_id: "client-123".to_string(),
symbol: "ETHUSDT".to_string(),
expiry_reason: None,
};
let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
let report = parse_order_status_report_sbe(
&order,
sample_account_id(),
&instrument,
BINANCE_NAUTILUS_SPOT_BROKER_ID,
ts_init,
)
.unwrap();
assert_eq!(report.account_id, sample_account_id());
assert_eq!(report.instrument_id, instrument.id());
assert_eq!(
report.client_order_id,
Some(ClientOrderId::new("client-123"))
);
assert_eq!(report.venue_order_id, VenueOrderId::new("42"));
assert_eq!(report.order_side, OrderSide::Buy);
assert_eq!(report.order_type, OrderType::Limit);
assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
assert_eq!(report.quantity.as_f64(), 2.5);
assert_eq!(report.filled_qty.as_f64(), 1.0);
assert_eq!(report.order_list_id, Some(OrderListId::new("77")));
assert_eq!(report.price, Some(Price::new(123.45, 2)));
assert_eq!(report.avg_px.unwrap().to_string(), "123.45");
assert!(report.post_only);
assert_eq!(
report.ts_accepted,
UnixNanos::from(1_700_000_000_000_000_000u64)
);
assert_eq!(
report.ts_last,
UnixNanos::from(1_700_000_000_100_000_000u64)
);
assert_eq!(report.ts_init, ts_init);
}
#[rstest]
fn test_parse_new_order_response_sbe() {
let instrument = sample_spot_instrument();
let response = BinanceNewOrderResponse {
price_exponent: -2,
qty_exponent: -4,
order_id: 99,
order_list_id: Some(7),
transact_time: 1_700_000_000_000_000,
price_mantissa: 12_100,
orig_qty_mantissa: 20_000,
executed_qty_mantissa: 5_000,
cummulative_quote_qty_mantissa: 60_500_000,
status: SbeOrderStatus::New,
time_in_force: SbeTimeInForce::Gtc,
order_type: SbeOrderType::StopLossLimit,
side: SbeOrderSide::Sell,
stop_price_mantissa: Some(12_000),
working_time: Some(1_700_000_000_000_000),
self_trade_prevention_mode:
crate::spot::sbe::spot::self_trade_prevention_mode::SelfTradePreventionMode::None,
client_order_id: "client-456".to_string(),
symbol: "ETHUSDT".to_string(),
fills: vec![],
expiry_reason: None,
};
let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
let report = parse_new_order_response_sbe(
&response,
sample_account_id(),
&instrument,
BINANCE_NAUTILUS_SPOT_BROKER_ID,
ts_init,
)
.unwrap();
assert_eq!(report.account_id, sample_account_id());
assert_eq!(report.instrument_id, instrument.id());
assert_eq!(
report.client_order_id,
Some(ClientOrderId::new("client-456"))
);
assert_eq!(report.venue_order_id, VenueOrderId::new("99"));
assert_eq!(report.order_side, OrderSide::Sell);
assert_eq!(report.order_type, OrderType::StopLimit);
assert_eq!(report.order_status, OrderStatus::Accepted);
assert_eq!(report.quantity.as_f64(), 2.0);
assert_eq!(report.filled_qty.as_f64(), 0.5);
assert_eq!(report.order_list_id, Some(OrderListId::new("7")));
assert_eq!(report.price, Some(Price::new(121.0, 2)));
assert_eq!(report.trigger_price, Some(Price::new(120.0, 2)));
assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
assert_eq!(report.avg_px, Some(dec!(121.00)));
assert_eq!(report.avg_px.unwrap().to_string(), "121.00");
assert!(!report.post_only);
assert_eq!(
report.ts_accepted,
UnixNanos::from(1_700_000_000_000_000_000u64)
);
assert_eq!(
report.ts_last,
UnixNanos::from(1_700_000_000_000_000_000u64)
);
}
#[rstest]
fn test_parse_fill_report_sbe() {
let instrument = sample_spot_instrument();
let trade = BinanceAccountTrade {
price_exponent: -2,
qty_exponent: -4,
commission_exponent: -8,
id: 123,
order_id: 456,
order_list_id: None,
price_mantissa: 12_345,
qty_mantissa: 25_000,
quote_qty_mantissa: 0,
commission_mantissa: 10_000,
time: 1_700_000_000_000_000,
is_buyer: false,
is_maker: true,
is_best_match: true,
symbol: "ETHUSDT".to_string(),
commission_asset: "USDT".to_string(),
};
let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
let report = parse_fill_report_sbe(
&trade,
sample_account_id(),
&instrument,
Currency::from("USDT"),
ts_init,
)
.unwrap();
assert_eq!(report.account_id, sample_account_id());
assert_eq!(report.instrument_id, instrument.id());
assert_eq!(report.venue_order_id, VenueOrderId::new("456"));
assert_eq!(report.trade_id, TradeId::new("123"));
assert_eq!(report.order_side, OrderSide::Sell);
assert_eq!(report.last_qty.as_f64(), 2.5);
assert_eq!(report.last_px.as_f64(), 123.45);
assert_eq!(report.liquidity_side, LiquiditySide::Maker);
assert_eq!(report.commission.as_f64(), 0.0001);
assert_eq!(
report.ts_event,
UnixNanos::from(1_700_000_000_000_000_000u64)
);
assert_eq!(report.ts_init, ts_init);
assert!(report.client_order_id.is_none());
}
#[rstest]
fn test_parse_klines_to_bars() {
use nautilus_model::enums::{AggregationSource, PriceType};
let instrument = sample_spot_instrument();
let bar_type = BarType::new(
instrument.id(),
BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
AggregationSource::External,
);
let klines = BinanceKlines {
price_exponent: -2,
qty_exponent: -4,
klines: vec![crate::spot::http::models::BinanceKline {
open_time: 1_700_000_000_000_000,
open_price: 12_000,
high_price: 12_500,
low_price: 11_900,
close_price: 12_345,
volume: 1_234_500_i128.to_le_bytes(),
close_time: 1_700_000_059_999_000,
quote_volume: 777_788_i128.to_le_bytes(),
num_trades: 100,
taker_buy_base_volume: 56_789_i128.to_le_bytes(),
taker_buy_quote_volume: 9_901_i128.to_le_bytes(),
}],
};
let ts_init = UnixNanos::from(1_700_000_001_000_000_000u64);
let bars = parse_klines_to_binance_bars(&klines, bar_type, &instrument, ts_init).unwrap();
assert_eq!(bars.len(), 1);
assert_eq!(bars[0].bar_type, bar_type);
assert_eq!(bars[0].open, Price::new(120.0, 2));
assert_eq!(bars[0].high, Price::new(125.0, 2));
assert_eq!(bars[0].low, Price::new(119.0, 2));
assert_eq!(bars[0].close, Price::new(123.45, 2));
assert_eq!(bars[0].volume, Quantity::new(123.45, 4));
assert_eq!(bars[0].quote_volume, dec!(7777.88));
assert_eq!(bars[0].count, 100);
assert_eq!(bars[0].taker_buy_base_volume, dec!(5.6789));
assert_eq!(bars[0].taker_buy_quote_volume, dec!(99.01));
assert_eq!(
bars[0].ts_event,
UnixNanos::from(1_700_000_059_999_000_000u64)
);
assert_eq!(bars[0].ts_init, ts_init);
}
mod bar_spec_tests {
use std::num::NonZeroUsize;
use nautilus_model::{
data::BarSpecification,
enums::{BarAggregation, PriceType},
};
use super::*;
use crate::common::enums::BinanceKlineInterval;
fn make_bar_spec(step: usize, aggregation: BarAggregation) -> BarSpecification {
BarSpecification {
step: NonZeroUsize::new(step).unwrap(),
aggregation,
price_type: PriceType::Last,
}
}
#[rstest]
#[case(1, BarAggregation::Second, BinanceKlineInterval::Second1)]
#[case(1, BarAggregation::Minute, BinanceKlineInterval::Minute1)]
#[case(3, BarAggregation::Minute, BinanceKlineInterval::Minute3)]
#[case(5, BarAggregation::Minute, BinanceKlineInterval::Minute5)]
#[case(15, BarAggregation::Minute, BinanceKlineInterval::Minute15)]
#[case(30, BarAggregation::Minute, BinanceKlineInterval::Minute30)]
#[case(1, BarAggregation::Hour, BinanceKlineInterval::Hour1)]
#[case(2, BarAggregation::Hour, BinanceKlineInterval::Hour2)]
#[case(4, BarAggregation::Hour, BinanceKlineInterval::Hour4)]
#[case(6, BarAggregation::Hour, BinanceKlineInterval::Hour6)]
#[case(8, BarAggregation::Hour, BinanceKlineInterval::Hour8)]
#[case(12, BarAggregation::Hour, BinanceKlineInterval::Hour12)]
#[case(1, BarAggregation::Day, BinanceKlineInterval::Day1)]
#[case(3, BarAggregation::Day, BinanceKlineInterval::Day3)]
#[case(1, BarAggregation::Week, BinanceKlineInterval::Week1)]
#[case(1, BarAggregation::Month, BinanceKlineInterval::Month1)]
fn test_bar_spec_to_binance_interval(
#[case] step: usize,
#[case] aggregation: BarAggregation,
#[case] expected: BinanceKlineInterval,
) {
let bar_spec = make_bar_spec(step, aggregation);
let result = bar_spec_to_binance_interval(bar_spec).unwrap();
assert_eq!(result, expected);
}
#[rstest]
fn test_unsupported_second_interval() {
let bar_spec = make_bar_spec(2, BarAggregation::Second);
let result = bar_spec_to_binance_interval(bar_spec);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Unsupported second interval")
);
}
#[rstest]
fn test_unsupported_minute_interval() {
let bar_spec = make_bar_spec(7, BarAggregation::Minute);
let result = bar_spec_to_binance_interval(bar_spec);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Unsupported minute interval")
);
}
#[rstest]
fn test_unsupported_aggregation() {
let bar_spec = make_bar_spec(100, BarAggregation::Tick);
let result = bar_spec_to_binance_interval(bar_spec);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Unsupported bar aggregation")
);
}
}
mod sbe_precision_tests {
use super::*;
use crate::spot::http::models::{BinanceLotSizeFilterSbe, BinancePriceFilterSbe};
#[rstest]
#[case::precision_0(100_000_000, -8, 0)]
#[case::precision_1(10_000_000, -8, 1)]
#[case::precision_2(1_000_000, -8, 2)]
#[case::precision_3(100_000, -8, 3)]
#[case::precision_4(10_000, -8, 4)]
#[case::precision_5(1_000, -8, 5)]
#[case::precision_6(100, -8, 6)]
#[case::precision_7(10, -8, 7)]
#[case::precision_8(1, -8, 8)]
fn test_sbe_mantissa_precision(
#[case] mantissa: i64,
#[case] exponent: i8,
#[case] expected: u8,
) {
let result = sbe_mantissa_precision(mantissa, exponent);
assert_eq!(
result, expected,
"mantissa={mantissa}, exponent={exponent}: expected {expected}, was {result}"
);
}
#[rstest]
fn test_sbe_mantissa_precision_zero_mantissa() {
assert_eq!(sbe_mantissa_precision(0, -8), 0);
}
#[rstest]
fn test_sbe_mantissa_precision_positive_exponent() {
assert_eq!(sbe_mantissa_precision(1, 0), 0);
assert_eq!(sbe_mantissa_precision(5, 2), 0);
}
#[rstest]
fn test_parse_sbe_price_filter_ethusdc() {
let filter = BinancePriceFilterSbe {
price_exponent: -8,
min_price: 1_000_000,
max_price: 100_000_000_000_000,
tick_size: 1_000_000,
};
let (tick_size, max_price, min_price) = parse_sbe_price_filter(&filter).unwrap();
let max_price = max_price.unwrap();
let min_price = min_price.unwrap();
assert_eq!(tick_size.precision, 2, "tick_size precision");
assert_eq!(tick_size.as_decimal(), dec!(0.01));
assert_eq!(max_price.precision, 2);
assert_eq!(max_price.as_decimal(), dec!(1000000.00));
assert_eq!(min_price.precision, 2);
assert_eq!(min_price.as_decimal(), dec!(0.01));
}
#[rstest]
fn test_parse_sbe_price_filter_shibusdt() {
let filter = BinancePriceFilterSbe {
price_exponent: -8,
min_price: 1,
max_price: 100_000_000,
tick_size: 1,
};
let (tick_size, _, _) = parse_sbe_price_filter(&filter).unwrap();
assert_eq!(tick_size.precision, 8);
assert_eq!(tick_size.as_decimal(), dec!(0.00000001));
}
#[rstest]
fn test_parse_sbe_lot_size_filter_ethusdc() {
let filter = BinanceLotSizeFilterSbe {
qty_exponent: -8,
min_qty: 10_000,
max_qty: 900_000_000_000,
step_size: 10_000,
};
let (step_size, max_qty, min_qty) = parse_sbe_lot_size_filter(&filter).unwrap();
let max_qty = max_qty.unwrap();
let min_qty = min_qty.unwrap();
assert_eq!(step_size.precision, 4, "step_size precision");
assert_eq!(step_size.as_decimal(), dec!(0.0001));
assert_eq!(min_qty.precision, 4);
assert_eq!(min_qty.as_decimal(), dec!(0.0001));
assert_eq!(max_qty.precision, 4);
assert_eq!(max_qty.as_decimal(), dec!(9000.0000));
}
}
}