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/exchanges/binance/src/convert/market_data.rs b/crates/exchanges/binance/src/convert/market_data.rs index 6bc99fb..a0cf02b 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() @@ -458,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/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/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/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..565ad00 --- /dev/null +++ b/crates/mkt-core/src/market_data_ext.rs @@ -0,0 +1,312 @@ +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.contains(&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 e34d96a..74fb95b 100644 --- a/crates/mkt-core/src/traits.rs +++ b/crates/mkt-core/src/traits.rs @@ -18,6 +18,7 @@ pub trait ExchangeInfo: Send + Sync { #[async_trait] pub trait MarketData: Send + Sync { async fn markets(&self) -> Result>; + 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 order_book(&self, symbol: &Symbol, depth: Option) -> Result; diff --git a/crates/mkt-types/src/market.rs b/crates/mkt-types/src/market.rs index 32c9ecc..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))] @@ -388,14 +429,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, } @@ -403,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/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]); + } } 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")) + ); +} 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,