Skip to main content

ccxt_core/base_exchange/
mod.rs

1//! Base exchange implementation
2//!
3//! Provides core functionality for all exchange implementations:
4//! - Market data caching
5//! - API configuration management
6//! - Common request/response handling
7//! - Authentication and signing
8//! - Rate limiting
9
10mod 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/// Base exchange implementation
35#[derive(Debug, Clone)]
36pub struct BaseExchange {
37    /// Exchange configuration
38    pub config: ExchangeConfig,
39    /// HTTP client for API requests
40    pub http_client: HttpClient,
41    /// Thread-safe market data cache
42    pub market_cache: Arc<RwLock<MarketCache>>,
43    /// Mutex to serialize market loading operations
44    pub market_loading_lock: Arc<Mutex<()>>,
45    /// Exchange capability flags
46    pub capabilities: ExchangeCapabilities,
47    /// API endpoint URLs
48    pub urls: HashMap<String, String>,
49    /// Timeframe mappings
50    pub timeframes: HashMap<String, String>,
51    /// Precision mode for price/amount formatting
52    pub precision_mode: PrecisionMode,
53}
54
55impl BaseExchange {
56    /// Creates a new exchange instance
57    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, // 128MB default
97            max_request_size: 10 * 1024 * 1024,   // 10MB default
98            circuit_breaker: None,                // Disabled by default for backward compatibility
99            pool_max_idle_per_host: 10,           // Default: 10 idle connections per host
100            pool_idle_timeout: Duration::from_secs(90), // Default: 90 seconds
101        };
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    /// Loads market data from the exchange
125    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    /// Loads market data with a custom loader function
142    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    /// Sets market and currency data in the cache
178    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    /// Gets market information by trading symbol
188    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    /// Gets market information by exchange-specific market ID
205    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    /// Gets currency information by currency code
213    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    /// Gets all available trading symbols
221    pub async fn symbols(&self) -> Result<Vec<String>> {
222        let cache = self.market_cache.read().await;
223        Ok(cache.symbols())
224    }
225
226    /// Applies rate limiting if enabled (deprecated, now handled by HttpClient)
227    #[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    /// Checks that required API credentials are configured
236    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    /// Gets a nonce value (current timestamp in milliseconds)
247    pub fn nonce(&self) -> i64 {
248        crate::time::milliseconds()
249    }
250
251    /// Builds a URL query string from parameters
252    pub fn build_query_string(&self, params: &HashMap<String, Value>) -> String {
253        RequestUtils::build_query_string(params)
254    }
255
256    /// Parses a JSON response string
257    pub fn parse_json(&self, response: &str) -> Result<Value> {
258        RequestUtils::parse_json(response)
259    }
260
261    /// Handles HTTP error responses
262    pub fn handle_http_error(&self, status_code: u16, response: &str) -> Error {
263        RequestUtils::handle_http_error(status_code, response)
264    }
265
266    /// Safely extracts a string value from a JSON object
267    pub fn safe_string(&self, dict: &Value, key: &str) -> Option<String> {
268        RequestUtils::safe_string(dict, key)
269    }
270
271    /// Safely extracts an integer value from a JSON object
272    pub fn safe_integer(&self, dict: &Value, key: &str) -> Option<i64> {
273        RequestUtils::safe_integer(dict, key)
274    }
275
276    /// Safely extracts a float value from a JSON object
277    pub fn safe_float(&self, dict: &Value, key: &str) -> Option<f64> {
278        RequestUtils::safe_float(dict, key)
279    }
280
281    /// Safely extracts a boolean value from a JSON object
282    pub fn safe_bool(&self, dict: &Value, key: &str) -> Option<bool> {
283        RequestUtils::safe_bool(dict, key)
284    }
285
286    /// Parses raw ticker data from exchange API response
287    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    /// Parses raw trade data from exchange API response
332    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    /// Parses raw order data from exchange API response
393    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    /// Parses raw balance data from exchange API response
483    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    /// Parses raw order book data from exchange API response
514    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    /// Calculates trading fee for a given order
603    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    /// Converts an amount to the precision required by the market
635    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    /// Converts a price to the precision required by the market
644    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    /// Converts a cost to the precision required by the market
653    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    /// Calculates the cost of a trade
676    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)] // unwrap() is acceptable in tests
689#[allow(clippy::default_trait_access)] // Default::default() is acceptable in tests
690mod 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(&params);
762        assert!(query.contains("symbol="));
763        assert!(query.contains("limit="));
764    }
765}