1mod config;
11mod market_cache;
12mod requests;
13
14pub use config::{ExchangeConfig, ExchangeConfigBuilder};
15pub use market_cache::MarketCache;
16pub use requests::RequestUtils;
17
18use crate::error::{Error, ParseError, Result};
19use crate::exchange::ExchangeCapabilities;
20use crate::http_client::{HttpClient, HttpConfig};
21use crate::rate_limiter::{RateLimiter, RateLimiterConfig};
22#[allow(clippy::wildcard_imports)]
23use crate::types::*;
24use rust_decimal::Decimal;
25use rust_decimal::prelude::{FromStr, ToPrimitive};
26use serde_json::Value;
27use std::collections::HashMap;
28use std::future::Future;
29use std::sync::Arc;
30use std::time::Duration;
31use tokio::sync::{Mutex, RwLock};
32use tracing::{debug, info, warn};
33
34#[derive(Debug, Clone)]
36pub struct BaseExchange {
37 pub config: ExchangeConfig,
39 pub http_client: HttpClient,
41 pub market_cache: Arc<RwLock<MarketCache>>,
43 pub market_loading_lock: Arc<Mutex<()>>,
45 pub capabilities: ExchangeCapabilities,
47 pub urls: HashMap<String, String>,
49 pub timeframes: HashMap<String, String>,
51 pub precision_mode: PrecisionMode,
53}
54
55impl BaseExchange {
56 pub fn new(config: ExchangeConfig) -> Result<Self> {
58 info!("Initializing exchange: {}", config.id);
59
60 if config.timeout.is_zero() {
61 return Err(Error::invalid_request("timeout cannot be zero"));
62 }
63 if config.connect_timeout.is_zero() {
64 return Err(Error::invalid_request("connect_timeout cannot be zero"));
65 }
66
67 if config.timeout > Duration::from_secs(300) {
68 warn!(
69 timeout_secs = config.timeout.as_secs(),
70 "Request timeout exceeds 5 minutes"
71 );
72 }
73
74 let http_config = HttpConfig {
75 timeout: config.timeout,
76 connect_timeout: config.connect_timeout,
77 #[allow(deprecated, clippy::map_unwrap_or)]
78 max_retries: config.retry_policy.map(|p| p.max_retries).unwrap_or(3),
79 verbose: false,
80 user_agent: config
81 .user_agent
82 .clone()
83 .unwrap_or_else(|| format!("ccxt-rust/{}", env!("CARGO_PKG_VERSION"))),
84 return_response_headers: false,
85 proxy: config.proxy.clone(),
86 enable_rate_limit: true,
87 retry_config: config
88 .retry_policy
89 .map(|p| crate::retry_strategy::RetryConfig {
90 max_retries: p.max_retries,
91 #[allow(clippy::cast_possible_truncation)]
92 base_delay_ms: p.delay.as_millis() as u64,
93 strategy_type: crate::retry_strategy::RetryStrategyType::Fixed,
94 ..crate::retry_strategy::RetryConfig::default()
95 }),
96 max_response_size: 128 * 1024 * 1024, max_request_size: 10 * 1024 * 1024, circuit_breaker: None, pool_max_idle_per_host: 10, pool_idle_timeout: Duration::from_secs(90), };
102
103 let mut http_client = HttpClient::new(http_config)?;
104
105 if config.enable_rate_limit {
106 let rate_config =
107 RateLimiterConfig::new(config.rate_limit, Duration::from_millis(1000));
108 let limiter = RateLimiter::new(rate_config);
109 http_client.set_rate_limiter(limiter);
110 }
111
112 Ok(Self {
113 config,
114 http_client,
115 market_cache: Arc::new(RwLock::new(MarketCache::default())),
116 market_loading_lock: Arc::new(Mutex::new(())),
117 capabilities: ExchangeCapabilities::default(),
118 urls: HashMap::new(),
119 timeframes: HashMap::new(),
120 precision_mode: PrecisionMode::DecimalPlaces,
121 })
122 }
123
124 pub async fn load_markets(&self, reload: bool) -> Result<Arc<HashMap<String, Arc<Market>>>> {
126 let cache = self.market_cache.read().await;
127
128 if cache.is_loaded() && !reload {
129 debug!("Returning cached markets for {}", self.config.id);
130 return Ok(cache.markets());
131 }
132
133 info!("Loading markets for {}", self.config.id);
134 drop(cache);
135
136 Err(Error::not_implemented(
137 "load_markets must be implemented by exchange",
138 ))
139 }
140
141 pub async fn load_markets_with_loader<F, Fut>(
143 &self,
144 reload: bool,
145 loader: F,
146 ) -> Result<Arc<HashMap<String, Arc<Market>>>>
147 where
148 F: FnOnce() -> Fut,
149 Fut: Future<Output = Result<(Vec<Market>, Option<Vec<Currency>>)>>,
150 {
151 let _loading_guard = self.market_loading_lock.lock().await;
152
153 {
154 let cache = self.market_cache.read().await;
155 if cache.is_loaded() && !reload {
156 debug!(
157 "Returning cached markets for {} ({} markets)",
158 self.config.id,
159 cache.market_count()
160 );
161 return Ok(cache.markets());
162 }
163 }
164
165 info!(
166 "Loading markets for {} (reload: {})",
167 self.config.id, reload
168 );
169 let (markets, currencies) = loader().await?;
170
171 self.set_markets(markets, currencies).await?;
172
173 let cache = self.market_cache.read().await;
174 Ok(cache.markets())
175 }
176
177 pub async fn set_markets(
179 &self,
180 markets: Vec<Market>,
181 currencies: Option<Vec<Currency>>,
182 ) -> Result<Arc<HashMap<String, Arc<Market>>>> {
183 let cache = self.market_cache.read().await;
184 cache.set_markets(markets, currencies, &self.config.id)
185 }
186
187 pub async fn market(&self, symbol: &str) -> Result<Arc<Market>> {
189 let cache = self.market_cache.read().await;
190
191 if !cache.is_loaded() {
192 drop(cache);
193 return Err(Error::exchange(
194 "-1",
195 "Markets not loaded. Call load_markets() first.",
196 ));
197 }
198
199 cache
200 .get_market(symbol)
201 .ok_or_else(|| Error::bad_symbol(format!("Market {symbol} not found")))
202 }
203
204 pub async fn market_by_id(&self, id: &str) -> Result<Arc<Market>> {
206 let cache = self.market_cache.read().await;
207 cache
208 .get_market_by_id(id)
209 .ok_or_else(|| Error::bad_symbol(format!("Market with id {id} not found")))
210 }
211
212 pub async fn currency(&self, code: &str) -> Result<Arc<Currency>> {
214 let cache = self.market_cache.read().await;
215 cache
216 .get_currency(code)
217 .ok_or_else(|| Error::bad_symbol(format!("Currency {code} not found")))
218 }
219
220 pub async fn symbols(&self) -> Result<Vec<String>> {
222 let cache = self.market_cache.read().await;
223 Ok(cache.symbols())
224 }
225
226 #[deprecated(
228 since = "0.2.0",
229 note = "Rate limiting is now handled internally by HttpClient. This method is a no-op."
230 )]
231 pub fn throttle(&self) -> Result<()> {
232 Ok(())
233 }
234
235 pub fn check_required_credentials(&self) -> Result<()> {
237 if self.config.api_key.is_none() {
238 return Err(Error::authentication("API key is required"));
239 }
240 if self.config.secret.is_none() {
241 return Err(Error::authentication("API secret is required"));
242 }
243 Ok(())
244 }
245
246 pub fn nonce(&self) -> i64 {
248 crate::time::milliseconds()
249 }
250
251 pub fn build_query_string(&self, params: &HashMap<String, Value>) -> String {
253 RequestUtils::build_query_string(params)
254 }
255
256 pub fn parse_json(&self, response: &str) -> Result<Value> {
258 RequestUtils::parse_json(response)
259 }
260
261 pub fn handle_http_error(&self, status_code: u16, response: &str) -> Error {
263 RequestUtils::handle_http_error(status_code, response)
264 }
265
266 pub fn safe_string(&self, dict: &Value, key: &str) -> Option<String> {
268 RequestUtils::safe_string(dict, key)
269 }
270
271 pub fn safe_integer(&self, dict: &Value, key: &str) -> Option<i64> {
273 RequestUtils::safe_integer(dict, key)
274 }
275
276 pub fn safe_float(&self, dict: &Value, key: &str) -> Option<f64> {
278 RequestUtils::safe_float(dict, key)
279 }
280
281 pub fn safe_bool(&self, dict: &Value, key: &str) -> Option<bool> {
283 RequestUtils::safe_bool(dict, key)
284 }
285
286 pub fn parse_ticker(&self, ticker_data: &Value, market: Option<&Market>) -> Result<Ticker> {
288 let symbol = if let Some(m) = market {
289 m.symbol.clone()
290 } else {
291 self.safe_string(ticker_data, "symbol")
292 .ok_or_else(|| ParseError::missing_field("symbol"))?
293 };
294
295 let timestamp = self.safe_integer(ticker_data, "timestamp").unwrap_or(0);
296
297 Ok(Ticker {
298 symbol,
299 timestamp,
300 datetime: self.safe_string(ticker_data, "datetime"),
301 high: self.safe_decimal(ticker_data, "high").map(Price::new),
302 low: self.safe_decimal(ticker_data, "low").map(Price::new),
303 bid: self.safe_decimal(ticker_data, "bid").map(Price::new),
304 bid_volume: self.safe_decimal(ticker_data, "bidVolume").map(Amount::new),
305 ask: self.safe_decimal(ticker_data, "ask").map(Price::new),
306 ask_volume: self.safe_decimal(ticker_data, "askVolume").map(Amount::new),
307 vwap: self.safe_decimal(ticker_data, "vwap").map(Price::new),
308 open: self.safe_decimal(ticker_data, "open").map(Price::new),
309 close: self.safe_decimal(ticker_data, "close").map(Price::new),
310 last: self.safe_decimal(ticker_data, "last").map(Price::new),
311 previous_close: self
312 .safe_decimal(ticker_data, "previousClose")
313 .map(Price::new),
314 change: self.safe_decimal(ticker_data, "change").map(Price::new),
315 percentage: self.safe_decimal(ticker_data, "percentage"),
316 average: self.safe_decimal(ticker_data, "average").map(Price::new),
317 base_volume: self
318 .safe_decimal(ticker_data, "baseVolume")
319 .map(Amount::new),
320 quote_volume: self
321 .safe_decimal(ticker_data, "quoteVolume")
322 .map(Amount::new),
323 funding_rate: self.safe_decimal(ticker_data, "fundingRate"),
324 open_interest: self.safe_decimal(ticker_data, "openInterest"),
325 index_price: self.safe_decimal(ticker_data, "indexPrice").map(Price::new),
326 mark_price: self.safe_decimal(ticker_data, "markPrice").map(Price::new),
327 info: HashMap::new(),
328 })
329 }
330
331 pub fn parse_trade(&self, trade_data: &Value, market: Option<&Market>) -> Result<Trade> {
333 let symbol = if let Some(m) = market {
334 m.symbol.clone()
335 } else {
336 self.safe_string(trade_data, "symbol")
337 .ok_or_else(|| ParseError::missing_field("symbol"))?
338 };
339
340 let side = self
341 .safe_string(trade_data, "side")
342 .and_then(|s| match s.to_lowercase().as_str() {
343 "buy" => Some(OrderSide::Buy),
344 "sell" => Some(OrderSide::Sell),
345 _ => None,
346 })
347 .ok_or_else(|| ParseError::missing_field("side"))?;
348
349 let trade_type =
350 self.safe_string(trade_data, "type")
351 .and_then(|t| match t.to_lowercase().as_str() {
352 "limit" => Some(OrderType::Limit),
353 "market" => Some(OrderType::Market),
354 _ => None,
355 });
356
357 let taker_or_maker = self.safe_string(trade_data, "takerOrMaker").and_then(|s| {
358 match s.to_lowercase().as_str() {
359 "taker" => Some(TakerOrMaker::Taker),
360 "maker" => Some(TakerOrMaker::Maker),
361 _ => None,
362 }
363 });
364
365 Ok(Trade {
366 id: self.safe_string(trade_data, "id"),
367 order: self.safe_string(trade_data, "orderId"),
368 timestamp: self.safe_integer(trade_data, "timestamp").unwrap_or(0),
369 datetime: self.safe_string(trade_data, "datetime"),
370 symbol,
371 trade_type,
372 side,
373 taker_or_maker,
374 price: Price::new(
375 self.safe_decimal(trade_data, "price")
376 .unwrap_or(Decimal::ZERO),
377 ),
378 amount: Amount::new(
379 self.safe_decimal(trade_data, "amount")
380 .unwrap_or(Decimal::ZERO),
381 ),
382 cost: self.safe_decimal(trade_data, "cost").map(Cost::new),
383 fee: None,
384 info: if let Some(obj) = trade_data.as_object() {
385 obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
386 } else {
387 HashMap::new()
388 },
389 })
390 }
391
392 pub fn parse_order(&self, order_data: &Value, market: Option<&Market>) -> Result<Order> {
394 let symbol = if let Some(m) = market {
395 m.symbol.clone()
396 } else {
397 self.safe_string(order_data, "symbol")
398 .ok_or_else(|| ParseError::missing_field("symbol"))?
399 };
400
401 let order_type = self
402 .safe_string(order_data, "type")
403 .and_then(|t| match t.to_lowercase().as_str() {
404 "limit" => Some(OrderType::Limit),
405 "market" => Some(OrderType::Market),
406 _ => None,
407 })
408 .unwrap_or(OrderType::Limit);
409
410 let side = self
411 .safe_string(order_data, "side")
412 .and_then(|s| match s.to_lowercase().as_str() {
413 "buy" => Some(OrderSide::Buy),
414 "sell" => Some(OrderSide::Sell),
415 _ => None,
416 })
417 .unwrap_or(OrderSide::Buy);
418
419 let status_str = self
420 .safe_string(order_data, "status")
421 .unwrap_or_else(|| "open".to_string());
422 #[allow(clippy::match_same_arms)]
423 let status = match status_str.to_lowercase().as_str() {
424 "open" => OrderStatus::Open,
425 "closed" => OrderStatus::Closed,
426 "canceled" | "cancelled" => OrderStatus::Cancelled,
427 "expired" => OrderStatus::Expired,
428 "rejected" => OrderStatus::Rejected,
429 _ => OrderStatus::Open,
430 };
431
432 let id = self
433 .safe_string(order_data, "id")
434 .unwrap_or_else(|| format!("order_{}", chrono::Utc::now().timestamp_millis()));
435 let amount = self
436 .safe_decimal(order_data, "amount")
437 .unwrap_or(Decimal::ZERO);
438
439 Ok(Order {
440 id,
441 client_order_id: self.safe_string(order_data, "clientOrderId"),
442 timestamp: self.safe_integer(order_data, "timestamp"),
443 datetime: self.safe_string(order_data, "datetime"),
444 last_trade_timestamp: self.safe_integer(order_data, "lastTradeTimestamp"),
445 symbol,
446 order_type,
447 time_in_force: self.safe_string(order_data, "timeInForce"),
448 post_only: self
449 .safe_string(order_data, "postOnly")
450 .and_then(|s| s.parse::<bool>().ok()),
451 reduce_only: self
452 .safe_string(order_data, "reduceOnly")
453 .and_then(|s| s.parse::<bool>().ok()),
454 side,
455 price: self.safe_decimal(order_data, "price"),
456 stop_price: self.safe_decimal(order_data, "stopPrice"),
457 trigger_price: self.safe_decimal(order_data, "triggerPrice"),
458 take_profit_price: self.safe_decimal(order_data, "takeProfitPrice"),
459 stop_loss_price: self.safe_decimal(order_data, "stopLossPrice"),
460 average: self.safe_decimal(order_data, "average"),
461 amount,
462 filled: self.safe_decimal(order_data, "filled"),
463 remaining: self.safe_decimal(order_data, "remaining"),
464 cost: self.safe_decimal(order_data, "cost"),
465 status,
466 fee: None,
467 fees: None,
468 trades: None,
469 trailing_delta: self.safe_decimal(order_data, "trailingDelta"),
470 trailing_percent: self.safe_decimal(order_data, "trailingPercent"),
471 activation_price: self.safe_decimal(order_data, "activationPrice"),
472 callback_rate: self.safe_decimal(order_data, "callbackRate"),
473 working_type: self.safe_string(order_data, "workingType"),
474 info: if let Some(obj) = order_data.as_object() {
475 obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
476 } else {
477 HashMap::new()
478 },
479 })
480 }
481
482 pub fn parse_balance(&self, balance_data: &Value) -> Result<Balance> {
484 let mut balance = Balance::new();
485
486 if let Some(obj) = balance_data.as_object() {
487 for (currency, balance_info) in obj {
488 if currency == "timestamp" || currency == "datetime" || currency == "info" {
489 continue;
490 }
491 let free = self
492 .safe_decimal(balance_info, "free")
493 .unwrap_or(Decimal::ZERO);
494 let used = self
495 .safe_decimal(balance_info, "used")
496 .unwrap_or(Decimal::ZERO);
497 let total = self
498 .safe_decimal(balance_info, "total")
499 .unwrap_or(free + used);
500
501 let entry = BalanceEntry { free, used, total };
502 balance.set(currency.clone(), entry);
503 }
504 }
505
506 if let Some(obj) = balance_data.as_object() {
507 balance.info = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
508 }
509
510 Ok(balance)
511 }
512
513 pub fn parse_order_book(
515 &self,
516 orderbook_data: &Value,
517 timestamp: Option<i64>,
518 ) -> Result<OrderBook> {
519 let mut bids_side = OrderBookSide::new();
520 let mut asks_side = OrderBookSide::new();
521
522 if let Some(bids_array) = orderbook_data.get("bids").and_then(|v| v.as_array()) {
523 for bid in bids_array {
524 if let Some(arr) = bid.as_array() {
525 #[allow(clippy::collapsible_if)]
526 if arr.len() >= 2 {
527 let price = self.safe_decimal_from_value(&arr[0]);
528 let amount = self.safe_decimal_from_value(&arr[1]);
529 if let (Some(p), Some(a)) = (price, amount) {
530 bids_side.push(OrderBookEntry {
531 price: Price::new(p),
532 amount: Amount::new(a),
533 });
534 }
535 }
536 }
537 }
538 }
539
540 if let Some(asks_array) = orderbook_data.get("asks").and_then(|v| v.as_array()) {
541 for ask in asks_array {
542 if let Some(arr) = ask.as_array() {
543 #[allow(clippy::collapsible_if)]
544 if arr.len() >= 2 {
545 let price = self.safe_decimal_from_value(&arr[0]);
546 let amount = self.safe_decimal_from_value(&arr[1]);
547 if let (Some(p), Some(a)) = (price, amount) {
548 asks_side.push(OrderBookEntry {
549 price: Price::new(p),
550 amount: Amount::new(a),
551 });
552 }
553 }
554 }
555 }
556 }
557
558 Ok(OrderBook {
559 symbol: self
560 .safe_string(orderbook_data, "symbol")
561 .unwrap_or_default(),
562 bids: bids_side,
563 asks: asks_side,
564 timestamp: timestamp
565 .or_else(|| self.safe_integer(orderbook_data, "timestamp"))
566 .unwrap_or(0),
567 datetime: self.safe_string(orderbook_data, "datetime"),
568 nonce: self.safe_integer(orderbook_data, "nonce"),
569 info: if let Some(obj) = orderbook_data.as_object() {
570 obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
571 } else {
572 HashMap::new()
573 },
574 buffered_deltas: std::collections::VecDeque::new(),
575 bids_map: std::collections::BTreeMap::new(),
576 asks_map: std::collections::BTreeMap::new(),
577 is_synced: false,
578 needs_resync: false,
579 last_resync_time: 0,
580 })
581 }
582
583 fn safe_decimal(&self, data: &Value, key: &str) -> Option<Decimal> {
584 data.get(key).and_then(|v| self.safe_decimal_from_value(v))
585 }
586
587 #[allow(clippy::unused_self)]
588 fn safe_decimal_from_value(&self, value: &Value) -> Option<Decimal> {
589 match value {
590 Value::Number(n) => {
591 if let Some(f) = n.as_f64() {
592 Decimal::from_f64_retain(f)
593 } else {
594 None
595 }
596 }
597 Value::String(s) => Decimal::from_str(s).ok(),
598 _ => None,
599 }
600 }
601
602 pub async fn calculate_fee(
604 &self,
605 symbol: &str,
606 _order_type: OrderType,
607 _side: OrderSide,
608 amount: Decimal,
609 price: Decimal,
610 taker_or_maker: Option<&str>,
611 ) -> Result<Fee> {
612 let market = self.market(symbol).await?;
613
614 let rate = if let Some(tom) = taker_or_maker {
615 if tom == "taker" {
616 market.taker.unwrap_or(Decimal::ZERO)
617 } else {
618 market.maker.unwrap_or(Decimal::ZERO)
619 }
620 } else {
621 market.taker.unwrap_or(Decimal::ZERO)
622 };
623
624 let cost = amount * price;
625 let fee_cost = cost * rate;
626
627 Ok(Fee {
628 currency: market.quote.clone(),
629 cost: fee_cost,
630 rate: Some(rate),
631 })
632 }
633
634 pub async fn amount_to_precision(&self, symbol: &str, amount: Decimal) -> Result<Decimal> {
636 let market = self.market(symbol).await?;
637 match market.precision.amount {
638 Some(precision_value) => Ok(self.round_to_precision(amount, precision_value)),
639 None => Ok(amount),
640 }
641 }
642
643 pub async fn price_to_precision(&self, symbol: &str, price: Decimal) -> Result<Decimal> {
645 let market = self.market(symbol).await?;
646 match market.precision.price {
647 Some(precision_value) => Ok(self.round_to_precision(price, precision_value)),
648 None => Ok(price),
649 }
650 }
651
652 pub async fn cost_to_precision(&self, symbol: &str, cost: Decimal) -> Result<Decimal> {
654 let market = self.market(symbol).await?;
655 match market.precision.price {
656 Some(precision_value) => Ok(self.round_to_precision(cost, precision_value)),
657 None => Ok(cost),
658 }
659 }
660
661 #[allow(clippy::unused_self)]
662 fn round_to_precision(&self, value: Decimal, precision_value: Decimal) -> Decimal {
663 if precision_value < Decimal::ONE {
664 let steps = (value / precision_value).round();
665 steps * precision_value
666 } else {
667 let digits = precision_value.to_u32().unwrap_or(8);
668 let multiplier = Decimal::from(10_i64.pow(digits));
669 let scaled = value * multiplier;
670 let rounded = scaled.round();
671 rounded / multiplier
672 }
673 }
674
675 pub async fn calculate_cost(
677 &self,
678 symbol: &str,
679 amount: Decimal,
680 price: Decimal,
681 ) -> Result<Decimal> {
682 let _market = self.market(symbol).await?;
683 Ok(amount * price)
684 }
685}
686
687#[cfg(test)]
688#[allow(clippy::disallowed_methods)] #[allow(clippy::default_trait_access)] mod tests {
691 use super::*;
692
693 #[tokio::test]
694 async fn test_base_exchange_creation() {
695 let config = ExchangeConfig {
696 id: "test".to_string(),
697 name: "Test Exchange".to_string(),
698 ..Default::default()
699 };
700 let exchange = BaseExchange::new(config).unwrap();
701 assert_eq!(exchange.config.id, "test");
702 assert!(exchange.config.enable_rate_limit);
703 }
704
705 #[tokio::test]
706 async fn test_market_cache() {
707 let config = ExchangeConfig {
708 id: "test".to_string(),
709 ..Default::default()
710 };
711 let exchange = BaseExchange::new(config).unwrap();
712
713 let markets = vec![Market {
714 id: "btcusdt".to_string(),
715 symbol: "BTC/USDT".to_string(),
716 parsed_symbol: None,
717 base: "BTC".to_string(),
718 quote: "USDT".to_string(),
719 active: true,
720 market_type: MarketType::Spot,
721 margin: false,
722 settle: None,
723 base_id: None,
724 quote_id: None,
725 settle_id: None,
726 contract: None,
727 linear: None,
728 inverse: None,
729 contract_size: None,
730 expiry: None,
731 expiry_datetime: None,
732 strike: None,
733 option_type: None,
734 precision: Default::default(),
735 limits: Default::default(),
736 maker: None,
737 taker: None,
738 percentage: None,
739 tier_based: None,
740 fee_side: None,
741 info: Default::default(),
742 }];
743
744 let _ = exchange.set_markets(markets, None).await.unwrap();
745 let market = exchange.market("BTC/USDT").await.unwrap();
746 assert_eq!(market.symbol, "BTC/USDT");
747
748 let symbols = exchange.symbols().await.unwrap();
749 assert_eq!(symbols.len(), 1);
750 }
751
752 #[test]
753 fn test_build_query_string() {
754 let config = ExchangeConfig::default();
755 let exchange = BaseExchange::new(config).unwrap();
756
757 let mut params = HashMap::new();
758 params.insert("symbol".to_string(), Value::String("BTC/USDT".to_string()));
759 params.insert("limit".to_string(), Value::Number(100.into()));
760
761 let query = exchange.build_query_string(¶ms);
762 assert!(query.contains("symbol="));
763 assert!(query.contains("limit="));
764 }
765}