rusty_ems/exchanges/
bithumb_rest_client.rs

1//! Comprehensive Bithumb REST API Client Implementation
2//!
3//! Provides complete REST API integration for Bithumb exchange covering:
4//! - Account management (balances, deposit addresses)
5//! - Order management (place, cancel, query, history)
6//! - Trade history and execution details
7//! - Deposits and withdrawals management
8//! - High-performance optimizations for HFT
9//!
10//! Reference: <https://apidocs.bithumb.com/v2.1.5>/
11
12use crate::error::{EMSError, Result as EMSResult};
13use reqwest::Client;
14use rust_decimal::Decimal;
15use rusty_common::SmartString;
16use rusty_common::auth::exchanges::bithumb::BithumbAuth;
17use serde::{Deserialize, Serialize};
18use simd_json;
19use smallvec::SmallVec;
20use std::sync::Arc;
21use std::time::Duration;
22
23/// Bithumb REST API client for comprehensive trading operations
24#[derive(Debug, Clone)]
25pub struct BithumbRestClient {
26    /// Authentication handler
27    auth: Arc<BithumbAuth>,
28    /// HTTP client for REST API requests
29    client: Arc<Client>,
30    /// Base API URL
31    base_url: SmartString,
32}
33
34/// Account balance information
35#[derive(Debug, Clone, Deserialize)]
36pub struct BithumbAccount {
37    /// Currency code (e.g., "KRW", "BTC")
38    pub currency: SmartString,
39    /// Available balance for trading
40    pub balance: SmartString,
41    /// Locked balance in orders
42    pub locked: SmartString,
43    /// Average buy price
44    pub avg_buy_price: SmartString,
45    /// Whether average buy price was modified
46    pub avg_buy_price_modified: bool,
47    /// Unit currency for average price
48    pub unit_currency: SmartString,
49}
50
51/// Order information from Bithumb API
52#[derive(Debug, Clone, Deserialize)]
53pub struct BithumbOrderInfo {
54    /// Order UUID
55    pub uuid: SmartString,
56    /// Order side (bid/ask)
57    pub side: SmartString,
58    /// Order type (limit/price/market)
59    pub ord_type: SmartString,
60    /// Order price
61    pub price: SmartString,
62    /// Order state (wait/trade/done/cancel)
63    pub state: SmartString,
64    /// Market symbol
65    pub market: SmartString,
66    /// Order creation timestamp
67    pub created_at: SmartString,
68    /// Order volume
69    pub volume: SmartString,
70    /// Remaining volume
71    pub remaining_volume: SmartString,
72    /// Reserved fee
73    pub reserved_fee: SmartString,
74    /// Remaining fee
75    pub remaining_fee: SmartString,
76    /// Paid fee
77    pub paid_fee: SmartString,
78    /// Locked amount
79    pub locked: SmartString,
80    /// Executed volume
81    pub executed_volume: SmartString,
82    /// Number of trades
83    pub trades_count: u64,
84    /// Trade details
85    #[serde(default)]
86    pub trades: Vec<BithumbTrade>,
87}
88
89/// Trade execution details
90#[derive(Debug, Clone, Deserialize)]
91pub struct BithumbTrade {
92    /// Market symbol
93    pub market: SmartString,
94    /// Trade UUID
95    pub uuid: SmartString,
96    /// Trade price
97    pub price: SmartString,
98    /// Trade volume
99    pub volume: SmartString,
100    /// Trade funds (total value)
101    pub funds: SmartString,
102    /// Trade side (bid/ask)
103    pub side: SmartString,
104    /// Trade timestamp
105    pub created_at: SmartString,
106}
107
108/// Order creation request
109#[derive(Debug, Clone, Serialize)]
110pub struct BithumbOrderRequest {
111    /// Market ID (e.g., "KRW-BTC")
112    pub market: SmartString,
113    /// Order side (bid/ask)
114    pub side: SmartString,
115    /// Order type (limit/price/market)
116    pub order_type: SmartString,
117    /// Order price (required for limit and market buy orders)
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub price: Option<SmartString>,
120    /// Order volume (required for limit and market sell orders)
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub volume: Option<SmartString>,
123}
124
125/// Order creation response
126#[derive(Debug, Clone, Deserialize)]
127pub struct BithumbOrderResponse {
128    /// Order UUID
129    pub uuid: SmartString,
130    /// Market symbol
131    pub market: SmartString,
132    /// Order side
133    pub side: SmartString,
134    /// Order type
135    pub ord_type: SmartString,
136    /// Order price
137    #[serde(default)]
138    pub price: SmartString,
139    /// Order volume
140    #[serde(default)]
141    pub volume: SmartString,
142    /// Order creation timestamp
143    pub created_at: SmartString,
144}
145
146/// Deposit address information
147#[derive(Debug, Clone, Deserialize)]
148pub struct BithumbDepositAddress {
149    /// Currency code
150    pub currency: SmartString,
151    /// Deposit address
152    pub deposit_address: SmartString,
153    /// Secondary address (e.g., memo/tag)
154    pub secondary_address: Option<SmartString>,
155    /// Network type
156    pub net_type: Option<SmartString>,
157}
158
159/// Deposit transaction information
160#[derive(Debug, Clone, Deserialize)]
161pub struct BithumbDeposit {
162    /// Transaction type
163    #[serde(rename = "type")]
164    pub transaction_type: SmartString,
165    /// Transaction UUID
166    pub uuid: SmartString,
167    /// Currency code
168    pub currency: SmartString,
169    /// Transaction hash
170    pub txid: Option<SmartString>,
171    /// Transaction state
172    pub state: SmartString,
173    /// Transaction timestamp
174    pub created_at: SmartString,
175    /// Transaction amount
176    pub amount: SmartString,
177    /// Transaction fee
178    pub fee: SmartString,
179}
180
181/// Withdrawal transaction information
182#[derive(Debug, Clone, Deserialize)]
183pub struct BithumbWithdrawal {
184    /// Transaction type
185    #[serde(rename = "type")]
186    pub transaction_type: SmartString,
187    /// Transaction UUID
188    pub uuid: SmartString,
189    /// Currency code
190    pub currency: SmartString,
191    /// Transaction hash
192    pub txid: Option<SmartString>,
193    /// Transaction state
194    pub state: SmartString,
195    /// Transaction timestamp
196    pub created_at: SmartString,
197    /// Transaction amount
198    pub amount: SmartString,
199    /// Transaction fee
200    pub fee: SmartString,
201    /// Withdrawal address
202    pub address: Option<SmartString>,
203    /// Secondary address
204    pub secondary_address: Option<SmartString>,
205}
206
207/// Generic error response from Bithumb API
208#[derive(Debug, Deserialize)]
209pub struct BithumbErrorResponse {
210    /// Error details from the API response
211    pub error: BithumbError,
212}
213
214/// Error details from Bithumb API
215#[derive(Debug, Deserialize)]
216pub struct BithumbError {
217    /// Error name/type identifier
218    pub name: SmartString,
219    /// Human-readable error message
220    pub message: SmartString,
221}
222
223impl BithumbRestClient {
224    /// Create a new Bithumb REST client
225    #[must_use]
226    pub fn new(auth: Arc<BithumbAuth>) -> Self {
227        let client = Client::builder()
228            .timeout(Duration::from_secs(30))
229            .pool_max_idle_per_host(10)
230            .pool_idle_timeout(Some(Duration::from_secs(30)))
231            .build()
232            .expect("Failed to build HTTP client");
233
234        Self {
235            auth,
236            client: Arc::new(client),
237            base_url: "https://api.bithumb.com/v1".into(),
238        }
239    }
240
241    /// Handle Bithumb API errors and convert to EMS errors
242    fn handle_api_error(status: u16, error_response: BithumbErrorResponse) -> EMSError {
243        let error_name = &error_response.error.name;
244        let error_message = &error_response.error.message;
245
246        match error_name.as_str() {
247            "invalid_access_key" | "invalid_secret_key" | "jwt_verification" => {
248                EMSError::auth(format!("Authentication failed: {error_message}"))
249            }
250            "too_many_requests" => {
251                EMSError::rate_limit(
252                    format!("Rate limit exceeded: {error_message}"),
253                    Some(60000), // 60 second default retry delay
254                )
255            }
256            "insufficient_funds" | "insufficient_funds_bid" | "insufficient_funds_ask" => {
257                EMSError::insufficient_balance(format!("Insufficient balance: {error_message}"))
258            }
259            "invalid_parameter" | "invalid_query_format" | "invalid_order_id" => {
260                EMSError::invalid_params(format!("Invalid parameters: {error_message}"))
261            }
262            "market_does_not_exist" | "order_not_found" => {
263                EMSError::instrument_not_found(format!("Not found: {error_message}"))
264            }
265            "order_already_canceled" | "order_cancel_error" => {
266                EMSError::order_cancellation(format!("Order cancellation error: {error_message}"))
267            }
268            _ => EMSError::exchange_api(
269                "Bithumb",
270                i32::from(status),
271                "API Error",
272                Some(format!("{error_name}: {error_message}")),
273            ),
274        }
275    }
276
277    /// Make authenticated GET request
278    async fn get_authenticated<T>(
279        &self,
280        endpoint: &str,
281        query_params: Option<&[(&str, &str)]>,
282    ) -> EMSResult<T>
283    where
284        T: for<'de> Deserialize<'de>,
285    {
286        let headers = self
287            .auth
288            .generate_headers("GET", endpoint, query_params)
289            .map_err(|e| EMSError::auth(format!("Header generation failed: {e}")))?;
290
291        let mut url = format!("{}{}", self.base_url, endpoint);
292        if let Some(params) = query_params
293            && !params.is_empty()
294        {
295            url.push('?');
296            for (i, (key, value)) in params.iter().enumerate() {
297                if i > 0 {
298                    url.push('&');
299                }
300                url.push_str(key);
301                url.push('=');
302                url.push_str(value);
303            }
304        }
305
306        let mut request = self.client.get(&url);
307        for (key, value) in &headers {
308            request = request.header(key.as_str(), value.as_str());
309        }
310
311        let response = request
312            .send()
313            .await
314            .map_err(|e| EMSError::connection(format!("Request failed: {e}")))?;
315
316        let status = response.status();
317        let response_text = response
318            .text()
319            .await
320            .map_err(|e| EMSError::connection(format!("Failed to read response: {e}")))?;
321
322        if status.is_success() {
323            let mut response_bytes = response_text.into_bytes();
324            simd_json::from_slice(&mut response_bytes).map_err(|e| {
325                EMSError::exchange_api("Bithumb", -1, "JSON parsing failed", Some(e.to_string()))
326            })
327        } else {
328            let mut error_bytes = response_text.into_bytes();
329            let error_response: BithumbErrorResponse = simd_json::from_slice(&mut error_bytes)
330                .map_err(|e| {
331                    EMSError::exchange_api(
332                        "Bithumb",
333                        i32::from(status.as_u16()),
334                        "Failed to parse error response",
335                        Some(e.to_string()),
336                    )
337                })?;
338
339            Err(Self::handle_api_error(status.as_u16(), error_response))
340        }
341    }
342
343    /// Make authenticated POST request
344    async fn post_authenticated<T, R>(&self, endpoint: &str, body: &T) -> EMSResult<R>
345    where
346        T: Serialize,
347        R: for<'de> Deserialize<'de>,
348    {
349        let body_string = simd_json::to_string(body).map_err(|e| {
350            EMSError::exchange_api(
351                "Bithumb",
352                -1,
353                "JSON serialization failed",
354                Some(e.to_string()),
355            )
356        })?;
357
358        // For POST requests, we'll use a simplified approach for authentication
359        // The parameters will be extracted from the request body if needed
360        let params: &[(&str, &str)] = &[];
361
362        let headers = self
363            .auth
364            .generate_headers("POST", endpoint, Some(params))
365            .map_err(|e| EMSError::auth(format!("Header generation failed: {e}")))?;
366
367        let url = format!("{}{}", self.base_url, endpoint);
368        let mut request = self.client.post(&url);
369
370        for (key, value) in &headers {
371            request = request.header(key.as_str(), value.as_str());
372        }
373
374        let response = request
375            .body(body_string)
376            .send()
377            .await
378            .map_err(|e| EMSError::connection(format!("Request failed: {e}")))?;
379
380        let status = response.status();
381        let response_text = response
382            .text()
383            .await
384            .map_err(|e| EMSError::connection(format!("Failed to read response: {e}")))?;
385
386        if status.is_success() {
387            let mut response_bytes = response_text.into_bytes();
388            simd_json::from_slice(&mut response_bytes).map_err(|e| {
389                EMSError::exchange_api("Bithumb", -1, "JSON parsing failed", Some(e.to_string()))
390            })
391        } else {
392            let mut error_bytes = response_text.into_bytes();
393            let error_response: BithumbErrorResponse = simd_json::from_slice(&mut error_bytes)
394                .map_err(|e| {
395                    EMSError::exchange_api(
396                        "Bithumb",
397                        i32::from(status.as_u16()),
398                        "Failed to parse error response",
399                        Some(e.to_string()),
400                    )
401                })?;
402
403            Err(Self::handle_api_error(status.as_u16(), error_response))
404        }
405    }
406
407    /// Make authenticated DELETE request
408    async fn delete_authenticated<T>(
409        &self,
410        endpoint: &str,
411        query_params: Option<&[(&str, &str)]>,
412    ) -> EMSResult<T>
413    where
414        T: for<'de> Deserialize<'de>,
415    {
416        let headers = self
417            .auth
418            .generate_headers("DELETE", endpoint, query_params)
419            .map_err(|e| EMSError::auth(format!("Header generation failed: {e}")))?;
420
421        let mut url = format!("{}{}", self.base_url, endpoint);
422        if let Some(params) = query_params
423            && !params.is_empty()
424        {
425            url.push('?');
426            for (i, (key, value)) in params.iter().enumerate() {
427                if i > 0 {
428                    url.push('&');
429                }
430                url.push_str(key);
431                url.push('=');
432                url.push_str(value);
433            }
434        }
435
436        let mut request = self.client.delete(&url);
437        for (key, value) in &headers {
438            request = request.header(key.as_str(), value.as_str());
439        }
440
441        let response = request
442            .send()
443            .await
444            .map_err(|e| EMSError::connection(format!("Request failed: {e}")))?;
445
446        let status = response.status();
447        let response_text = response
448            .text()
449            .await
450            .map_err(|e| EMSError::connection(format!("Failed to read response: {e}")))?;
451
452        if status.is_success() {
453            let mut response_bytes = response_text.into_bytes();
454            simd_json::from_slice(&mut response_bytes).map_err(|e| {
455                EMSError::exchange_api("Bithumb", -1, "JSON parsing failed", Some(e.to_string()))
456            })
457        } else {
458            let mut error_bytes = response_text.into_bytes();
459            let error_response: BithumbErrorResponse = simd_json::from_slice(&mut error_bytes)
460                .map_err(|e| {
461                    EMSError::exchange_api(
462                        "Bithumb",
463                        i32::from(status.as_u16()),
464                        "Failed to parse error response",
465                        Some(e.to_string()),
466                    )
467                })?;
468
469            Err(Self::handle_api_error(status.as_u16(), error_response))
470        }
471    }
472
473    /// Get all account balances
474    pub async fn get_accounts(&self) -> EMSResult<SmallVec<[BithumbAccount; 16]>> {
475        let accounts: Vec<BithumbAccount> = self.get_authenticated("/accounts", None).await?;
476        Ok(accounts.into_iter().collect())
477    }
478
479    /// Get account balance for specific currency
480    pub async fn get_account_balance(&self, currency: &str) -> EMSResult<Option<BithumbAccount>> {
481        let accounts = self.get_accounts().await?;
482        Ok(accounts
483            .into_iter()
484            .find(|account| account.currency == currency))
485    }
486
487    /// Place a new order
488    pub async fn place_order(
489        &self,
490        order_request: BithumbOrderRequest,
491    ) -> EMSResult<BithumbOrderResponse> {
492        self.post_authenticated("/orders", &order_request).await
493    }
494
495    /// Get order details by UUID
496    pub async fn get_order(&self, uuid: &str) -> EMSResult<BithumbOrderInfo> {
497        let params = [("uuid", uuid)];
498        self.get_authenticated("/order", Some(&params)).await
499    }
500
501    /// Get list of orders
502    #[allow(clippy::too_many_arguments)]
503    pub async fn get_orders(
504        &self,
505        market: Option<&str>,
506        state: Option<&str>,
507        states: Option<&[&str]>,
508        uuids: Option<&[&str]>,
509        page: Option<u32>,
510        limit: Option<u32>,
511        order_by: Option<&str>,
512    ) -> EMSResult<SmallVec<[BithumbOrderInfo; 32]>> {
513        let mut params = Vec::new();
514
515        if let Some(m) = market {
516            params.push(("market", m));
517        }
518
519        if let Some(s) = state {
520            params.push(("state", s));
521        }
522
523        if let Some(states_list) = states {
524            for state in states_list {
525                params.push(("states[]", state));
526            }
527        }
528
529        if let Some(uuids_list) = uuids {
530            for uuid in uuids_list {
531                params.push(("uuids[]", uuid));
532            }
533        }
534
535        // Create owned strings for page and limit to fix lifetime issues
536        let page_str = page.map(|p| p.to_string());
537        let limit_str = limit.map(|l| l.to_string());
538
539        if let Some(ref p) = page_str {
540            params.push(("page", p.as_str()));
541        }
542
543        if let Some(ref l) = limit_str {
544            params.push(("limit", l.as_str()));
545        }
546
547        if let Some(ob) = order_by {
548            params.push(("order_by", ob));
549        }
550
551        let orders: Vec<BithumbOrderInfo> =
552            self.get_authenticated("/orders", Some(&params)).await?;
553        Ok(orders.into_iter().collect())
554    }
555
556    /// Cancel an order by UUID
557    pub async fn cancel_order(&self, uuid: &str) -> EMSResult<BithumbOrderInfo> {
558        let params = [("uuid", uuid)];
559        self.delete_authenticated("/order", Some(&params)).await
560    }
561
562    /// Get order trades/executions
563    pub async fn get_order_trades(&self, uuid: &str) -> EMSResult<SmallVec<[BithumbTrade; 32]>> {
564        let params = [("uuid", uuid)];
565        let trades: Vec<BithumbTrade> = self
566            .get_authenticated("/order/trades", Some(&params))
567            .await?;
568        Ok(trades.into_iter().collect())
569    }
570
571    /// Get trade history
572    pub async fn get_trades(
573        &self,
574        market: Option<&str>,
575        uuids: Option<&[&str]>,
576        limit: Option<u32>,
577        page: Option<u32>,
578        order_by: Option<&str>,
579    ) -> EMSResult<SmallVec<[BithumbTrade; 32]>> {
580        let mut params = Vec::new();
581
582        if let Some(m) = market {
583            params.push(("market", m));
584        }
585
586        if let Some(uuids_list) = uuids {
587            for uuid in uuids_list {
588                params.push(("uuids[]", uuid));
589            }
590        }
591
592        // Create owned strings for limit and page to fix lifetime issues
593        let limit_str = limit.map(|l| l.to_string());
594        let page_str = page.map(|p| p.to_string());
595
596        if let Some(ref l) = limit_str {
597            params.push(("limit", l.as_str()));
598        }
599
600        if let Some(ref p) = page_str {
601            params.push(("page", p.as_str()));
602        }
603
604        if let Some(ob) = order_by {
605            params.push(("order_by", ob));
606        }
607
608        let trades: Vec<BithumbTrade> = self.get_authenticated("/trades", Some(&params)).await?;
609        Ok(trades.into_iter().collect())
610    }
611
612    /// Get deposit addresses for all currencies
613    pub async fn get_deposit_addresses(&self) -> EMSResult<SmallVec<[BithumbDepositAddress; 16]>> {
614        let addresses: Vec<BithumbDepositAddress> = self
615            .get_authenticated("/deposits/coin_addresses", None)
616            .await?;
617        Ok(addresses.into_iter().collect())
618    }
619
620    /// Get deposit address for specific currency
621    pub async fn get_deposit_address(&self, currency: &str) -> EMSResult<BithumbDepositAddress> {
622        let params = [("currency", currency)];
623        self.get_authenticated("/deposits/coin_address", Some(&params))
624            .await
625    }
626
627    /// Get deposit history
628    #[allow(clippy::too_many_arguments)]
629    pub async fn get_deposits(
630        &self,
631        currency: Option<&str>,
632        state: Option<&str>,
633        uuids: Option<&[&str]>,
634        txids: Option<&[&str]>,
635        limit: Option<u32>,
636        page: Option<u32>,
637        order_by: Option<&str>,
638    ) -> EMSResult<SmallVec<[BithumbDeposit; 32]>> {
639        let mut params = Vec::new();
640
641        if let Some(c) = currency {
642            params.push(("currency", c));
643        }
644
645        if let Some(s) = state {
646            params.push(("state", s));
647        }
648
649        if let Some(uuids_list) = uuids {
650            for uuid in uuids_list {
651                params.push(("uuids[]", uuid));
652            }
653        }
654
655        if let Some(txids_list) = txids {
656            for txid in txids_list {
657                params.push(("txids[]", txid));
658            }
659        }
660
661        // Create owned strings for limit and page to fix lifetime issues
662        let limit_str = limit.map(|l| l.to_string());
663        let page_str = page.map(|p| p.to_string());
664
665        if let Some(ref l) = limit_str {
666            params.push(("limit", l.as_str()));
667        }
668
669        if let Some(ref p) = page_str {
670            params.push(("page", p.as_str()));
671        }
672
673        if let Some(ob) = order_by {
674            params.push(("order_by", ob));
675        }
676
677        let deposits: Vec<BithumbDeposit> =
678            self.get_authenticated("/deposits", Some(&params)).await?;
679        Ok(deposits.into_iter().collect())
680    }
681
682    /// Get withdrawal history
683    #[allow(clippy::too_many_arguments)]
684    pub async fn get_withdrawals(
685        &self,
686        currency: Option<&str>,
687        state: Option<&str>,
688        uuids: Option<&[&str]>,
689        txids: Option<&[&str]>,
690        limit: Option<u32>,
691        page: Option<u32>,
692        order_by: Option<&str>,
693    ) -> EMSResult<SmallVec<[BithumbWithdrawal; 32]>> {
694        let mut params = Vec::new();
695
696        if let Some(c) = currency {
697            params.push(("currency", c));
698        }
699
700        if let Some(s) = state {
701            params.push(("state", s));
702        }
703
704        if let Some(uuids_list) = uuids {
705            for uuid in uuids_list {
706                params.push(("uuids[]", uuid));
707            }
708        }
709
710        if let Some(txids_list) = txids {
711            for txid in txids_list {
712                params.push(("txids[]", txid));
713            }
714        }
715
716        // Create owned strings for limit and page to fix lifetime issues
717        let limit_str = limit.map(|l| l.to_string());
718        let page_str = page.map(|p| p.to_string());
719
720        if let Some(ref l) = limit_str {
721            params.push(("limit", l.as_str()));
722        }
723
724        if let Some(ref p) = page_str {
725            params.push(("page", p.as_str()));
726        }
727
728        if let Some(ob) = order_by {
729            params.push(("order_by", ob));
730        }
731
732        let withdrawals: Vec<BithumbWithdrawal> =
733            self.get_authenticated("/withdraws", Some(&params)).await?;
734        Ok(withdrawals.into_iter().collect())
735    }
736
737    /// Get total portfolio value in KRW
738    pub async fn get_total_balance_krw(&self) -> EMSResult<Decimal> {
739        let accounts = self.get_accounts().await?;
740        let mut total = Decimal::ZERO;
741
742        for account in accounts {
743            if account.currency == "KRW" {
744                let balance = Decimal::from_str_exact(&account.balance).map_err(|e| {
745                    EMSError::exchange_api(
746                        "Bithumb",
747                        -1,
748                        "Failed to parse balance",
749                        Some(e.to_string()),
750                    )
751                })?;
752                total += balance;
753            } else {
754                // For crypto currencies, would need market prices to calculate KRW value
755                // This would require additional market data API calls
756                let balance = Decimal::from_str_exact(&account.balance).map_err(|e| {
757                    EMSError::exchange_api(
758                        "Bithumb",
759                        -1,
760                        "Failed to parse balance",
761                        Some(e.to_string()),
762                    )
763                })?;
764                let avg_price = Decimal::from_str_exact(&account.avg_buy_price).map_err(|e| {
765                    EMSError::exchange_api(
766                        "Bithumb",
767                        -1,
768                        "Failed to parse avg price",
769                        Some(e.to_string()),
770                    )
771                })?;
772
773                // Rough estimation using average buy price
774                total += balance * avg_price;
775            }
776        }
777
778        Ok(total)
779    }
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785    use rusty_common::auth::exchanges::bithumb::BithumbAuth;
786
787    fn create_test_client() -> BithumbRestClient {
788        let auth = Arc::new(BithumbAuth::new("test_key".into(), "test_secret".into()));
789        BithumbRestClient::new(auth)
790    }
791
792    #[test]
793    fn test_client_creation() {
794        let client = create_test_client();
795        assert_eq!(client.base_url, "https://api.bithumb.com/v1");
796    }
797
798    #[test]
799    fn test_error_handling() {
800        let error_response = BithumbErrorResponse {
801            error: BithumbError {
802                name: "too_many_requests".into(),
803                message: "Rate limit exceeded".into(),
804            },
805        };
806
807        let ems_error = BithumbRestClient::handle_api_error(429, error_response);
808        assert!(matches!(ems_error, EMSError::RateLimitExceeded { .. }));
809    }
810
811    #[test]
812    fn test_authentication_error_mapping() {
813        let error_response = BithumbErrorResponse {
814            error: BithumbError {
815                name: "invalid_access_key".into(),
816                message: "Invalid API key".into(),
817            },
818        };
819
820        let ems_error = BithumbRestClient::handle_api_error(401, error_response);
821        assert!(matches!(ems_error, EMSError::AuthenticationError(_)));
822    }
823
824    #[test]
825    fn test_insufficient_balance_error_mapping() {
826        let error_response = BithumbErrorResponse {
827            error: BithumbError {
828                name: "insufficient_funds".into(),
829                message: "Not enough balance".into(),
830            },
831        };
832
833        let ems_error = BithumbRestClient::handle_api_error(400, error_response);
834        assert!(matches!(ems_error, EMSError::InsufficientBalance(_)));
835    }
836
837    #[test]
838    fn test_order_request_serialization() {
839        let order_request = BithumbOrderRequest {
840            market: "KRW-BTC".into(),
841            side: "bid".into(),
842            order_type: "limit".into(),
843            price: Some("50000000".into()),
844            volume: Some("0.001".into()),
845        };
846
847        let json = simd_json::to_string(&order_request).unwrap();
848        assert!(json.contains("KRW-BTC"));
849        assert!(json.contains("bid"));
850        assert!(json.contains("limit"));
851    }
852
853    #[test]
854    fn test_order_response_deserialization() {
855        let json = r#"{
856            "uuid": "123e4567-e89b-12d3-a456-426614174000",
857            "market": "KRW-BTC",
858            "side": "bid",
859            "ord_type": "limit",
860            "price": "50000000",
861            "volume": "0.001",
862            "created_at": "2023-01-01T00:00:00Z"
863        }"#;
864
865        let mut bytes = json.as_bytes().to_vec();
866        let response: BithumbOrderResponse = simd_json::from_slice(&mut bytes).unwrap();
867
868        assert_eq!(response.market, "KRW-BTC");
869        assert_eq!(response.side, "bid");
870        assert_eq!(response.ord_type, "limit");
871    }
872}