rusty_ems/exchanges/
upbit_rest_client.rs

1//! Comprehensive Upbit V1 REST API Client Implementation
2//!
3//! Provides complete REST API integration for Upbit exchange covering:
4//! - Account management (balances, deposits, withdrawals)
5//! - Order history and management (open/closed orders, order details)
6//! - Market data queries
7//! - High-performance optimizations for HFT
8//!
9//! Reference: <https://docs.upbit.com/reference>
10
11use crate::error::{EMSError, Result as EMSResult};
12use reqwest::Client;
13use rust_decimal::Decimal;
14use rusty_common::SmartString;
15use rusty_common::auth::exchanges::upbit::UpbitAuth;
16use serde::Deserialize;
17use simd_json;
18use smallvec::SmallVec;
19use std::sync::Arc;
20use std::time::Duration;
21
22/// Upbit REST API client for comprehensive trading operations
23#[derive(Debug, Clone)]
24pub struct UpbitRestClient {
25    /// Authentication handler
26    auth: Arc<UpbitAuth>,
27    /// HTTP client for REST API requests
28    client: Arc<Client>,
29    /// Base API URL
30    base_url: SmartString,
31}
32
33/// Account balance information
34#[derive(Debug, Clone, Deserialize)]
35pub struct UpbitAccount {
36    /// Currency code (e.g., "KRW", "BTC")
37    pub currency: SmartString,
38    /// Available balance for trading
39    pub balance: SmartString,
40    /// Locked balance in orders
41    pub locked: SmartString,
42    /// Average buy price
43    pub avg_buy_price: SmartString,
44    /// Whether average buy price was modified
45    pub avg_buy_price_modified: bool,
46    /// Unit currency for average price
47    pub unit_currency: SmartString,
48}
49
50/// Order information from Upbit API
51#[derive(Debug, Clone, Deserialize)]
52pub struct UpbitOrderInfo {
53    /// Order UUID
54    pub uuid: SmartString,
55    /// Order side (bid/ask)
56    pub side: SmartString,
57    /// Order type (limit/price/market)
58    pub ord_type: SmartString,
59    /// Order price (optional for market orders)
60    pub price: Option<SmartString>,
61    /// Order state (wait/watch/done/cancel)
62    pub state: SmartString,
63    /// Market symbol
64    pub market: SmartString,
65    /// Order creation timestamp
66    pub created_at: SmartString,
67    /// Order volume
68    pub volume: SmartString,
69    /// Remaining volume
70    pub remaining_volume: SmartString,
71    /// Reserved fee
72    pub reserved_fee: SmartString,
73    /// Remaining fee
74    pub remaining_fee: SmartString,
75    /// Paid fee
76    pub paid_fee: SmartString,
77    /// Locked amount
78    pub locked: SmartString,
79    /// Executed volume
80    pub executed_volume: SmartString,
81    /// Number of trades
82    pub trades_count: u64,
83    /// Time in force (optional)
84    pub time_in_force: Option<SmartString>,
85    /// Client identifier (optional)
86    pub identifier: Option<SmartString>,
87}
88
89/// Deposit address information
90#[derive(Debug, Clone, Deserialize)]
91pub struct UpbitDepositAddress {
92    /// Currency code
93    pub currency: SmartString,
94    /// Deposit address
95    pub deposit_address: SmartString,
96    /// Secondary address (e.g., memo/tag)
97    pub secondary_address: Option<SmartString>,
98}
99
100/// Deposit transaction information
101#[derive(Debug, Clone, Deserialize)]
102pub struct UpbitDeposit {
103    /// Transaction type
104    #[serde(rename = "type")]
105    pub transaction_type: SmartString,
106    /// Transaction UUID
107    pub uuid: SmartString,
108    /// Currency code
109    pub currency: SmartString,
110    /// Transaction hash
111    pub txid: Option<SmartString>,
112    /// Transaction state
113    pub state: SmartString,
114    /// Transaction timestamp
115    pub created_at: SmartString,
116    /// Transaction amount
117    pub amount: SmartString,
118    /// Transaction fee
119    pub fee: SmartString,
120    /// Network name
121    pub net_type: Option<SmartString>,
122}
123
124/// Withdrawal transaction information
125#[derive(Debug, Clone, Deserialize)]
126pub struct UpbitWithdrawal {
127    /// Transaction type
128    #[serde(rename = "type")]
129    pub transaction_type: SmartString,
130    /// Transaction UUID
131    pub uuid: SmartString,
132    /// Currency code
133    pub currency: SmartString,
134    /// Transaction hash
135    pub txid: Option<SmartString>,
136    /// Transaction state
137    pub state: SmartString,
138    /// Transaction timestamp
139    pub created_at: SmartString,
140    /// Transaction amount
141    pub amount: SmartString,
142    /// Transaction fee
143    pub fee: SmartString,
144    /// Withdrawal address
145    pub address: Option<SmartString>,
146    /// Secondary address
147    pub secondary_address: Option<SmartString>,
148    /// Network name
149    pub net_type: Option<SmartString>,
150}
151
152/// Order chance (parameters and constraints) information
153#[derive(Debug, Clone, Deserialize)]
154pub struct UpbitOrderChance {
155    /// Bid fee rate
156    pub bid_fee: SmartString,
157    /// Ask fee rate
158    pub ask_fee: SmartString,
159    /// Bid account information
160    pub bid_account: UpbitOrderChanceAccount,
161    /// Ask account information
162    pub ask_account: UpbitOrderChanceAccount,
163    /// Market information
164    pub market: UpbitMarketInfo,
165}
166
167/// Account information within order chance
168#[derive(Debug, Clone, Deserialize)]
169pub struct UpbitOrderChanceAccount {
170    /// Currency code
171    pub currency: SmartString,
172    /// Available balance
173    pub balance: SmartString,
174    /// Locked balance
175    pub locked: SmartString,
176    /// Average buy price
177    pub avg_buy_price: SmartString,
178    /// Whether average buy price was modified
179    pub avg_buy_price_modified: bool,
180    /// Unit currency
181    pub unit_currency: SmartString,
182}
183
184/// Market information within order chance
185#[derive(Debug, Clone, Deserialize)]
186pub struct UpbitMarketInfo {
187    /// Market symbol
188    pub id: SmartString,
189    /// Market name
190    pub name: SmartString,
191    /// Order types available
192    pub order_types: Vec<SmartString>,
193    /// Order sides available
194    pub order_sides: Vec<SmartString>,
195    /// Bid information
196    pub bid: UpbitMarketBidAsk,
197    /// Ask information
198    pub ask: UpbitMarketBidAsk,
199    /// Maximum total amount
200    pub max_total: SmartString,
201    /// Current state
202    pub state: SmartString,
203}
204
205/// Bid/Ask information within market info
206#[derive(Debug, Clone, Deserialize)]
207pub struct UpbitMarketBidAsk {
208    /// Currency code
209    pub currency: SmartString,
210    /// Price unit
211    pub price_unit: Option<SmartString>,
212    /// Minimum total amount
213    pub min_total: Option<SmartString>,
214}
215
216/// Generic error response from Upbit API
217#[derive(Debug, Deserialize)]
218pub struct UpbitErrorResponse {
219    /// Error details containing name and message
220    pub error: UpbitError,
221}
222
223/// Error details from Upbit API
224#[derive(Debug, Deserialize)]
225pub struct UpbitError {
226    /// Error name/code identifying the type of error
227    pub name: SmartString,
228    /// Human-readable error message describing the issue
229    pub message: SmartString,
230}
231
232impl UpbitRestClient {
233    /// Create a new Upbit REST client
234    #[must_use]
235    pub fn new(auth: Arc<UpbitAuth>) -> Self {
236        let client = Client::builder()
237            .timeout(Duration::from_secs(30))
238            .pool_max_idle_per_host(10)
239            .pool_idle_timeout(Some(Duration::from_secs(30)))
240            .build()
241            .expect("Failed to build HTTP client");
242
243        Self {
244            auth,
245            client: Arc::new(client),
246            base_url: "https://api.upbit.com/v1".into(),
247        }
248    }
249
250    /// Handle Upbit API errors and convert to EMS errors
251    fn handle_api_error(status: u16, error_response: UpbitErrorResponse) -> EMSError {
252        let error_name = &error_response.error.name;
253        let error_message = &error_response.error.message;
254
255        match error_name.as_str() {
256            "invalid_access_key" | "invalid_secret_key" | "jwt_verification" => {
257                EMSError::auth(format!("Authentication failed: {error_message}"))
258            }
259            "too_many_requests" => {
260                EMSError::rate_limit(
261                    format!("Rate limit exceeded: {error_message}"),
262                    Some(60000), // 60 second default retry delay in milliseconds
263                )
264            }
265            "insufficient_funds" | "insufficient_funds_ask" | "insufficient_funds_bid" => {
266                EMSError::insufficient_balance(format!("Insufficient balance: {error_message}"))
267            }
268            "invalid_parameter" | "invalid_query_format" | "invalid_order_id" => {
269                EMSError::invalid_params(format!("Invalid parameters: {error_message}"))
270            }
271            "market_does_not_exist" | "order_not_found" => {
272                EMSError::instrument_not_found(format!("Not found: {error_message}"))
273            }
274            "order_already_canceled" | "order_cancel_error" => {
275                EMSError::order_cancellation(format!("Order cancellation error: {error_message}"))
276            }
277            _ => EMSError::exchange_api(
278                "Upbit",
279                i32::from(status),
280                "API Error",
281                Some(format!("{error_name}: {error_message}")),
282            ),
283        }
284    }
285
286    /// Make authenticated GET request
287    async fn get_authenticated<T>(
288        &self,
289        endpoint: &str,
290        query_params: Option<&[(&str, &str)]>,
291    ) -> EMSResult<T>
292    where
293        T: for<'de> Deserialize<'de>,
294    {
295        let token = self
296            .auth
297            .generate_rest_jwt_get(query_params)
298            .map_err(|e| EMSError::auth(format!("JWT generation failed: {e}")))?;
299
300        let mut url = format!("{}{}", self.base_url, endpoint);
301        if let Some(params) = query_params
302            && !params.is_empty()
303        {
304            url.push('?');
305            for (i, (key, value)) in params.iter().enumerate() {
306                if i > 0 {
307                    url.push('&');
308                }
309                url.push_str(key);
310                url.push('=');
311                url.push_str(value);
312            }
313        }
314
315        let response = self
316            .client
317            .get(&url)
318            .header("Authorization", format!("Bearer {token}"))
319            .send()
320            .await
321            .map_err(|e| EMSError::connection(format!("Request failed: {e}")))?;
322
323        let status = response.status();
324        let response_text = response
325            .text()
326            .await
327            .map_err(|e| EMSError::connection(format!("Failed to read response: {e}")))?;
328
329        if status.is_success() {
330            let mut response_bytes = response_text.into_bytes();
331            simd_json::from_slice(&mut response_bytes).map_err(|e| {
332                EMSError::exchange_api("Upbit", -1, "JSON parsing failed", Some(e.to_string()))
333            })
334        } else {
335            let mut error_bytes = response_text.into_bytes();
336            let error_response: UpbitErrorResponse = simd_json::from_slice(&mut error_bytes)
337                .map_err(|e| {
338                    EMSError::exchange_api(
339                        "Upbit",
340                        i32::from(status.as_u16()),
341                        "Failed to parse error response",
342                        Some(e.to_string()),
343                    )
344                })?;
345
346            Err(Self::handle_api_error(status.as_u16(), error_response))
347        }
348    }
349
350    /// Get all account balances
351    pub async fn get_accounts(&self) -> EMSResult<SmallVec<[UpbitAccount; 16]>> {
352        let accounts: Vec<UpbitAccount> = self.get_authenticated("/accounts", None).await?;
353        Ok(accounts.into_iter().collect())
354    }
355
356    /// Get all deposit addresses
357    pub async fn get_deposit_addresses(&self) -> EMSResult<SmallVec<[UpbitDepositAddress; 16]>> {
358        let addresses: Vec<UpbitDepositAddress> = self
359            .get_authenticated("/deposits/coin_addresses", None)
360            .await?;
361        Ok(addresses.into_iter().collect())
362    }
363
364    /// Get deposit address for specific currency
365    pub async fn get_deposit_address(&self, currency: &str) -> EMSResult<UpbitDepositAddress> {
366        let params = [("currency", currency)];
367        self.get_authenticated("/deposits/coin_address", Some(&params))
368            .await
369    }
370
371    /// Request new deposit address generation
372    pub async fn request_deposit_address(
373        &self,
374        currency: &str,
375        net_type: Option<&str>,
376    ) -> EMSResult<UpbitDepositAddress> {
377        let mut params = vec![("currency", currency)];
378        if let Some(net) = net_type {
379            params.push(("net_type", net));
380        }
381        self.get_authenticated("/deposits/generate_coin_address", Some(&params))
382            .await
383    }
384
385    /// Get open orders
386    pub async fn get_open_orders(
387        &self,
388        market: Option<&str>,
389        uuids: Option<&[&str]>,
390        identifiers: Option<&[&str]>,
391        page: Option<u32>,
392        limit: Option<u32>,
393        order_by: Option<&str>,
394    ) -> EMSResult<SmallVec<[UpbitOrderInfo; 32]>> {
395        let mut params = Vec::new();
396
397        if let Some(m) = market {
398            params.push(("market", m));
399        }
400
401        if let Some(uuids_list) = uuids {
402            for uuid in uuids_list {
403                params.push(("uuids[]", uuid));
404            }
405        }
406
407        if let Some(identifiers_list) = identifiers {
408            for identifier in identifiers_list {
409                params.push(("identifiers[]", identifier));
410            }
411        }
412
413        // Create owned strings for page and limit to fix lifetime issues
414        let page_str = page.map(|p| p.to_string());
415        let limit_str = limit.map(|l| l.to_string());
416
417        if let Some(ref p) = page_str {
418            params.push(("page", p.as_str()));
419        }
420
421        if let Some(ref l) = limit_str {
422            params.push(("limit", l.as_str()));
423        }
424
425        if let Some(ob) = order_by {
426            params.push(("order_by", ob));
427        }
428
429        let orders: Vec<UpbitOrderInfo> = self.get_authenticated("/orders", Some(&params)).await?;
430        Ok(orders.into_iter().collect())
431    }
432
433    /// Get closed orders (order history)
434    #[allow(clippy::too_many_arguments)]
435    pub async fn get_closed_orders(
436        &self,
437        market: Option<&str>,
438        uuids: Option<&[&str]>,
439        identifiers: Option<&[&str]>,
440        states: Option<&[&str]>,
441        page: Option<u32>,
442        limit: Option<u32>,
443        order_by: Option<&str>,
444        start_time: Option<&str>,
445        end_time: Option<&str>,
446    ) -> EMSResult<SmallVec<[UpbitOrderInfo; 32]>> {
447        let mut params = Vec::new();
448
449        if let Some(m) = market {
450            params.push(("market", m));
451        }
452
453        if let Some(uuids_list) = uuids {
454            for uuid in uuids_list {
455                params.push(("uuids[]", uuid));
456            }
457        }
458
459        if let Some(identifiers_list) = identifiers {
460            for identifier in identifiers_list {
461                params.push(("identifiers[]", identifier));
462            }
463        }
464
465        if let Some(states_list) = states {
466            for state in states_list {
467                params.push(("states[]", state));
468            }
469        }
470
471        // Create owned strings for page and limit to fix lifetime issues
472        let page_str = page.map(|p| p.to_string());
473        let limit_str = limit.map(|l| l.to_string());
474
475        if let Some(ref p) = page_str {
476            params.push(("page", p.as_str()));
477        }
478
479        if let Some(ref l) = limit_str {
480            params.push(("limit", l.as_str()));
481        }
482
483        if let Some(ob) = order_by {
484            params.push(("order_by", ob));
485        }
486
487        if let Some(start) = start_time {
488            params.push(("start_time", start));
489        }
490
491        if let Some(end) = end_time {
492            params.push(("end_time", end));
493        }
494
495        let orders: Vec<UpbitOrderInfo> = self
496            .get_authenticated("/orders/closed", Some(&params))
497            .await?;
498        Ok(orders.into_iter().collect())
499    }
500
501    /// Get specific orders by UUIDs
502    pub async fn get_orders_by_uuids(
503        &self,
504        uuids: &[&str],
505    ) -> EMSResult<SmallVec<[UpbitOrderInfo; 32]>> {
506        let mut params = Vec::new();
507        for uuid in uuids {
508            params.push(("uuids[]", *uuid));
509        }
510
511        let orders: Vec<UpbitOrderInfo> = self.get_authenticated("/orders", Some(&params)).await?;
512        Ok(orders.into_iter().collect())
513    }
514
515    /// Get order parameters and constraints for a market
516    pub async fn get_order_chance(&self, market: &str) -> EMSResult<UpbitOrderChance> {
517        let params = [("market", market)];
518        self.get_authenticated("/orders/chance", Some(&params))
519            .await
520    }
521
522    /// Get deposit history
523    #[allow(clippy::too_many_arguments)]
524    pub async fn get_deposits(
525        &self,
526        currency: Option<&str>,
527        state: Option<&str>,
528        uuids: Option<&[&str]>,
529        txids: Option<&[&str]>,
530        limit: Option<u32>,
531        page: Option<u32>,
532        order_by: Option<&str>,
533    ) -> EMSResult<SmallVec<[UpbitDeposit; 32]>> {
534        let mut params = Vec::new();
535
536        if let Some(c) = currency {
537            params.push(("currency", c));
538        }
539
540        if let Some(s) = state {
541            params.push(("state", s));
542        }
543
544        if let Some(uuids_list) = uuids {
545            for uuid in uuids_list {
546                params.push(("uuids[]", uuid));
547            }
548        }
549
550        if let Some(txids_list) = txids {
551            for txid in txids_list {
552                params.push(("txids[]", txid));
553            }
554        }
555
556        // Create owned strings for limit and page to fix lifetime issues
557        let limit_str = limit.map(|l| l.to_string());
558        let page_str = page.map(|p| p.to_string());
559
560        if let Some(ref l) = limit_str {
561            params.push(("limit", l.as_str()));
562        }
563
564        if let Some(ref p) = page_str {
565            params.push(("page", p.as_str()));
566        }
567
568        if let Some(ob) = order_by {
569            params.push(("order_by", ob));
570        }
571
572        let deposits: Vec<UpbitDeposit> =
573            self.get_authenticated("/deposits", Some(&params)).await?;
574        Ok(deposits.into_iter().collect())
575    }
576
577    /// Get withdrawal history
578    #[allow(clippy::too_many_arguments)]
579    pub async fn get_withdrawals(
580        &self,
581        currency: Option<&str>,
582        state: Option<&str>,
583        uuids: Option<&[&str]>,
584        txids: Option<&[&str]>,
585        limit: Option<u32>,
586        page: Option<u32>,
587        order_by: Option<&str>,
588    ) -> EMSResult<SmallVec<[UpbitWithdrawal; 32]>> {
589        let mut params = Vec::new();
590
591        if let Some(c) = currency {
592            params.push(("currency", c));
593        }
594
595        if let Some(s) = state {
596            params.push(("state", s));
597        }
598
599        if let Some(uuids_list) = uuids {
600            for uuid in uuids_list {
601                params.push(("uuids[]", uuid));
602            }
603        }
604
605        if let Some(txids_list) = txids {
606            for txid in txids_list {
607                params.push(("txids[]", txid));
608            }
609        }
610
611        // Create owned strings for limit and page to fix lifetime issues
612        let limit_str = limit.map(|l| l.to_string());
613        let page_str = page.map(|p| p.to_string());
614
615        if let Some(ref l) = limit_str {
616            params.push(("limit", l.as_str()));
617        }
618
619        if let Some(ref p) = page_str {
620            params.push(("page", p.as_str()));
621        }
622
623        if let Some(ob) = order_by {
624            params.push(("order_by", ob));
625        }
626
627        let withdrawals: Vec<UpbitWithdrawal> =
628            self.get_authenticated("/withdraws", Some(&params)).await?;
629        Ok(withdrawals.into_iter().collect())
630    }
631
632    /// Get account balance for specific currency
633    pub async fn get_account_balance(&self, currency: &str) -> EMSResult<Option<UpbitAccount>> {
634        let accounts = self.get_accounts().await?;
635        Ok(accounts
636            .into_iter()
637            .find(|account| account.currency == currency))
638    }
639
640    /// Get total portfolio value in KRW
641    pub async fn get_total_balance_krw(&self) -> EMSResult<Decimal> {
642        let accounts = self.get_accounts().await?;
643        let mut total = Decimal::ZERO;
644
645        for account in accounts {
646            // For KRW, use balance directly
647            if account.currency == "KRW" {
648                let balance = Decimal::from_str_exact(&account.balance).map_err(|e| {
649                    EMSError::exchange_api(
650                        "Upbit",
651                        -1,
652                        "Failed to parse balance",
653                        Some(e.to_string()),
654                    )
655                })?;
656                total += balance;
657            } else {
658                // For other currencies, would need current prices to calculate KRW value
659                // This would require additional market data API calls
660                let balance = Decimal::from_str_exact(&account.balance).map_err(|e| {
661                    EMSError::exchange_api(
662                        "Upbit",
663                        -1,
664                        "Failed to parse balance",
665                        Some(e.to_string()),
666                    )
667                })?;
668                let avg_price = Decimal::from_str_exact(&account.avg_buy_price).map_err(|e| {
669                    EMSError::exchange_api(
670                        "Upbit",
671                        -1,
672                        "Failed to parse avg price",
673                        Some(e.to_string()),
674                    )
675                })?;
676
677                // Rough estimation using average buy price
678                total += balance * avg_price;
679            }
680        }
681
682        Ok(total)
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use rusty_common::auth::exchanges::upbit::{UpbitAuth, UpbitAuthConfig};
690
691    fn create_test_client() -> UpbitRestClient {
692        let config = UpbitAuthConfig::new("test_key".into(), "test_secret".into());
693        let auth = Arc::new(UpbitAuth::new(config));
694        UpbitRestClient::new(auth)
695    }
696
697    #[test]
698    fn test_client_creation() {
699        let client = create_test_client();
700        assert_eq!(client.base_url, "https://api.upbit.com/v1");
701    }
702
703    #[test]
704    fn test_error_handling() {
705        let error_response = UpbitErrorResponse {
706            error: UpbitError {
707                name: "too_many_requests".into(),
708                message: "Rate limit exceeded".into(),
709            },
710        };
711
712        let ems_error = UpbitRestClient::handle_api_error(429, error_response);
713        assert!(matches!(ems_error, EMSError::RateLimitExceeded { .. }));
714    }
715
716    #[test]
717    fn test_authentication_error_mapping() {
718        let error_response = UpbitErrorResponse {
719            error: UpbitError {
720                name: "invalid_access_key".into(),
721                message: "Invalid API key".into(),
722            },
723        };
724
725        let ems_error = UpbitRestClient::handle_api_error(401, error_response);
726        assert!(matches!(ems_error, EMSError::AuthenticationError(_)));
727    }
728
729    #[test]
730    fn test_insufficient_balance_error_mapping() {
731        let error_response = UpbitErrorResponse {
732            error: UpbitError {
733                name: "insufficient_funds".into(),
734                message: "Not enough balance".into(),
735            },
736        };
737
738        let ems_error = UpbitRestClient::handle_api_error(400, error_response);
739        assert!(matches!(ems_error, EMSError::InsufficientBalance(_)));
740    }
741}