From adeb203cb1e4829de8386d4d31a2212ccbc19171 Mon Sep 17 00:00:00 2001 From: "Mt. Shit" <284372560+mtshit@users.noreply.github.com> Date: Fri, 15 May 2026 14:36:50 +0000 Subject: [PATCH 1/8] binance: preserve exchangeInfo symbol precision fields --- .../binance/src/convert/market_data.rs | 61 +++++++++++++++++++ crates/mkt-types/src/market.rs | 6 ++ 2 files changed, 67 insertions(+) diff --git a/crates/exchanges/binance/src/convert/market_data.rs b/crates/exchanges/binance/src/convert/market_data.rs index 6bc99fb..764005b 100644 --- a/crates/exchanges/binance/src/convert/market_data.rs +++ b/crates/exchanges/binance/src/convert/market_data.rs @@ -243,6 +243,9 @@ pub(crate) fn market_info_from_exchange_symbol( .quote_asset .ok_or_else(|| crate::error::missing_field(operation, "quoteAsset"))?, ) + .base_asset_precision(symbol_definition.base_asset_precision) + .quote_precision(symbol_definition.quote_precision) + .quote_asset_precision(symbol_definition.quote_asset_precision) .trading_permissions(trading_permissions) .trading_constraints(trading_constraints) .build() @@ -373,6 +376,64 @@ pub(crate) fn trades_from_recent_response( .collect() } +#[cfg(test)] +mod tests { + use super::market_info_from_exchange_symbol; + use binance_sdk::spot::rest_api::{ + ExchangeInfoResponseSymbolsInner, ExchangeInfoSymbolStatusEnum, LotSizeFilter, + PriceFilter, SymbolFilters, + }; + use rust_decimal::Decimal; + use std::str::FromStr; + + #[test] + fn market_info_preserves_symbol_precision_fields() { + let symbol = ExchangeInfoResponseSymbolsInner { + symbol: Some(String::from("BTCUSDT")), + status: Some(ExchangeInfoSymbolStatusEnum::Trading.as_str().to_string()), + base_asset: Some(String::from("BTC")), + base_asset_precision: Some(8), + quote_asset: Some(String::from("USDT")), + quote_precision: Some(8), + quote_asset_precision: Some(8), + order_types: Some(vec![String::from("MARKET")]), + quote_order_qty_market_allowed: Some(true), + is_spot_trading_allowed: Some(true), + filters: Some(vec![ + SymbolFilters::PriceFilter(Box::new(PriceFilter { + filter_type: Some(String::from("PRICE_FILTER")), + price_exponent: None, + min_price: Some(String::from("0.01000000")), + max_price: Some(String::from("1000000.00000000")), + tick_size: Some(String::from("0.01000000")), + })), + SymbolFilters::LotSize(Box::new(LotSizeFilter { + filter_type: Some(String::from("LOT_SIZE")), + qty_exponent: None, + min_qty: Some(String::from("0.00001000")), + max_qty: Some(String::from("9000.00000000")), + step_size: Some(String::from("0.00001000")), + })), + ]), + ..ExchangeInfoResponseSymbolsInner::new() + }; + + let market = market_info_from_exchange_symbol(symbol, "spot.exchange_info") + .expect("market should convert"); + + assert_eq!(market.base_asset_precision, Some(8)); + assert_eq!(market.quote_precision, Some(8)); + assert_eq!(market.quote_asset_precision, Some(8)); + assert_eq!( + market + .trading_constraints + .price_filter + .and_then(|filter| filter.tick_size), + Some(Decimal::from_str("0.01").expect("decimal")) + ); + } +} + pub(crate) fn klines_from_rows( symbol: &Symbol, interval: KlineInterval, diff --git a/crates/mkt-types/src/market.rs b/crates/mkt-types/src/market.rs index 32c9ecc..f8c1a1d 100644 --- a/crates/mkt-types/src/market.rs +++ b/crates/mkt-types/src/market.rs @@ -394,6 +394,12 @@ pub struct MarketInfo { pub base_asset: String, pub quote_asset: String, #[builder(default)] + pub base_asset_precision: Option, + #[builder(default)] + pub quote_precision: Option, + #[builder(default)] + pub quote_asset_precision: Option, + #[builder(default)] pub trading_permissions: TradingPermissions, #[builder(default)] pub trading_constraints: TradingConstraints, From cb31a2ec07ccee22152fcd43dd9a90470055731c Mon Sep 17 00:00:00 2001 From: "Mt. Shit" <284372560+mtshit@users.noreply.github.com> Date: Fri, 15 May 2026 14:40:36 +0000 Subject: [PATCH 2/8] docs: clarify MarketInfo precision fields --- .../binance/src/convert/market_data.rs | 116 +++++++++--------- crates/mkt-types/src/market.rs | 21 ++++ 2 files changed, 79 insertions(+), 58 deletions(-) diff --git a/crates/exchanges/binance/src/convert/market_data.rs b/crates/exchanges/binance/src/convert/market_data.rs index 764005b..a0cf02b 100644 --- a/crates/exchanges/binance/src/convert/market_data.rs +++ b/crates/exchanges/binance/src/convert/market_data.rs @@ -376,64 +376,6 @@ pub(crate) fn trades_from_recent_response( .collect() } -#[cfg(test)] -mod tests { - use super::market_info_from_exchange_symbol; - use binance_sdk::spot::rest_api::{ - ExchangeInfoResponseSymbolsInner, ExchangeInfoSymbolStatusEnum, LotSizeFilter, - PriceFilter, SymbolFilters, - }; - use rust_decimal::Decimal; - use std::str::FromStr; - - #[test] - fn market_info_preserves_symbol_precision_fields() { - let symbol = ExchangeInfoResponseSymbolsInner { - symbol: Some(String::from("BTCUSDT")), - status: Some(ExchangeInfoSymbolStatusEnum::Trading.as_str().to_string()), - base_asset: Some(String::from("BTC")), - base_asset_precision: Some(8), - quote_asset: Some(String::from("USDT")), - quote_precision: Some(8), - quote_asset_precision: Some(8), - order_types: Some(vec![String::from("MARKET")]), - quote_order_qty_market_allowed: Some(true), - is_spot_trading_allowed: Some(true), - filters: Some(vec![ - SymbolFilters::PriceFilter(Box::new(PriceFilter { - filter_type: Some(String::from("PRICE_FILTER")), - price_exponent: None, - min_price: Some(String::from("0.01000000")), - max_price: Some(String::from("1000000.00000000")), - tick_size: Some(String::from("0.01000000")), - })), - SymbolFilters::LotSize(Box::new(LotSizeFilter { - filter_type: Some(String::from("LOT_SIZE")), - qty_exponent: None, - min_qty: Some(String::from("0.00001000")), - max_qty: Some(String::from("9000.00000000")), - step_size: Some(String::from("0.00001000")), - })), - ]), - ..ExchangeInfoResponseSymbolsInner::new() - }; - - let market = market_info_from_exchange_symbol(symbol, "spot.exchange_info") - .expect("market should convert"); - - assert_eq!(market.base_asset_precision, Some(8)); - assert_eq!(market.quote_precision, Some(8)); - assert_eq!(market.quote_asset_precision, Some(8)); - assert_eq!( - market - .trading_constraints - .price_filter - .and_then(|filter| filter.tick_size), - Some(Decimal::from_str("0.01").expect("decimal")) - ); - } -} - pub(crate) fn klines_from_rows( symbol: &Symbol, interval: KlineInterval, @@ -519,3 +461,61 @@ pub(crate) fn klines_from_rows( }) .collect() } + +#[cfg(test)] +mod tests { + use super::market_info_from_exchange_symbol; + use binance_sdk::spot::rest_api::{ + ExchangeInfoResponseSymbolsInner, ExchangeInfoSymbolStatusEnum, LotSizeFilter, PriceFilter, + SymbolFilters, + }; + use rust_decimal::Decimal; + use std::str::FromStr; + + #[test] + fn market_info_preserves_symbol_precision_fields() { + let symbol = ExchangeInfoResponseSymbolsInner { + symbol: Some(String::from("BTCUSDT")), + status: Some(ExchangeInfoSymbolStatusEnum::Trading.as_str().to_string()), + base_asset: Some(String::from("BTC")), + base_asset_precision: Some(8), + quote_asset: Some(String::from("USDT")), + quote_precision: Some(8), + quote_asset_precision: Some(8), + order_types: Some(vec![String::from("MARKET")]), + quote_order_qty_market_allowed: Some(true), + is_spot_trading_allowed: Some(true), + filters: Some(vec![ + SymbolFilters::PriceFilter(Box::new(PriceFilter { + filter_type: Some(String::from("PRICE_FILTER")), + price_exponent: None, + min_price: Some(String::from("0.01000000")), + max_price: Some(String::from("1000000.00000000")), + tick_size: Some(String::from("0.01000000")), + })), + SymbolFilters::LotSize(Box::new(LotSizeFilter { + filter_type: Some(String::from("LOT_SIZE")), + qty_exponent: None, + min_qty: Some(String::from("0.00001000")), + max_qty: Some(String::from("9000.00000000")), + step_size: Some(String::from("0.00001000")), + })), + ]), + ..ExchangeInfoResponseSymbolsInner::new() + }; + + let market = market_info_from_exchange_symbol(symbol, "spot.exchange_info") + .expect("market should convert"); + + assert_eq!(market.base_asset_precision, Some(8)); + assert_eq!(market.quote_precision, Some(8)); + assert_eq!(market.quote_asset_precision, Some(8)); + assert_eq!( + market + .trading_constraints + .price_filter + .and_then(|filter| filter.tick_size), + Some(Decimal::from_str("0.01").expect("decimal")) + ); + } +} diff --git a/crates/mkt-types/src/market.rs b/crates/mkt-types/src/market.rs index f8c1a1d..3614138 100644 --- a/crates/mkt-types/src/market.rs +++ b/crates/mkt-types/src/market.rs @@ -388,20 +388,41 @@ impl TradingConstraints { #[derive(Debug, Clone, PartialEq, Builder)] #[builder(pattern = "owned", setter(into))] pub struct MarketInfo { + /// Canonical exchange identifier for the venue that exposed this market. pub exchange_id: ExchangeId, + /// Unified symbol descriptor, including market kind and venue-specific symbol string. pub symbol: Symbol, + /// Current lifecycle status reported by the exchange. pub status: MarketStatus, + /// Venue-reported base asset code for this market. pub base_asset: String, + /// Venue-reported quote asset code for this market. pub quote_asset: String, #[builder(default)] + /// Exchange-reported precision for base-asset quantities at the symbol level, when available. + /// + /// This is raw venue metadata. It may differ from executable step-size filters and should not + /// be treated as a substitute for lot-size validation. pub base_asset_precision: Option, #[builder(default)] + /// Exchange-reported precision for quote-denominated values at the symbol level, when + /// available. + /// + /// On venues such as Binance spot, this is the most relevant symbol-level precision hint for + /// quote-sized order entry like `quoteOrderQty`. pub quote_precision: Option, #[builder(default)] + /// Exchange-reported precision for the quote asset at the symbol level, when available. + /// + /// Some venues expose both `quote_precision` and `quote_asset_precision`. When they differ, + /// adapter code should prefer the field that the venue documents for order-entry precision and + /// treat this field as auxiliary metadata or fallback. pub quote_asset_precision: Option, #[builder(default)] + /// Venue capabilities describing which order types, sides, and quantity modes are supported. pub trading_permissions: TradingPermissions, #[builder(default)] + /// Venue execution constraints such as price filters, lot sizes, and notional limits. pub trading_constraints: TradingConstraints, } From 8d6b7500b46407fa6e9abd6901f82bca81391e75 Mon Sep 17 00:00:00 2001 From: "Mt. Shit" <284372560+mtshit@users.noreply.github.com> Date: Sun, 17 May 2026 15:07:04 +0000 Subject: [PATCH 3/8] Add market helper methods --- crates/mkt-types/src/market.rs | 331 +++++++++++++++++++++++ crates/mkt-types/src/trading.rs | 29 +- crates/mkt-types/tests/market_helpers.rs | 212 +++++++++++++++ 3 files changed, 571 insertions(+), 1 deletion(-) create mode 100644 crates/mkt-types/tests/market_helpers.rs diff --git a/crates/mkt-types/src/market.rs b/crates/mkt-types/src/market.rs index 3614138..225a35f 100644 --- a/crates/mkt-types/src/market.rs +++ b/crates/mkt-types/src/market.rs @@ -341,6 +341,18 @@ impl QuantityModeSupport { pub fn builder() -> QuantityModeSupportBuilder { QuantityModeSupportBuilder::default() } + + fn supports_order_type(&self, order_type: crate::OrderType) -> bool { + self.order_types.is_empty() || self.order_types.contains(&order_type) + } + + fn supports_side(&self, side: crate::OrderSide) -> bool { + self.sides.is_empty() || self.sides.contains(&side) + } + + fn supports(&self, order_type: crate::OrderType, side: crate::OrderSide) -> bool { + self.supports_order_type(order_type) && self.supports_side(side) + } } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -360,6 +372,26 @@ impl TradingPermissions { pub fn builder() -> TradingPermissionsBuilder { TradingPermissionsBuilder::default() } + + pub fn allows_spot_order_entry(&self) -> bool { + self.spot_order_entry_allowed.unwrap_or(true) + } + + pub fn supports_order_type(&self, order_type: crate::OrderType) -> bool { + self.supported_order_types.contains(&order_type) + } + + pub fn supports_quantity_mode( + &self, + mode: MarketQuantityMode, + order_type: crate::OrderType, + side: crate::OrderSide, + ) -> bool { + self.quantity_mode_support + .iter() + .filter(|support| support.mode == mode) + .any(|support| support.supports(order_type, side)) + } } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -381,6 +413,15 @@ impl TradingConstraints { pub fn builder() -> TradingConstraintsBuilder { TradingConstraintsBuilder::default() } + + fn lot_size_for(&self, order_type: crate::OrderType) -> Option<&LotSizeFilter> { + match order_type { + crate::OrderType::Market | crate::OrderType::StopMarket => { + self.market_lot_size.as_ref().or(self.lot_size.as_ref()) + } + _ => self.lot_size.as_ref().or(self.market_lot_size.as_ref()), + } + } } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -430,4 +471,294 @@ impl MarketInfo { pub fn builder() -> MarketInfoBuilder { MarketInfoBuilder::default() } + + pub fn is_trading(&self) -> bool { + matches!(self.status, MarketStatus::Trading) + } + + pub fn allows_spot_order_entry(&self) -> bool { + self.trading_permissions.allows_spot_order_entry() + } + + pub fn tick_size(&self) -> Option { + self.trading_constraints + .price_filter + .as_ref() + .and_then(|filter| filter.tick_size) + .filter(|value| *value > Decimal::ZERO) + } + + pub fn quote_scale(&self) -> Option { + self.quote_precision + .and_then(|scale| u32::try_from(scale).ok()) + .or_else(|| { + self.quote_asset_precision + .and_then(|scale| u32::try_from(scale).ok()) + }) + } + + pub fn min_notional_or(&self, fallback: Decimal) -> Decimal { + self.trading_constraints + .notional + .as_ref() + .and_then(|constraints| constraints.min_notional) + .filter(|value| *value > Decimal::ZERO) + .unwrap_or(fallback) + .max(fallback) + } + + pub fn lot_size_for(&self, order_type: crate::OrderType) -> Option<&LotSizeFilter> { + self.trading_constraints.lot_size_for(order_type) + } + + pub fn min_quantity(&self, order_type: crate::OrderType) -> Option { + self.positive_lot_size_value(order_type, |filter| filter.min_quantity) + } + + pub fn max_quantity(&self, order_type: crate::OrderType) -> Option { + self.positive_lot_size_value(order_type, |filter| filter.max_quantity) + } + + pub fn step_size(&self, order_type: crate::OrderType) -> Option { + self.positive_lot_size_value(order_type, |filter| filter.step_size) + } + + fn positive_lot_size_value( + &self, + order_type: crate::OrderType, + value: impl Fn(&LotSizeFilter) -> Option, + ) -> Option { + let trading_constraints = &self.trading_constraints; + let (primary, fallback) = match order_type { + crate::OrderType::Market | crate::OrderType::StopMarket => ( + trading_constraints.market_lot_size.as_ref(), + trading_constraints.lot_size.as_ref(), + ), + _ => ( + trading_constraints.lot_size.as_ref(), + trading_constraints.market_lot_size.as_ref(), + ), + }; + + primary + .and_then(&value) + .filter(|current| *current > Decimal::ZERO) + .or_else(|| { + fallback + .and_then(value) + .filter(|current| *current > Decimal::ZERO) + }) + } +} + +#[cfg(test)] +mod tests { + use super::{ + LotSizeFilter, MarketInfo, MarketQuantityMode, MarketStatus, NotionalConstraints, + PriceFilter, QuantityModeSupport, TradingConstraints, TradingPermissions, + }; + use crate::{ExchangeId, KnownExchange, OrderSide, OrderType, Symbol}; + use rust_decimal::Decimal; + use std::str::FromStr; + + fn decimal(value: &str) -> Decimal { + Decimal::from_str(value).expect("test decimal must be valid") + } + + fn lot_size(min_quantity: &str, max_quantity: &str, step_size: &str) -> LotSizeFilter { + LotSizeFilter::builder() + .min_quantity(decimal(min_quantity)) + .max_quantity(decimal(max_quantity)) + .step_size(decimal(step_size)) + .build() + .expect("lot size must build") + } + + fn market_info( + status: MarketStatus, + trading_permissions: TradingPermissions, + trading_constraints: TradingConstraints, + ) -> MarketInfo { + MarketInfo::builder() + .exchange_id(ExchangeId::from(KnownExchange::Binance)) + .symbol(Symbol::spot("BTCUSDT")) + .status(status) + .base_asset("BTC") + .quote_asset("USDT") + .quote_precision(8) + .quote_asset_precision(6) + .trading_permissions(trading_permissions) + .trading_constraints(trading_constraints) + .build() + .expect("market info must build") + } + + #[test] + fn trading_permissions_helpers_use_expected_defaults() { + let unknown = TradingPermissions::default(); + assert!(unknown.allows_spot_order_entry()); + assert!(!unknown.supports_order_type(OrderType::Limit)); + assert!(!unknown.supports_quantity_mode( + MarketQuantityMode::Base, + OrderType::Limit, + OrderSide::Buy + )); + + let permissions = TradingPermissions::builder() + .spot_order_entry_allowed(Some(false)) + .supported_order_types([OrderType::Limit, OrderType::Market]) + .quantity_mode_support(vec![ + QuantityModeSupport::builder() + .mode(MarketQuantityMode::Base) + .order_types([OrderType::Limit]) + .sides([OrderSide::Buy, OrderSide::Sell]) + .build() + .expect("base support must build"), + QuantityModeSupport::builder() + .mode(MarketQuantityMode::Quote) + .order_types([OrderType::Market]) + .sides([OrderSide::Buy]) + .build() + .expect("quote support must build"), + ]) + .build() + .expect("permissions must build"); + + assert!(!permissions.allows_spot_order_entry()); + assert!(permissions.supports_order_type(OrderType::Limit)); + assert!(!permissions.supports_order_type(OrderType::PostOnly)); + assert!(permissions.supports_quantity_mode( + MarketQuantityMode::Base, + OrderType::Limit, + OrderSide::Buy + )); + assert!(!permissions.supports_quantity_mode( + MarketQuantityMode::Base, + OrderType::Market, + OrderSide::Buy + )); + assert!(permissions.supports_quantity_mode( + MarketQuantityMode::Quote, + OrderType::Market, + OrderSide::Buy + )); + assert!(!permissions.supports_quantity_mode( + MarketQuantityMode::Quote, + OrderType::Market, + OrderSide::Sell + )); + } + + #[test] + fn market_info_helpers_pick_effective_constraints() { + let limit_lot = lot_size("0.01", "50", "0.01"); + let market_lot = lot_size("0.2", "2", "0.2"); + let market = market_info( + MarketStatus::Trading, + TradingPermissions::default(), + TradingConstraints::builder() + .price_filter( + PriceFilter::builder() + .tick_size(decimal("0.25")) + .build() + .expect("price filter must build"), + ) + .lot_size(limit_lot.clone()) + .market_lot_size(market_lot.clone()) + .notional( + NotionalConstraints::builder() + .min_notional(decimal("12")) + .build() + .expect("notional must build"), + ) + .build() + .expect("constraints must build"), + ); + + assert!(market.is_trading()); + assert!(market.allows_spot_order_entry()); + assert_eq!(market.tick_size(), Some(decimal("0.25"))); + assert_eq!(market.quote_scale(), Some(8)); + assert_eq!(market.min_notional_or(decimal("10")), decimal("12")); + assert_eq!(market.step_size(OrderType::Limit), Some(decimal("0.01"))); + assert_eq!(market.step_size(OrderType::Market), Some(decimal("0.2"))); + assert_eq!(market.min_quantity(OrderType::Limit), Some(decimal("0.01"))); + assert_eq!(market.min_quantity(OrderType::Market), Some(decimal("0.2"))); + assert_eq!(market.max_quantity(OrderType::Market), Some(decimal("2"))); + assert_eq!(market.lot_size_for(OrderType::Limit), Some(&limit_lot)); + assert_eq!( + market.lot_size_for(OrderType::StopMarket), + Some(&market_lot) + ); + } + + #[test] + fn market_info_helpers_filter_non_positive_values_and_fallback() { + let zero_lot = lot_size("0", "0", "0"); + let market = market_info( + MarketStatus::Halted, + TradingPermissions::builder() + .spot_order_entry_allowed(Some(true)) + .build() + .expect("permissions must build"), + TradingConstraints::builder() + .price_filter( + PriceFilter::builder() + .tick_size(decimal("0")) + .build() + .expect("price filter must build"), + ) + .lot_size(zero_lot) + .notional( + NotionalConstraints::builder() + .min_notional(decimal("0")) + .build() + .expect("notional must build"), + ) + .build() + .expect("constraints must build"), + ); + + assert!(!market.is_trading()); + assert!(market.allows_spot_order_entry()); + assert_eq!(market.tick_size(), None); + assert_eq!(market.min_quantity(OrderType::Limit), None); + assert_eq!(market.max_quantity(OrderType::Market), None); + assert_eq!(market.step_size(OrderType::Market), None); + assert_eq!(market.min_notional_or(decimal("10")), decimal("10")); + } + + #[test] + fn market_order_constraints_fall_back_per_field() { + let limit_lot = lot_size("0.01", "100", "0.01"); + let market_lot = lot_size("0", "50", "0"); + let market = market_info( + MarketStatus::Trading, + TradingPermissions::default(), + TradingConstraints::builder() + .lot_size(limit_lot) + .market_lot_size(market_lot) + .build() + .expect("constraints must build"), + ); + + assert_eq!( + market.min_quantity(OrderType::Market), + Some(decimal("0.01")) + ); + assert_eq!(market.max_quantity(OrderType::Market), Some(decimal("50"))); + assert_eq!(market.step_size(OrderType::Market), Some(decimal("0.01"))); + assert_eq!( + market.min_quantity(OrderType::StopMarket), + Some(decimal("0.01")) + ); + assert_eq!( + market.max_quantity(OrderType::StopMarket), + Some(decimal("50")) + ); + assert_eq!( + market.step_size(OrderType::StopMarket), + Some(decimal("0.01")) + ); + } } diff --git a/crates/mkt-types/src/trading.rs b/crates/mkt-types/src/trading.rs index 8256f3d..6691061 100644 --- a/crates/mkt-types/src/trading.rs +++ b/crates/mkt-types/src/trading.rs @@ -80,6 +80,16 @@ pub enum OrderStatus { Expired, } +impl OrderStatus { + pub fn is_open(self) -> bool { + matches!(self, Self::New | Self::PartiallyFilled) + } + + pub fn is_terminal(self) -> bool { + !self.is_open() + } +} + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString)] @@ -153,7 +163,7 @@ impl SpotOrderRequestBuilder { #[cfg(test)] mod tests { - use super::{OrderQuantity, OrderSide, OrderType, SpotOrderRequest}; + use super::{OrderQuantity, OrderSide, OrderStatus, OrderType, SpotOrderRequest}; use crate::Symbol; use rust_decimal::Decimal; @@ -189,6 +199,23 @@ mod tests { .to_string() .contains("quote quantity must be greater than zero")); } + + #[test] + fn order_status_helpers_follow_order_lifecycle() { + assert!(OrderStatus::New.is_open()); + assert!(OrderStatus::PartiallyFilled.is_open()); + assert!(!OrderStatus::Filled.is_open()); + assert!(!OrderStatus::Canceled.is_open()); + assert!(!OrderStatus::Rejected.is_open()); + assert!(!OrderStatus::Expired.is_open()); + + assert!(!OrderStatus::New.is_terminal()); + assert!(!OrderStatus::PartiallyFilled.is_terminal()); + assert!(OrderStatus::Filled.is_terminal()); + assert!(OrderStatus::Canceled.is_terminal()); + assert!(OrderStatus::Rejected.is_terminal()); + assert!(OrderStatus::Expired.is_terminal()); + } } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/crates/mkt-types/tests/market_helpers.rs b/crates/mkt-types/tests/market_helpers.rs new file mode 100644 index 0000000..f420151 --- /dev/null +++ b/crates/mkt-types/tests/market_helpers.rs @@ -0,0 +1,212 @@ +use mkt_types::{ + Decimal, ExchangeId, KnownExchange, LotSizeFilter, MarketInfo, MarketQuantityMode, + MarketStatus, NotionalConstraints, OrderSide, OrderType, PriceFilter, QuantityModeSupport, + Symbol, TradingConstraints, TradingPermissions, +}; +use std::str::FromStr; + +fn decimal(value: &str) -> Decimal { + Decimal::from_str(value).expect("test decimal must be valid") +} + +fn lot_size(min_quantity: &str, max_quantity: &str, step_size: &str) -> LotSizeFilter { + LotSizeFilter::builder() + .min_quantity(decimal(min_quantity)) + .max_quantity(decimal(max_quantity)) + .step_size(decimal(step_size)) + .build() + .expect("lot size must build") +} + +fn market_info( + status: MarketStatus, + quote_precision: Option, + quote_asset_precision: Option, + trading_permissions: TradingPermissions, + trading_constraints: TradingConstraints, +) -> MarketInfo { + MarketInfo::builder() + .exchange_id(ExchangeId::from(KnownExchange::Binance)) + .symbol(Symbol::spot("BTCUSDT")) + .status(status) + .base_asset("BTC") + .quote_asset("USDT") + .quote_precision(quote_precision) + .quote_asset_precision(quote_asset_precision) + .trading_permissions(trading_permissions) + .trading_constraints(trading_constraints) + .build() + .expect("market info must build") +} + +#[test] +fn trading_permission_helpers_preserve_expected_defaults_and_support() { + let unknown = TradingPermissions::default(); + + assert!(unknown.allows_spot_order_entry()); + assert!(!unknown.supports_order_type(OrderType::Limit)); + assert!(!unknown.supports_quantity_mode( + MarketQuantityMode::Base, + OrderType::Limit, + OrderSide::Buy + )); + + let permissions = TradingPermissions::builder() + .supported_order_types([OrderType::Limit, OrderType::Market]) + .quantity_mode_support([ + QuantityModeSupport::builder() + .mode(MarketQuantityMode::Base) + .order_types([OrderType::Limit, OrderType::Market]) + .sides([OrderSide::Buy, OrderSide::Sell]) + .build() + .expect("base support must build"), + QuantityModeSupport::builder() + .mode(MarketQuantityMode::Quote) + .order_types([OrderType::Market]) + .sides([OrderSide::Buy]) + .build() + .expect("quote support must build"), + ]) + .build() + .expect("permissions must build"); + + assert!(permissions.supports_order_type(OrderType::Limit)); + assert!(!permissions.supports_order_type(OrderType::StopLimit)); + assert!(permissions.supports_quantity_mode( + MarketQuantityMode::Base, + OrderType::Limit, + OrderSide::Sell + )); + assert!(permissions.supports_quantity_mode( + MarketQuantityMode::Quote, + OrderType::Market, + OrderSide::Buy + )); + assert!(!permissions.supports_quantity_mode( + MarketQuantityMode::Quote, + OrderType::Market, + OrderSide::Sell + )); + assert!(!permissions.supports_quantity_mode( + MarketQuantityMode::Quote, + OrderType::Limit, + OrderSide::Buy + )); +} + +#[test] +fn market_info_helpers_expose_effective_constraints() { + let limit_lot_size = lot_size("0.001", "100", "0.001"); + let market_lot_size = lot_size("0.01", "50", "0.01"); + let trading_constraints = TradingConstraints::builder() + .price_filter( + PriceFilter::builder() + .tick_size(decimal("0.05")) + .build() + .expect("price filter must build"), + ) + .lot_size(limit_lot_size.clone()) + .market_lot_size(market_lot_size.clone()) + .notional( + NotionalConstraints::builder() + .min_notional(decimal("10")) + .build() + .expect("notional must build"), + ) + .build() + .expect("constraints must build"); + let market = market_info( + MarketStatus::Trading, + Some(-1), + Some(6), + TradingPermissions::builder() + .spot_order_entry_allowed(true) + .build() + .expect("permissions must build"), + trading_constraints, + ); + + assert!(market.is_trading()); + assert!(market.allows_spot_order_entry()); + assert_eq!(market.tick_size(), Some(decimal("0.05"))); + assert_eq!(market.quote_scale(), Some(6)); + assert_eq!(market.min_notional_or(decimal("2")), decimal("10")); + assert_eq!( + market.min_quantity(OrderType::Limit), + Some(decimal("0.001")) + ); + assert_eq!(market.max_quantity(OrderType::Limit), Some(decimal("100"))); + assert_eq!(market.step_size(OrderType::Limit), Some(decimal("0.001"))); + assert_eq!( + market.min_quantity(OrderType::Market), + Some(decimal("0.01")) + ); + assert_eq!(market.max_quantity(OrderType::Market), Some(decimal("50"))); + assert_eq!( + market.step_size(OrderType::StopMarket), + Some(decimal("0.01")) + ); + + let fallback_market = market_info( + MarketStatus::PreLaunch, + None, + None, + TradingPermissions::default(), + TradingConstraints::builder() + .lot_size(limit_lot_size) + .build() + .expect("fallback constraints must build"), + ); + + assert!(!fallback_market.is_trading()); + assert!(fallback_market.allows_spot_order_entry()); + assert_eq!(fallback_market.tick_size(), None); + assert_eq!(fallback_market.quote_scale(), None); + assert_eq!(fallback_market.min_notional_or(decimal("2")), decimal("2")); + assert_eq!( + fallback_market.min_quantity(OrderType::Market), + Some(decimal("0.001")) + ); + assert_eq!( + fallback_market.max_quantity(OrderType::Market), + Some(decimal("100")) + ); + assert_eq!( + fallback_market.step_size(OrderType::Market), + Some(decimal("0.001")) + ); +} + +#[test] +fn market_order_constraints_fall_back_per_field() { + let market = market_info( + MarketStatus::Trading, + None, + None, + TradingPermissions::default(), + TradingConstraints::builder() + .lot_size(lot_size("0.001", "100", "0.001")) + .market_lot_size(lot_size("0", "50", "0")) + .build() + .expect("constraints must build"), + ); + + assert_eq!( + market.min_quantity(OrderType::Market), + Some(decimal("0.001")) + ); + assert_eq!(market.max_quantity(OrderType::Market), Some(decimal("50"))); + assert_eq!(market.step_size(OrderType::Market), Some(decimal("0.001"))); + assert_eq!( + market.min_quantity(OrderType::StopMarket), + Some(decimal("0.001")) + ); + assert_eq!( + market.max_quantity(OrderType::StopMarket), + Some(decimal("50")) + ); + assert_eq!( + market.step_size(OrderType::StopMarket), + Some(decimal("0.001")) + ); +} From b97fcb3a709ac51521ea3ed3229fdc966a7a255b Mon Sep 17 00:00:00 2001 From: "Mt. Shit" <284372560+mtshit@users.noreply.github.com> Date: Sun, 17 May 2026 15:07:08 +0000 Subject: [PATCH 4/8] Add kline collection helpers --- crates/mkt-types/src/market_data.rs | 116 +++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/crates/mkt-types/src/market_data.rs b/crates/mkt-types/src/market_data.rs index 8483d0a..d5e1356 100644 --- a/crates/mkt-types/src/market_data.rs +++ b/crates/mkt-types/src/market_data.rs @@ -290,6 +290,46 @@ impl Kline { pub fn builder() -> KlineBuilder { KlineBuilder::default() } + + pub fn dedup(klines: impl IntoIterator) -> Vec { + let mut deduped = Vec::new(); + + for kline in klines { + if let Some(existing) = deduped.iter_mut().find(|existing: &&mut Self| { + existing.symbol == kline.symbol + && existing.interval == kline.interval + && existing.open_time == kline.open_time + }) { + *existing = kline; + } else { + deduped.push(kline); + } + } + + deduped.sort_by_key(|kline| kline.open_time); + deduped + } + + pub fn merge( + left: impl IntoIterator, + right: impl IntoIterator, + ) -> Vec { + Self::dedup(left.into_iter().chain(right)) + } + + pub fn window( + klines: impl IntoIterator, + start: Option, + end: Option, + ) -> Vec { + klines + .into_iter() + .filter(|kline| { + start.is_none_or(|start| kline.open_time >= start) + && end.is_none_or(|end| kline.open_time < end) + }) + .collect() + } } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -485,7 +525,35 @@ impl MiniTicker { #[cfg(test)] mod tests { - use super::KlineInterval; + use super::{Kline, KlineInterval}; + use crate::Symbol; + use rust_decimal::Decimal; + use std::str::FromStr; + use time::OffsetDateTime; + + fn decimal(value: &str) -> Decimal { + Decimal::from_str(value).expect("test decimal must be valid") + } + + fn timestamp(value: i64) -> OffsetDateTime { + OffsetDateTime::from_unix_timestamp(value).expect("test timestamp must be valid") + } + + fn kline(symbol: &str, open_time: i64, close: &str) -> Kline { + Kline::builder() + .symbol(Symbol::spot(symbol)) + .interval(KlineInterval::M1) + .open_time(timestamp(open_time)) + .close_time(timestamp(open_time + 60)) + .open(decimal(close)) + .high(decimal(close)) + .low(decimal(close)) + .close(decimal(close)) + .volume_base(decimal("1")) + .closed(true) + .build() + .expect("kline must build") + } #[test] fn common_intervals_parse_to_duration_backed_values() { @@ -501,4 +569,50 @@ mod tests { assert_eq!(interval, KlineInterval::Month(3)); assert_eq!(interval.to_string(), "3M"); } + + #[test] + fn kline_dedup_replaces_matching_entries_and_keeps_other_series() { + let first = kline("BTCUSDT", 60, "100"); + let replacement = kline("BTCUSDT", 60, "101"); + let other_symbol = kline("ETHUSDT", 60, "200"); + + let deduped = Kline::dedup([first, other_symbol.clone(), replacement.clone()]); + + assert_eq!(deduped.len(), 2); + assert_eq!(deduped[0], replacement); + assert_eq!(deduped[1], other_symbol); + } + + #[test] + fn kline_merge_sorts_by_open_time_and_uses_latest_duplicate() { + let one = kline("BTCUSDT", 60, "1"); + let three = kline("BTCUSDT", 180, "3"); + let two = kline("BTCUSDT", 120, "2"); + let replacement = kline("BTCUSDT", 180, "33"); + + let merged = Kline::merge([one.clone(), three], [replacement.clone(), two.clone()]); + + assert_eq!(merged, vec![one, two, replacement]); + } + + #[test] + fn kline_window_filters_by_half_open_open_time_range() { + let one = kline("BTCUSDT", 60, "1"); + let two = kline("BTCUSDT", 120, "2"); + let three = kline("BTCUSDT", 180, "3"); + + let middle = Kline::window( + [one.clone(), two.clone(), three.clone()], + Some(timestamp(120)), + Some(timestamp(180)), + ); + assert_eq!(middle, vec![two.clone()]); + + let trailing = Kline::window( + [one, two.clone(), three.clone()], + Some(timestamp(120)), + None, + ); + assert_eq!(trailing, vec![two, three]); + } } From ba0ef9971d5eb3d656f2480f41cd94831742d8eb Mon Sep 17 00:00:00 2001 From: "Mt. Shit" <284372560+mtshit@users.noreply.github.com> Date: Sun, 17 May 2026 15:07:13 +0000 Subject: [PATCH 5/8] Fold market data helpers into MarketData --- Cargo.lock | 2 + crates/mkt-core/Cargo.toml | 4 + crates/mkt-core/src/traits.rs | 289 ++++++++++++++++++++++++++++++++++ 3 files changed, 295 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 1d779e9..f5036a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1137,6 +1137,8 @@ dependencies = [ "strum", "strum_macros", "thiserror 2.0.18", + "time", + "tokio", ] [[package]] diff --git a/crates/mkt-core/Cargo.toml b/crates/mkt-core/Cargo.toml index 6deaa22..974dae1 100644 --- a/crates/mkt-core/Cargo.toml +++ b/crates/mkt-core/Cargo.toml @@ -19,3 +19,7 @@ secrecy.workspace = true strum.workspace = true strum_macros.workspace = true thiserror.workspace = true + +[dev-dependencies] +time.workspace = true +tokio.workspace = true diff --git a/crates/mkt-core/src/traits.rs b/crates/mkt-core/src/traits.rs index e34d96a..233011e 100644 --- a/crates/mkt-core/src/traits.rs +++ b/crates/mkt-core/src/traits.rs @@ -4,6 +4,7 @@ use mkt_types::{ KlineRequest, LastPrice, MarketInfo, Order, OrderBook, Position, SetLeverageRequest, SpotCancelOrderRequest, SpotOrderQuery, SpotOrderRequest, Symbol, Trade, }; +use std::collections::HashMap; use crate::{ Capabilities, EventStream, PrivateEventStream, PrivateSubscription, Result, Subscription, @@ -18,11 +19,70 @@ pub trait ExchangeInfo: Send + Sync { #[async_trait] pub trait MarketData: Send + Sync { async fn markets(&self) -> Result>; + async fn market(&self, symbol: &Symbol) -> Result> { + Ok(self.markets_by_symbol().await?.remove(symbol)) + } + async fn markets_by_symbol(&self) -> Result> { + Ok(self + .markets() + .await? + .into_iter() + .map(|market| (market.symbol.clone(), market)) + .collect()) + } async fn last_price(&self, symbol: &Symbol) -> Result; async fn last_prices(&self, symbols: Option<&[Symbol]>) -> Result>; + async fn last_prices_by_symbol( + &self, + symbols: Option<&[Symbol]>, + ) -> Result> { + Ok(self + .last_prices(symbols) + .await? + .into_iter() + .map(|price| (price.symbol.clone(), price)) + .collect()) + } async fn order_book(&self, symbol: &Symbol, depth: Option) -> Result; async fn recent_trades(&self, symbol: &Symbol, limit: Option) -> Result>; async fn klines(&self, request: KlineRequest) -> Result>; + async fn kline_history(&self, request: KlineRequest) -> Result> { + let Some(limit) = request.limit else { + return self.klines(request).await; + }; + if limit == 0 { + return self.klines(request).await; + } + + let start = request.start; + let end = request.end; + let mut request = request; + let mut previous_start = request.start; + let mut history = Vec::new(); + + loop { + let batch = self.klines(request.clone()).await?; + if batch.is_empty() { + break; + } + + let batch_len = batch.len(); + let next_start = batch.last().map(|kline| kline.open_time); + history = Kline::merge(history, batch); + + let Some(next_start) = next_start else { + break; + }; + if batch_len < limit as usize || Some(next_start) == previous_start { + break; + } + + previous_start = Some(next_start); + request.start = Some(next_start); + } + + Ok(Kline::window(history, start, end)) + } } #[async_trait] @@ -65,3 +125,232 @@ pub trait PrivateStream: Send + Sync { subscriptions: Vec, ) -> Result>; } + +#[cfg(test)] +mod tests { + use super::MarketData; + use crate::Result; + use async_trait::async_trait; + use mkt_types::{ + Decimal, ExchangeId, Kline, KlineInterval, KlineRequest, KnownExchange, LastPrice, + MarketInfo, MarketStatus, OrderBook, Symbol, Trade, TradingConstraints, TradingPermissions, + }; + use time::OffsetDateTime; + + struct TestMarketData { + markets: Vec, + prices: Vec, + } + + #[async_trait] + impl MarketData for TestMarketData { + async fn markets(&self) -> Result> { + Ok(self.markets.clone()) + } + + async fn last_price(&self, symbol: &Symbol) -> Result { + Ok(self + .prices + .iter() + .find(|price| price.symbol == *symbol) + .cloned() + .expect("test price must exist")) + } + + async fn last_prices(&self, symbols: Option<&[Symbol]>) -> Result> { + Ok(match symbols { + Some(symbols) => self + .prices + .iter() + .filter(|price| symbols.iter().any(|symbol| *symbol == price.symbol)) + .cloned() + .collect(), + None => self.prices.clone(), + }) + } + + async fn order_book(&self, _symbol: &Symbol, _depth: Option) -> Result { + unimplemented!("order_book is not needed in this test") + } + + async fn recent_trades(&self, _symbol: &Symbol, _limit: Option) -> Result> { + unimplemented!("recent_trades is not needed in this test") + } + + async fn klines(&self, _request: KlineRequest) -> Result> { + unimplemented!("klines is not needed in this test") + } + } + + fn timestamp(value: i64) -> OffsetDateTime { + OffsetDateTime::from_unix_timestamp(value).expect("test timestamp must be valid") + } + + fn market(symbol: &str, status: MarketStatus) -> MarketInfo { + MarketInfo::builder() + .exchange_id(ExchangeId::from(KnownExchange::Binance)) + .symbol(Symbol::spot(symbol)) + .status(status) + .base_asset("BASE") + .quote_asset("QUOTE") + .trading_permissions(TradingPermissions::default()) + .trading_constraints(TradingConstraints::default()) + .build() + .expect("market must build") + } + + fn price(symbol: &str, value: i64) -> LastPrice { + LastPrice::new(Symbol::spot(symbol), Decimal::new(value, 0)) + } + + #[tokio::test] + async fn market_data_helpers_build_symbol_maps_with_last_value_wins() { + let btc = Symbol::spot("BTCUSDT"); + let eth = Symbol::spot("ETHUSDT"); + let market_data = TestMarketData { + markets: vec![ + market("BTCUSDT", MarketStatus::Trading), + market("ETHUSDT", MarketStatus::Trading), + market("BTCUSDT", MarketStatus::Halted), + ], + prices: vec![ + price("BTCUSDT", 100), + price("ETHUSDT", 200), + price("BTCUSDT", 101), + ], + }; + + let markets = market_data + .markets_by_symbol() + .await + .expect("markets map must build"); + assert_eq!(markets.len(), 2); + assert_eq!( + markets.get(&btc).expect("btc market must exist").status, + MarketStatus::Halted + ); + assert_eq!( + market_data + .market(&btc) + .await + .expect("market lookup must succeed") + .expect("btc market must exist") + .status, + MarketStatus::Halted + ); + + let prices = market_data + .last_prices_by_symbol(Some(&[btc.clone(), eth.clone()])) + .await + .expect("prices map must build"); + assert_eq!(prices.len(), 2); + assert_eq!( + prices.get(&btc).expect("btc price must exist").price, + Decimal::new(101, 0) + ); + assert_eq!( + prices.get(ð).expect("eth price must exist").price, + Decimal::new(200, 0) + ); + } + + fn kline_at(day: u8) -> Kline { + let open_time = match day { + 1 => timestamp(1_767_225_600), + 2 => timestamp(1_767_312_000), + 3 => timestamp(1_767_398_400), + _ => timestamp(1_767_484_800), + }; + let close_time = match day { + 1 => timestamp(1_767_312_000), + 2 => timestamp(1_767_398_400), + 3 => timestamp(1_767_484_800), + _ => timestamp(1_767_571_200), + }; + + Kline::builder() + .symbol(Symbol::spot("BTCUSDT")) + .interval(KlineInterval::D1) + .open_time(open_time) + .close_time(close_time) + .open(Decimal::new(100 + i64::from(day), 0)) + .high(Decimal::new(110 + i64::from(day), 0)) + .low(Decimal::new(90 + i64::from(day), 0)) + .close(Decimal::new(105 + i64::from(day), 0)) + .volume_base(Decimal::ONE) + .closed(true) + .build() + .expect("kline must build") + } + + #[tokio::test] + async fn kline_history_pages_without_execution_state() { + struct TestHistoryMarketData { + klines: Vec, + } + + #[async_trait] + impl MarketData for TestHistoryMarketData { + async fn markets(&self) -> Result> { + unimplemented!("markets is not needed in this test") + } + + async fn last_price(&self, _symbol: &Symbol) -> Result { + unimplemented!("last_price is not needed in this test") + } + + async fn last_prices(&self, _symbols: Option<&[Symbol]>) -> Result> { + unimplemented!("last_prices is not needed in this test") + } + + async fn order_book(&self, _symbol: &Symbol, _depth: Option) -> Result { + unimplemented!("order_book is not needed in this test") + } + + async fn recent_trades( + &self, + _symbol: &Symbol, + _limit: Option, + ) -> Result> { + unimplemented!("recent_trades is not needed in this test") + } + + async fn klines(&self, request: KlineRequest) -> Result> { + let start = request.start; + let end = request.end; + let limit = request.limit.unwrap_or(u32::MAX) as usize; + + Ok(self + .klines + .iter() + .filter(|kline| start.is_none_or(|start| kline.open_time >= start)) + .filter(|kline| end.is_none_or(|end| kline.open_time < end)) + .take(limit) + .cloned() + .collect()) + } + } + + let market_data = TestHistoryMarketData { + klines: vec![kline_at(1), kline_at(2), kline_at(3)], + }; + let history = market_data + .kline_history( + KlineRequest::builder() + .symbol(Symbol::spot("BTCUSDT")) + .interval(KlineInterval::D1) + .start(Some(timestamp(1_767_225_600))) + .end(Some(timestamp(1_767_484_800))) + .limit(Some(2)) + .build() + .expect("request must build"), + ) + .await + .expect("history must build"); + + assert_eq!(history.len(), 3); + assert_eq!(history[0].open_time, timestamp(1_767_225_600)); + assert_eq!(history[1].open_time, timestamp(1_767_312_000)); + assert_eq!(history[2].open_time, timestamp(1_767_398_400)); + } +} From 4dd6f6b98ba69d1f39c5ce493e4af11eed2c3c60 Mon Sep 17 00:00:00 2001 From: "Mt. Shit" <284372560+mtshit@users.noreply.github.com> Date: Sun, 17 May 2026 15:20:18 +0000 Subject: [PATCH 6/8] Split market data helpers from core trait --- crates/exchanges/binance/src/market_data.rs | 41 ++- crates/mkt-core/src/lib.rs | 2 + crates/mkt-core/src/market_data_ext.rs | 313 ++++++++++++++++++++ crates/mkt-core/src/traits.rs | 290 +----------------- crates/mkt/src/lib.rs | 4 +- 5 files changed, 351 insertions(+), 299 deletions(-) create mode 100644 crates/mkt-core/src/market_data_ext.rs diff --git a/crates/exchanges/binance/src/market_data.rs b/crates/exchanges/binance/src/market_data.rs index cbfcaa3..6467db4 100644 --- a/crates/exchanges/binance/src/market_data.rs +++ b/crates/exchanges/binance/src/market_data.rs @@ -23,15 +23,8 @@ impl BinanceMarketData { pub(crate) fn new(inner: Arc) -> Self { Self { inner } } -} -#[async_trait] -impl MarketData for BinanceMarketData { - async fn markets(&self) -> Result> { - let params = ExchangeInfoParams::builder() - .show_permission_sets(false) - .build() - .map_err(|err| error::adapter_error(EXCHANGE_INFO_OPERATION, err.to_string()))?; + async fn exchange_info(&self, params: ExchangeInfoParams) -> Result> { let response = self .inner .spot_rest @@ -51,6 +44,38 @@ impl MarketData for BinanceMarketData { }) .collect() } +} + +#[async_trait] +impl MarketData for BinanceMarketData { + async fn markets(&self) -> Result> { + let params = ExchangeInfoParams::builder() + .show_permission_sets(false) + .build() + .map_err(|err| error::adapter_error(EXCHANGE_INFO_OPERATION, err.to_string()))?; + self.exchange_info(params).await + } + + async fn market(&self, symbol: &Symbol) -> Result> { + let params = ExchangeInfoParams::builder() + .symbol(convert::require_spot_symbol( + symbol, + EXCHANGE_INFO_OPERATION, + )?) + .show_permission_sets(false) + .build() + .map_err(|err| error::adapter_error(EXCHANGE_INFO_OPERATION, err.to_string()))?; + let mut markets = self.exchange_info(params).await?; + match markets.len() { + 0 => Ok(None), + 1 => Ok(markets.pop()), + _ => Err(error::invalid_field( + EXCHANGE_INFO_OPERATION, + "symbol", + "expected a single-symbol exchange info response", + )), + } + } async fn last_price(&self, symbol: &Symbol) -> Result { let mut prices = self.last_prices(Some(std::slice::from_ref(symbol))).await?; diff --git a/crates/mkt-core/src/lib.rs b/crates/mkt-core/src/lib.rs index 92dbb47..a33ab88 100644 --- a/crates/mkt-core/src/lib.rs +++ b/crates/mkt-core/src/lib.rs @@ -4,6 +4,7 @@ mod capabilities; mod config; pub mod error; mod handle; +mod market_data_ext; mod stream; mod traits; @@ -14,6 +15,7 @@ pub use capabilities::{ pub use config::{ApiCredentials, ExchangeConfig, SecretString}; pub use error::{CapabilityUnavailableReason, Error, ErrorKind, Result}; pub use handle::{Builder, ExchangeHandle}; +pub use market_data_ext::MarketDataExt; pub use secrecy::ExposeSecret; pub use stream::{ EventStream, MarketDataEvent, PrivateEvent, PrivateEventStream, PrivateSubscription, diff --git a/crates/mkt-core/src/market_data_ext.rs b/crates/mkt-core/src/market_data_ext.rs new file mode 100644 index 0000000..ffcbf50 --- /dev/null +++ b/crates/mkt-core/src/market_data_ext.rs @@ -0,0 +1,313 @@ +use async_trait::async_trait; +use std::collections::HashMap; + +use mkt_types::{Kline, KlineRequest, LastPrice, MarketInfo, Symbol}; + +use crate::{MarketData, Result}; + +#[async_trait] +pub trait MarketDataExt: MarketData { + async fn markets_by_symbol(&self) -> Result> { + Ok(self + .markets() + .await? + .into_iter() + .map(|market| (market.symbol.clone(), market)) + .collect()) + } + + async fn last_prices_by_symbol( + &self, + symbols: Option<&[Symbol]>, + ) -> Result> { + Ok(self + .last_prices(symbols) + .await? + .into_iter() + .map(|price| (price.symbol.clone(), price)) + .collect()) + } + + async fn kline_history(&self, request: KlineRequest) -> Result> { + let Some(limit) = request.limit else { + return self.klines(request).await; + }; + if limit == 0 { + return self.klines(request).await; + } + + let start = request.start; + let end = request.end; + let mut request = request; + let mut previous_start = request.start; + let mut history = Vec::new(); + + loop { + let batch = self.klines(request.clone()).await?; + if batch.is_empty() { + break; + } + + let batch_len = batch.len(); + let next_start = batch.last().map(|kline| kline.open_time); + history = Kline::merge(history, batch); + + let Some(next_start) = next_start else { + break; + }; + if batch_len < limit as usize || Some(next_start) == previous_start { + break; + } + + previous_start = Some(next_start); + request.start = Some(next_start); + } + + Ok(Kline::window(history, start, end)) + } +} + +impl MarketDataExt for T where T: MarketData + ?Sized {} + +#[cfg(test)] +mod tests { + use super::MarketDataExt; + use crate::{MarketData, Result}; + use async_trait::async_trait; + use mkt_types::{ + Decimal, ExchangeId, Kline, KlineInterval, KlineRequest, KnownExchange, LastPrice, + MarketInfo, MarketStatus, OrderBook, Symbol, Trade, TradingConstraints, + TradingPermissions, + }; + use time::OffsetDateTime; + + struct TestMarketData { + markets: Vec, + prices: Vec, + } + + #[async_trait] + impl MarketData for TestMarketData { + async fn markets(&self) -> Result> { + Ok(self.markets.clone()) + } + + async fn market(&self, symbol: &Symbol) -> Result> { + Ok(self + .markets + .iter() + .rev() + .find(|market| market.symbol == *symbol) + .cloned()) + } + + async fn last_price(&self, symbol: &Symbol) -> Result { + Ok(self + .prices + .iter() + .find(|price| price.symbol == *symbol) + .cloned() + .expect("test price must exist")) + } + + async fn last_prices(&self, symbols: Option<&[Symbol]>) -> Result> { + Ok(match symbols { + Some(symbols) => self + .prices + .iter() + .filter(|price| symbols.iter().any(|symbol| *symbol == price.symbol)) + .cloned() + .collect(), + None => self.prices.clone(), + }) + } + + async fn order_book(&self, _symbol: &Symbol, _depth: Option) -> Result { + unimplemented!("order_book is not needed in this test") + } + + async fn recent_trades(&self, _symbol: &Symbol, _limit: Option) -> Result> { + unimplemented!("recent_trades is not needed in this test") + } + + async fn klines(&self, _request: KlineRequest) -> Result> { + unimplemented!("klines is not needed in this test") + } + } + + fn timestamp(value: i64) -> OffsetDateTime { + OffsetDateTime::from_unix_timestamp(value).expect("test timestamp must be valid") + } + + fn market(symbol: &str, status: MarketStatus) -> MarketInfo { + MarketInfo::builder() + .exchange_id(ExchangeId::from(KnownExchange::Binance)) + .symbol(Symbol::spot(symbol)) + .status(status) + .base_asset("BASE") + .quote_asset("QUOTE") + .trading_permissions(TradingPermissions::default()) + .trading_constraints(TradingConstraints::default()) + .build() + .expect("market must build") + } + + fn price(symbol: &str, value: i64) -> LastPrice { + LastPrice::new(Symbol::spot(symbol), Decimal::new(value, 0)) + } + + #[tokio::test] + async fn market_data_helpers_build_symbol_maps_with_last_value_wins() { + let btc = Symbol::spot("BTCUSDT"); + let eth = Symbol::spot("ETHUSDT"); + let market_data = TestMarketData { + markets: vec![ + market("BTCUSDT", MarketStatus::Trading), + market("ETHUSDT", MarketStatus::Trading), + market("BTCUSDT", MarketStatus::Halted), + ], + prices: vec![ + price("BTCUSDT", 100), + price("ETHUSDT", 200), + price("BTCUSDT", 101), + ], + }; + + let markets = market_data + .markets_by_symbol() + .await + .expect("markets map must build"); + assert_eq!(markets.len(), 2); + assert_eq!( + markets.get(&btc).expect("btc market must exist").status, + MarketStatus::Halted + ); + assert_eq!( + market_data + .market(&btc) + .await + .expect("market lookup must succeed") + .expect("btc market must exist") + .status, + MarketStatus::Halted + ); + + let prices = market_data + .last_prices_by_symbol(Some(&[btc.clone(), eth.clone()])) + .await + .expect("prices map must build"); + assert_eq!(prices.len(), 2); + assert_eq!( + prices.get(&btc).expect("btc price must exist").price, + Decimal::new(101, 0) + ); + assert_eq!( + prices.get(ð).expect("eth price must exist").price, + Decimal::new(200, 0) + ); + } + + fn kline_at(day: u8) -> Kline { + let open_time = match day { + 1 => timestamp(1_767_225_600), + 2 => timestamp(1_767_312_000), + 3 => timestamp(1_767_398_400), + _ => timestamp(1_767_484_800), + }; + let close_time = match day { + 1 => timestamp(1_767_312_000), + 2 => timestamp(1_767_398_400), + 3 => timestamp(1_767_484_800), + _ => timestamp(1_767_571_200), + }; + + Kline::builder() + .symbol(Symbol::spot("BTCUSDT")) + .interval(KlineInterval::D1) + .open_time(open_time) + .close_time(close_time) + .open(Decimal::new(100 + i64::from(day), 0)) + .high(Decimal::new(110 + i64::from(day), 0)) + .low(Decimal::new(90 + i64::from(day), 0)) + .close(Decimal::new(105 + i64::from(day), 0)) + .volume_base(Decimal::ONE) + .closed(true) + .build() + .expect("kline must build") + } + + #[tokio::test] + async fn kline_history_pages_without_execution_state() { + struct TestHistoryMarketData { + klines: Vec, + } + + #[async_trait] + impl MarketData for TestHistoryMarketData { + async fn markets(&self) -> Result> { + unimplemented!("markets is not needed in this test") + } + + async fn market(&self, _symbol: &Symbol) -> Result> { + unimplemented!("market is not needed in this test") + } + + async fn last_price(&self, _symbol: &Symbol) -> Result { + unimplemented!("last_price is not needed in this test") + } + + async fn last_prices(&self, _symbols: Option<&[Symbol]>) -> Result> { + unimplemented!("last_prices is not needed in this test") + } + + async fn order_book(&self, _symbol: &Symbol, _depth: Option) -> Result { + unimplemented!("order_book is not needed in this test") + } + + async fn recent_trades( + &self, + _symbol: &Symbol, + _limit: Option, + ) -> Result> { + unimplemented!("recent_trades is not needed in this test") + } + + async fn klines(&self, request: KlineRequest) -> Result> { + let start = request.start; + let end = request.end; + let limit = request.limit.unwrap_or(u32::MAX) as usize; + + Ok(self + .klines + .iter() + .filter(|kline| start.is_none_or(|start| kline.open_time >= start)) + .filter(|kline| end.is_none_or(|end| kline.open_time < end)) + .take(limit) + .cloned() + .collect()) + } + } + + let market_data = TestHistoryMarketData { + klines: vec![kline_at(1), kline_at(2), kline_at(3)], + }; + let history = market_data + .kline_history( + KlineRequest::builder() + .symbol(Symbol::spot("BTCUSDT")) + .interval(KlineInterval::D1) + .start(Some(timestamp(1_767_225_600))) + .end(Some(timestamp(1_767_484_800))) + .limit(Some(2)) + .build() + .expect("request must build"), + ) + .await + .expect("history must build"); + + assert_eq!(history.len(), 3); + assert_eq!(history[0].open_time, timestamp(1_767_225_600)); + assert_eq!(history[1].open_time, timestamp(1_767_312_000)); + assert_eq!(history[2].open_time, timestamp(1_767_398_400)); + } +} diff --git a/crates/mkt-core/src/traits.rs b/crates/mkt-core/src/traits.rs index 233011e..74fb95b 100644 --- a/crates/mkt-core/src/traits.rs +++ b/crates/mkt-core/src/traits.rs @@ -4,7 +4,6 @@ use mkt_types::{ KlineRequest, LastPrice, MarketInfo, Order, OrderBook, Position, SetLeverageRequest, SpotCancelOrderRequest, SpotOrderQuery, SpotOrderRequest, Symbol, Trade, }; -use std::collections::HashMap; use crate::{ Capabilities, EventStream, PrivateEventStream, PrivateSubscription, Result, Subscription, @@ -19,70 +18,12 @@ pub trait ExchangeInfo: Send + Sync { #[async_trait] pub trait MarketData: Send + Sync { async fn markets(&self) -> Result>; - async fn market(&self, symbol: &Symbol) -> Result> { - Ok(self.markets_by_symbol().await?.remove(symbol)) - } - async fn markets_by_symbol(&self) -> Result> { - Ok(self - .markets() - .await? - .into_iter() - .map(|market| (market.symbol.clone(), market)) - .collect()) - } + async fn market(&self, symbol: &Symbol) -> Result>; async fn last_price(&self, symbol: &Symbol) -> Result; async fn last_prices(&self, symbols: Option<&[Symbol]>) -> Result>; - async fn last_prices_by_symbol( - &self, - symbols: Option<&[Symbol]>, - ) -> Result> { - Ok(self - .last_prices(symbols) - .await? - .into_iter() - .map(|price| (price.symbol.clone(), price)) - .collect()) - } async fn order_book(&self, symbol: &Symbol, depth: Option) -> Result; async fn recent_trades(&self, symbol: &Symbol, limit: Option) -> Result>; async fn klines(&self, request: KlineRequest) -> Result>; - async fn kline_history(&self, request: KlineRequest) -> Result> { - let Some(limit) = request.limit else { - return self.klines(request).await; - }; - if limit == 0 { - return self.klines(request).await; - } - - let start = request.start; - let end = request.end; - let mut request = request; - let mut previous_start = request.start; - let mut history = Vec::new(); - - loop { - let batch = self.klines(request.clone()).await?; - if batch.is_empty() { - break; - } - - let batch_len = batch.len(); - let next_start = batch.last().map(|kline| kline.open_time); - history = Kline::merge(history, batch); - - let Some(next_start) = next_start else { - break; - }; - if batch_len < limit as usize || Some(next_start) == previous_start { - break; - } - - previous_start = Some(next_start); - request.start = Some(next_start); - } - - Ok(Kline::window(history, start, end)) - } } #[async_trait] @@ -125,232 +66,3 @@ pub trait PrivateStream: Send + Sync { subscriptions: Vec, ) -> Result>; } - -#[cfg(test)] -mod tests { - use super::MarketData; - use crate::Result; - use async_trait::async_trait; - use mkt_types::{ - Decimal, ExchangeId, Kline, KlineInterval, KlineRequest, KnownExchange, LastPrice, - MarketInfo, MarketStatus, OrderBook, Symbol, Trade, TradingConstraints, TradingPermissions, - }; - use time::OffsetDateTime; - - struct TestMarketData { - markets: Vec, - prices: Vec, - } - - #[async_trait] - impl MarketData for TestMarketData { - async fn markets(&self) -> Result> { - Ok(self.markets.clone()) - } - - async fn last_price(&self, symbol: &Symbol) -> Result { - Ok(self - .prices - .iter() - .find(|price| price.symbol == *symbol) - .cloned() - .expect("test price must exist")) - } - - async fn last_prices(&self, symbols: Option<&[Symbol]>) -> Result> { - Ok(match symbols { - Some(symbols) => self - .prices - .iter() - .filter(|price| symbols.iter().any(|symbol| *symbol == price.symbol)) - .cloned() - .collect(), - None => self.prices.clone(), - }) - } - - async fn order_book(&self, _symbol: &Symbol, _depth: Option) -> Result { - unimplemented!("order_book is not needed in this test") - } - - async fn recent_trades(&self, _symbol: &Symbol, _limit: Option) -> Result> { - unimplemented!("recent_trades is not needed in this test") - } - - async fn klines(&self, _request: KlineRequest) -> Result> { - unimplemented!("klines is not needed in this test") - } - } - - fn timestamp(value: i64) -> OffsetDateTime { - OffsetDateTime::from_unix_timestamp(value).expect("test timestamp must be valid") - } - - fn market(symbol: &str, status: MarketStatus) -> MarketInfo { - MarketInfo::builder() - .exchange_id(ExchangeId::from(KnownExchange::Binance)) - .symbol(Symbol::spot(symbol)) - .status(status) - .base_asset("BASE") - .quote_asset("QUOTE") - .trading_permissions(TradingPermissions::default()) - .trading_constraints(TradingConstraints::default()) - .build() - .expect("market must build") - } - - fn price(symbol: &str, value: i64) -> LastPrice { - LastPrice::new(Symbol::spot(symbol), Decimal::new(value, 0)) - } - - #[tokio::test] - async fn market_data_helpers_build_symbol_maps_with_last_value_wins() { - let btc = Symbol::spot("BTCUSDT"); - let eth = Symbol::spot("ETHUSDT"); - let market_data = TestMarketData { - markets: vec![ - market("BTCUSDT", MarketStatus::Trading), - market("ETHUSDT", MarketStatus::Trading), - market("BTCUSDT", MarketStatus::Halted), - ], - prices: vec![ - price("BTCUSDT", 100), - price("ETHUSDT", 200), - price("BTCUSDT", 101), - ], - }; - - let markets = market_data - .markets_by_symbol() - .await - .expect("markets map must build"); - assert_eq!(markets.len(), 2); - assert_eq!( - markets.get(&btc).expect("btc market must exist").status, - MarketStatus::Halted - ); - assert_eq!( - market_data - .market(&btc) - .await - .expect("market lookup must succeed") - .expect("btc market must exist") - .status, - MarketStatus::Halted - ); - - let prices = market_data - .last_prices_by_symbol(Some(&[btc.clone(), eth.clone()])) - .await - .expect("prices map must build"); - assert_eq!(prices.len(), 2); - assert_eq!( - prices.get(&btc).expect("btc price must exist").price, - Decimal::new(101, 0) - ); - assert_eq!( - prices.get(ð).expect("eth price must exist").price, - Decimal::new(200, 0) - ); - } - - fn kline_at(day: u8) -> Kline { - let open_time = match day { - 1 => timestamp(1_767_225_600), - 2 => timestamp(1_767_312_000), - 3 => timestamp(1_767_398_400), - _ => timestamp(1_767_484_800), - }; - let close_time = match day { - 1 => timestamp(1_767_312_000), - 2 => timestamp(1_767_398_400), - 3 => timestamp(1_767_484_800), - _ => timestamp(1_767_571_200), - }; - - Kline::builder() - .symbol(Symbol::spot("BTCUSDT")) - .interval(KlineInterval::D1) - .open_time(open_time) - .close_time(close_time) - .open(Decimal::new(100 + i64::from(day), 0)) - .high(Decimal::new(110 + i64::from(day), 0)) - .low(Decimal::new(90 + i64::from(day), 0)) - .close(Decimal::new(105 + i64::from(day), 0)) - .volume_base(Decimal::ONE) - .closed(true) - .build() - .expect("kline must build") - } - - #[tokio::test] - async fn kline_history_pages_without_execution_state() { - struct TestHistoryMarketData { - klines: Vec, - } - - #[async_trait] - impl MarketData for TestHistoryMarketData { - async fn markets(&self) -> Result> { - unimplemented!("markets is not needed in this test") - } - - async fn last_price(&self, _symbol: &Symbol) -> Result { - unimplemented!("last_price is not needed in this test") - } - - async fn last_prices(&self, _symbols: Option<&[Symbol]>) -> Result> { - unimplemented!("last_prices is not needed in this test") - } - - async fn order_book(&self, _symbol: &Symbol, _depth: Option) -> Result { - unimplemented!("order_book is not needed in this test") - } - - async fn recent_trades( - &self, - _symbol: &Symbol, - _limit: Option, - ) -> Result> { - unimplemented!("recent_trades is not needed in this test") - } - - async fn klines(&self, request: KlineRequest) -> Result> { - let start = request.start; - let end = request.end; - let limit = request.limit.unwrap_or(u32::MAX) as usize; - - Ok(self - .klines - .iter() - .filter(|kline| start.is_none_or(|start| kline.open_time >= start)) - .filter(|kline| end.is_none_or(|end| kline.open_time < end)) - .take(limit) - .cloned() - .collect()) - } - } - - let market_data = TestHistoryMarketData { - klines: vec![kline_at(1), kline_at(2), kline_at(3)], - }; - let history = market_data - .kline_history( - KlineRequest::builder() - .symbol(Symbol::spot("BTCUSDT")) - .interval(KlineInterval::D1) - .start(Some(timestamp(1_767_225_600))) - .end(Some(timestamp(1_767_484_800))) - .limit(Some(2)) - .build() - .expect("request must build"), - ) - .await - .expect("history must build"); - - assert_eq!(history.len(), 3); - assert_eq!(history[0].open_time, timestamp(1_767_225_600)); - assert_eq!(history[1].open_time, timestamp(1_767_312_000)); - assert_eq!(history[2].open_time, timestamp(1_767_398_400)); - } -} diff --git a/crates/mkt/src/lib.rs b/crates/mkt/src/lib.rs index ab76726..ddeae10 100644 --- a/crates/mkt/src/lib.rs +++ b/crates/mkt/src/lib.rs @@ -5,8 +5,8 @@ pub use mkt_types as types; pub mod prelude { pub use mkt_core::{ - Account, ExchangeHandle, ExchangeInfo, FuturesTrading, MarketData, PrivateStream, - PublicStream, SpotTrading, + Account, ExchangeHandle, ExchangeInfo, FuturesTrading, MarketData, MarketDataExt, + PrivateStream, PublicStream, SpotTrading, }; pub use mkt_types::{ Balance, Decimal, ExchangeId, FuturesOrderRequest, Kline, KlineInterval, KnownExchange, From db6d78b947b37aa786007a58415c05ffa624b0f3 Mon Sep 17 00:00:00 2001 From: "Mt. Shit" <284372560+mtshit@users.noreply.github.com> Date: Sun, 17 May 2026 15:45:20 +0000 Subject: [PATCH 7/8] Fix market-data extension formatting --- crates/mkt-core/src/market_data_ext.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/mkt-core/src/market_data_ext.rs b/crates/mkt-core/src/market_data_ext.rs index ffcbf50..05ab569 100644 --- a/crates/mkt-core/src/market_data_ext.rs +++ b/crates/mkt-core/src/market_data_ext.rs @@ -76,8 +76,7 @@ mod tests { use async_trait::async_trait; use mkt_types::{ Decimal, ExchangeId, Kline, KlineInterval, KlineRequest, KnownExchange, LastPrice, - MarketInfo, MarketStatus, OrderBook, Symbol, Trade, TradingConstraints, - TradingPermissions, + MarketInfo, MarketStatus, OrderBook, Symbol, Trade, TradingConstraints, TradingPermissions, }; use time::OffsetDateTime; From 5d7a5b33b9e16ce916008b4bbe0528d8e110610f Mon Sep 17 00:00:00 2001 From: "Mt. Shit" <284372560+mtshit@users.noreply.github.com> Date: Sun, 17 May 2026 15:46:41 +0000 Subject: [PATCH 8/8] Fix clippy warning in market-data extension tests --- crates/mkt-core/src/market_data_ext.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mkt-core/src/market_data_ext.rs b/crates/mkt-core/src/market_data_ext.rs index 05ab569..565ad00 100644 --- a/crates/mkt-core/src/market_data_ext.rs +++ b/crates/mkt-core/src/market_data_ext.rs @@ -114,7 +114,7 @@ mod tests { Some(symbols) => self .prices .iter() - .filter(|price| symbols.iter().any(|symbol| *symbol == price.symbol)) + .filter(|price| symbols.contains(&price.symbol)) .cloned() .collect(), None => self.prices.clone(),