1use 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#[derive(Debug, Clone)]
24pub struct UpbitRestClient {
25 auth: Arc<UpbitAuth>,
27 client: Arc<Client>,
29 base_url: SmartString,
31}
32
33#[derive(Debug, Clone, Deserialize)]
35pub struct UpbitAccount {
36 pub currency: SmartString,
38 pub balance: SmartString,
40 pub locked: SmartString,
42 pub avg_buy_price: SmartString,
44 pub avg_buy_price_modified: bool,
46 pub unit_currency: SmartString,
48}
49
50#[derive(Debug, Clone, Deserialize)]
52pub struct UpbitOrderInfo {
53 pub uuid: SmartString,
55 pub side: SmartString,
57 pub ord_type: SmartString,
59 pub price: Option<SmartString>,
61 pub state: SmartString,
63 pub market: SmartString,
65 pub created_at: SmartString,
67 pub volume: SmartString,
69 pub remaining_volume: SmartString,
71 pub reserved_fee: SmartString,
73 pub remaining_fee: SmartString,
75 pub paid_fee: SmartString,
77 pub locked: SmartString,
79 pub executed_volume: SmartString,
81 pub trades_count: u64,
83 pub time_in_force: Option<SmartString>,
85 pub identifier: Option<SmartString>,
87}
88
89#[derive(Debug, Clone, Deserialize)]
91pub struct UpbitDepositAddress {
92 pub currency: SmartString,
94 pub deposit_address: SmartString,
96 pub secondary_address: Option<SmartString>,
98}
99
100#[derive(Debug, Clone, Deserialize)]
102pub struct UpbitDeposit {
103 #[serde(rename = "type")]
105 pub transaction_type: SmartString,
106 pub uuid: SmartString,
108 pub currency: SmartString,
110 pub txid: Option<SmartString>,
112 pub state: SmartString,
114 pub created_at: SmartString,
116 pub amount: SmartString,
118 pub fee: SmartString,
120 pub net_type: Option<SmartString>,
122}
123
124#[derive(Debug, Clone, Deserialize)]
126pub struct UpbitWithdrawal {
127 #[serde(rename = "type")]
129 pub transaction_type: SmartString,
130 pub uuid: SmartString,
132 pub currency: SmartString,
134 pub txid: Option<SmartString>,
136 pub state: SmartString,
138 pub created_at: SmartString,
140 pub amount: SmartString,
142 pub fee: SmartString,
144 pub address: Option<SmartString>,
146 pub secondary_address: Option<SmartString>,
148 pub net_type: Option<SmartString>,
150}
151
152#[derive(Debug, Clone, Deserialize)]
154pub struct UpbitOrderChance {
155 pub bid_fee: SmartString,
157 pub ask_fee: SmartString,
159 pub bid_account: UpbitOrderChanceAccount,
161 pub ask_account: UpbitOrderChanceAccount,
163 pub market: UpbitMarketInfo,
165}
166
167#[derive(Debug, Clone, Deserialize)]
169pub struct UpbitOrderChanceAccount {
170 pub currency: SmartString,
172 pub balance: SmartString,
174 pub locked: SmartString,
176 pub avg_buy_price: SmartString,
178 pub avg_buy_price_modified: bool,
180 pub unit_currency: SmartString,
182}
183
184#[derive(Debug, Clone, Deserialize)]
186pub struct UpbitMarketInfo {
187 pub id: SmartString,
189 pub name: SmartString,
191 pub order_types: Vec<SmartString>,
193 pub order_sides: Vec<SmartString>,
195 pub bid: UpbitMarketBidAsk,
197 pub ask: UpbitMarketBidAsk,
199 pub max_total: SmartString,
201 pub state: SmartString,
203}
204
205#[derive(Debug, Clone, Deserialize)]
207pub struct UpbitMarketBidAsk {
208 pub currency: SmartString,
210 pub price_unit: Option<SmartString>,
212 pub min_total: Option<SmartString>,
214}
215
216#[derive(Debug, Deserialize)]
218pub struct UpbitErrorResponse {
219 pub error: UpbitError,
221}
222
223#[derive(Debug, Deserialize)]
225pub struct UpbitError {
226 pub name: SmartString,
228 pub message: SmartString,
230}
231
232impl UpbitRestClient {
233 #[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 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), )
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 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 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 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 pub async fn get_deposit_address(&self, currency: &str) -> EMSResult<UpbitDepositAddress> {
366 let params = [("currency", currency)];
367 self.get_authenticated("/deposits/coin_address", Some(¶ms))
368 .await
369 }
370
371 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(¶ms))
382 .await
383 }
384
385 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 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(¶ms)).await?;
430 Ok(orders.into_iter().collect())
431 }
432
433 #[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 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(¶ms))
497 .await?;
498 Ok(orders.into_iter().collect())
499 }
500
501 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(¶ms)).await?;
512 Ok(orders.into_iter().collect())
513 }
514
515 pub async fn get_order_chance(&self, market: &str) -> EMSResult<UpbitOrderChance> {
517 let params = [("market", market)];
518 self.get_authenticated("/orders/chance", Some(¶ms))
519 .await
520 }
521
522 #[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 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(¶ms)).await?;
574 Ok(deposits.into_iter().collect())
575 }
576
577 #[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 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(¶ms)).await?;
629 Ok(withdrawals.into_iter().collect())
630 }
631
632 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 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 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 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 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}