1use crate::clock::NonceGenerator;
11use crate::credentials::Credentials;
12use crate::error::{Error, Result};
13use crate::events::{BookLevel, Event, OrderBookSnapshot, TradePrint};
14use crate::normalize::{format_decimal, parse_decimal};
15use crate::options::{ExchangeOptions, MarketType, SelfTradePrevention};
16use crate::signing::{hmac_sha512_bytes, sha512_hex};
17use crate::symbol::Symbol;
18use crate::traits::{Exchange, Execution, MarketData};
19use crate::transport::{
20 HttpMethod, HttpRequest, HttpResponse, HttpTransport, WsConnection, WsTransport,
21};
22use crate::types::{
23 Balance, Order, OrderRequest, OrderSide, OrderStatus, OrderType, Ticker, TimeInForce,
24};
25use base64::Engine;
26use rust_decimal::Decimal;
27use std::fmt;
28use wickra_core::Candle;
29
30fn system_now_ms() -> i64 {
31 use std::time::{SystemTime, UNIX_EPOCH};
32 SystemTime::now()
33 .duration_since(UNIX_EPOCH)
34 .expect("system clock is before the Unix epoch")
35 .as_millis() as i64
36}
37
38fn b64url(data: &[u8]) -> String {
39 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
40}
41
42pub struct Upbit {
44 http: Box<dyn HttpTransport>,
45 ws: Option<Box<dyn WsTransport>>,
46 rest_base: String,
47 market_type: MarketType,
53 credentials: Option<Credentials>,
54 now_ms: Box<dyn Fn() -> i64 + Send + Sync>,
55 nonces: NonceGenerator,
60 connection: Option<Box<dyn WsConnection>>,
61 sub_messages: Vec<String>,
62}
63
64impl fmt::Debug for Upbit {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 f.debug_struct("Upbit")
72 .field("ws", &self.ws.is_some())
73 .field("rest_base", &self.rest_base)
74 .field("authenticated", &self.credentials.is_some())
75 .field("connection", &self.connection.is_some())
76 .field("sub_messages", &self.sub_messages.len())
77 .finish_non_exhaustive()
78 }
79}
80
81impl Upbit {
82 fn ensure_market_is_routed(&self) -> Result<()> {
90 super::ensure_market_is_routed("Upbit", self.market_type, super::SPOT_ONLY)
91 }
92
93 fn build(
94 http: Box<dyn HttpTransport>,
95 options: &ExchangeOptions,
96 credentials: Option<Credentials>,
97 ) -> Self {
98 Self {
99 http,
100 ws: None,
101 rest_base: "https://api.upbit.com".to_string(),
102 market_type: options.market_type,
103 credentials,
104 now_ms: Box::new(system_now_ms),
105 nonces: NonceGenerator::new(0),
106 connection: None,
107 sub_messages: Vec::new(),
108 }
109 }
110
111 #[must_use]
113 pub fn with_http(http: Box<dyn HttpTransport>, options: &ExchangeOptions) -> Self {
114 Self::build(http, options, None)
115 }
116
117 #[must_use]
119 pub fn with_credentials(
120 http: Box<dyn HttpTransport>,
121 options: &ExchangeOptions,
122 credentials: Credentials,
123 ) -> Self {
124 Self::build(http, options, Some(credentials))
125 }
126
127 #[must_use]
129 pub fn with_clock(mut self, now_ms: Box<dyn Fn() -> i64 + Send + Sync>) -> Self {
130 self.now_ms = now_ms;
131 self
132 }
133
134 #[must_use]
136 pub fn with_ws(mut self, ws: Box<dyn WsTransport>) -> Self {
137 self.ws = Some(ws);
138 self
139 }
140
141 #[must_use]
144 pub fn wire_symbol(symbol: &Symbol) -> String {
145 format!("{}-{}", symbol.quote(), symbol.base())
146 }
147
148 pub fn ticker(&self, symbol: &Symbol) -> Result<Ticker> {
154 let query = format!("markets={}", Self::wire_symbol(symbol));
155 let value = self.get("/v1/ticker", &query)?;
156 let entry = value
157 .as_array()
158 .and_then(|a| a.first())
159 .ok_or_else(|| Error::NotFound(format!("no ticker for {symbol}")))?;
160 let last = decimal_field(entry, "trade_price")?;
161 Ok(Ticker {
162 symbol: symbol.clone(),
163 last,
164 bid: last,
165 ask: last,
166 volume: decimal_field(entry, "acc_trade_volume_24h").unwrap_or(Decimal::ZERO),
167 timestamp: entry
168 .get("trade_timestamp")
169 .and_then(serde_json::Value::as_i64)
170 .unwrap_or(0),
171 })
172 }
173
174 pub fn klines(&self, symbol: &Symbol, interval: &str, limit: u32) -> Result<Vec<Candle>> {
180 let path = candle_path(interval);
181 let query = format!("market={}&count={limit}", Self::wire_symbol(symbol));
182 let value = self.get(&path, &query)?;
183 let rows = value
184 .as_array()
185 .ok_or_else(|| Error::Deserialization("missing candles".to_string()))?;
186 let mut candles = rows.iter().map(parse_candle).collect::<Result<Vec<_>>>()?;
187 candles.reverse();
188 Ok(candles)
189 }
190
191 pub fn order_book(&self, symbol: &Symbol, _depth: u32) -> Result<OrderBookSnapshot> {
196 let query = format!("markets={}", Self::wire_symbol(symbol));
197 let value = self.get("/v1/orderbook", &query)?;
198 let entry = value
199 .as_array()
200 .and_then(|a| a.first())
201 .ok_or_else(|| Error::NotFound(format!("no book for {symbol}")))?;
202 let units = entry
203 .get("orderbook_units")
204 .and_then(serde_json::Value::as_array)
205 .ok_or_else(|| Error::Deserialization("missing orderbook_units".to_string()))?;
206 let mut bids = Vec::with_capacity(units.len());
207 let mut asks = Vec::with_capacity(units.len());
208 for unit in units {
209 bids.push(BookLevel {
210 price: decimal_field(unit, "bid_price")?,
211 quantity: decimal_field(unit, "bid_size")?,
212 });
213 asks.push(BookLevel {
214 price: decimal_field(unit, "ask_price")?,
215 quantity: decimal_field(unit, "ask_size")?,
216 });
217 }
218 Ok(OrderBookSnapshot {
219 symbol: symbol.clone(),
220 last_update_id: 0,
221 bids,
222 asks,
223 timestamp: entry
224 .get("timestamp")
225 .and_then(serde_json::Value::as_i64)
226 .unwrap_or(0),
227 })
228 }
229
230 pub fn subscribe_trades(&mut self, symbol: &Symbol) -> Result<()> {
235 self.subscribe(symbol, "trade")
236 }
237
238 pub fn subscribe_book(&mut self, symbol: &Symbol) -> Result<()> {
243 self.subscribe(symbol, "orderbook")
244 }
245
246 pub fn subscribe_ticker(&mut self, symbol: &Symbol) -> Result<()> {
251 self.subscribe(symbol, "ticker")
252 }
253
254 fn subscribe(&mut self, symbol: &Symbol, kind: &str) -> Result<()> {
255 self.ensure_market_is_routed()?;
256 let market = Self::wire_symbol(symbol);
257 if self.connection.is_none() {
258 let ws = self.ws.as_ref().ok_or(Error::NotConnected)?;
259 let connection = ws.connect("wss://api.upbit.com/websocket/v1")?;
260 self.connection = Some(connection);
261 }
262 let ticket = format!("wkex-{}", (self.now_ms)());
263 let message = format!(
264 r#"[{{"ticket":"{ticket}"}},{{"type":"{kind}","codes":["{market}"]}},{{"format":"DEFAULT"}}]"#
265 );
266 self.connection
267 .as_mut()
268 .expect("connection just ensured")
269 .send(&message)?;
270 if !self.sub_messages.contains(&message) {
271 self.sub_messages.push(message.clone());
272 }
273 Ok(())
274 }
275
276 pub fn poll_events(&mut self) -> Vec<Event> {
278 let mut events = Vec::new();
279 if let Some(connection) = self.connection.as_mut() {
280 while let Ok(Some(frame)) = connection.recv() {
281 if let Ok(Some(event)) = parse_ws_message(&frame) {
282 events.push(event);
283 }
284 }
285 }
286 let url = "wss://api.upbit.com/websocket/v1";
287 crate::wsutil::reconnect_if_dropped(
288 self.ws.as_deref(),
289 url,
290 &mut self.connection,
291 &self.sub_messages,
292 &mut events,
293 );
294 events
295 }
296
297 pub fn place_order(&self, request: &OrderRequest) -> Result<Order> {
303 if request.order_type.is_trigger() {
304 return Err(Error::unsupported_trigger("Upbit"));
305 }
306 request.validate()?;
307 let mut params: Vec<(&str, String)> = vec![
308 ("market", Self::wire_symbol(&request.symbol)),
309 ("side", side_str(request.side).to_string()),
310 (
311 "ord_type",
312 ord_type_str(request.order_type, request.side).to_string(),
313 ),
314 ("volume", format_decimal(request.quantity)),
315 ];
316 if let Some(price) = request.price {
317 params.push(("price", format_decimal(price)));
318 }
319 if let Some(tif) = tif_str(request)? {
320 params.push(("time_in_force", tif.to_string()));
321 }
322 if let Some(id) = &request.client_order_id {
323 params.push(("identifier", id.clone()));
324 }
325 let value = self.signed_request(HttpMethod::Post, "/v1/orders", ¶ms)?;
326 order_from_value(request.symbol.clone(), &value)
327 }
328
329 pub fn cancel_order(&self, _symbol: &Symbol, order_id: &str) -> Result<()> {
334 self.signed_request(
335 HttpMethod::Delete,
336 "/v1/order",
337 &[("uuid", order_id.to_string())],
338 )?;
339 Ok(())
340 }
341
342 pub fn query_order(&self, symbol: &Symbol, order_id: &str) -> Result<Order> {
347 let value = self.signed_request(
348 HttpMethod::Get,
349 "/v1/order",
350 &[("uuid", order_id.to_string())],
351 )?;
352 order_from_value(symbol.clone(), &value)
353 }
354
355 pub fn open_orders(&self, symbol: Option<&Symbol>) -> Result<Vec<Order>> {
360 let mut params: Vec<(&str, String)> = vec![("state", "wait".to_string())];
361 let market = symbol.map(Self::wire_symbol);
362 if let Some(m) = &market {
363 params.push(("market", m.clone()));
364 }
365 let value = self.signed_request(HttpMethod::Get, "/v1/orders", ¶ms)?;
366 let orders = value
367 .as_array()
368 .ok_or_else(|| Error::Deserialization("missing orders".to_string()))?;
369 orders
370 .iter()
371 .map(|order| {
372 let sym = symbol.cloned().unwrap_or_else(|| {
373 let raw = order
374 .get("market")
375 .and_then(serde_json::Value::as_str)
376 .unwrap_or("");
377 split_wire_symbol(raw)
378 });
379 order_from_value(sym, order)
380 })
381 .collect()
382 }
383
384 pub fn balances(&self) -> Result<Vec<Balance>> {
389 let value = self.signed_request(HttpMethod::Get, "/v1/accounts", &[])?;
390 let accounts = value
391 .as_array()
392 .ok_or_else(|| Error::Deserialization("missing accounts".to_string()))?;
393 accounts
394 .iter()
395 .map(|a| {
396 Ok(Balance {
397 asset: a
398 .get("currency")
399 .and_then(serde_json::Value::as_str)
400 .unwrap_or("")
401 .to_string(),
402 free: decimal_field(a, "balance").unwrap_or(Decimal::ZERO),
403 locked: decimal_field(a, "locked").unwrap_or(Decimal::ZERO),
404 })
405 })
406 .collect()
407 }
408
409 fn get(&self, path: &str, query: &str) -> Result<serde_json::Value> {
410 self.ensure_market_is_routed()?;
411 let url = format!("{}{path}?{query}", self.rest_base);
412 let response = self.http.execute(&HttpRequest::get(url))?;
413 parse_body(&response)
414 }
415
416 fn signed_request(
419 &self,
420 method: HttpMethod,
421 path: &str,
422 params: &[(&str, String)],
423 ) -> Result<serde_json::Value> {
424 self.ensure_market_is_routed()?;
425 let query_string = params
426 .iter()
427 .map(|(key, val)| format!("{key}={val}"))
428 .collect::<Vec<_>>()
429 .join("&");
430 let query_hash = (!params.is_empty()).then(|| sha512_hex(query_string.as_bytes()));
431 let jwt = self.build_jwt(query_hash)?;
432
433 let is_get = matches!(method, HttpMethod::Get);
434 let url = if is_get && !query_string.is_empty() {
435 format!("{}{path}?{query_string}", self.rest_base)
436 } else {
437 format!("{}{path}", self.rest_base)
438 };
439 let mut request =
440 HttpRequest::new(method, url).with_header("Authorization", format!("Bearer {jwt}"));
441 if !is_get && !query_string.is_empty() {
442 request = request
443 .with_header("Content-Type", "application/x-www-form-urlencoded")
444 .with_body(query_string);
445 }
446 let response = self.http.execute(&request)?;
447 parse_body(&response)
448 }
449
450 fn build_jwt(&self, query_hash: Option<String>) -> Result<String> {
451 let creds = self.credentials.as_ref().ok_or(Error::InvalidCredentials(
452 "signed endpoint requires credentials",
453 ))?;
454 let header = br#"{"alg":"HS512","typ":"JWT"}"#;
455 let candidate = u64::try_from((self.now_ms)()).unwrap_or(0);
456 let nonce = format!("wkex-{:x}", self.nonces.next(candidate));
457 let mut payload = serde_json::json!({
458 "access_key": creds.api_key,
459 "nonce": nonce,
460 });
461 if let Some(hash) = query_hash {
462 payload["query_hash"] = serde_json::json!(hash);
463 payload["query_hash_alg"] = serde_json::json!("SHA512");
464 }
465 let signing_input = format!(
466 "{}.{}",
467 b64url(header),
468 b64url(payload.to_string().as_bytes())
469 );
470 let signature = hmac_sha512_bytes(creds.api_secret.as_bytes(), signing_input.as_bytes());
471 Ok(format!("{signing_input}.{}", b64url(&signature)))
472 }
473}
474
475fn candle_path(interval: &str) -> String {
476 match interval {
477 "1m" => "/v1/candles/minutes/1".to_string(),
478 "3m" => "/v1/candles/minutes/3".to_string(),
479 "5m" => "/v1/candles/minutes/5".to_string(),
480 "15m" => "/v1/candles/minutes/15".to_string(),
481 "30m" => "/v1/candles/minutes/30".to_string(),
482 "4h" => "/v1/candles/minutes/240".to_string(),
483 "1d" => "/v1/candles/days".to_string(),
484 "1w" => "/v1/candles/weeks".to_string(),
485 _ => "/v1/candles/minutes/60".to_string(),
487 }
488}
489
490fn parse_body(response: &HttpResponse) -> Result<serde_json::Value> {
491 if response.is_success() {
492 serde_json::from_str(&response.body).map_err(|e| Error::Deserialization(e.to_string()))
493 } else {
494 let value: serde_json::Value = serde_json::from_str(&response.body).unwrap_or_default();
495 let error = value.get("error");
496 let name = error
497 .and_then(|e| e.get("name"))
498 .and_then(serde_json::Value::as_str)
499 .unwrap_or("");
500 let message = error
501 .and_then(|e| e.get("message"))
502 .and_then(serde_json::Value::as_str)
503 .unwrap_or("");
504 Err(map_error(name, message, response.status).with_retry_after(response.retry_after()))
505 }
506}
507
508fn map_error(name: &str, message: &str, status: u16) -> Error {
509 if name.contains("insufficient_funds") || message.contains("주문가능") {
510 Error::InsufficientBalance
511 } else if status == 401 || name.contains("invalid_access_key") || name.contains("jwt") {
512 Error::Auth(message.to_string())
513 } else if status == 429 || name.contains("too_many") {
514 Error::RateLimited { retry_after: None }
515 } else if name.contains("order_not_found") {
516 Error::NotFound(message.to_string())
517 } else if name.contains("market") {
518 Error::InvalidSymbol(message.to_string())
519 } else {
520 Error::Exchange {
521 code: if name.is_empty() {
522 status.to_string()
523 } else {
524 name.to_string()
525 },
526 message: message.to_string(),
527 }
528 }
529}
530
531fn side_str(side: OrderSide) -> &'static str {
532 match side {
533 OrderSide::Buy => "bid",
534 OrderSide::Sell => "ask",
535 }
536}
537
538fn tif_str(request: &OrderRequest) -> Result<Option<&'static str>> {
547 if request.post_only {
548 return Err(Error::unsupported_field(
549 "Upbit",
550 "post_only",
551 "Upbit's order API carries no post-only flag",
552 ));
553 }
554 if request.stp != SelfTradePrevention::None {
555 return Err(Error::unsupported_field(
556 "Upbit",
557 "a self-trade-prevention policy",
558 "Upbit's order API carries no STP field",
559 ));
560 }
561 if request.reduce_only {
562 return Err(Error::unsupported_field(
563 "Upbit",
564 "reduce_only",
565 "Upbit is spot-only and has no positions to reduce",
566 ));
567 }
568 match (request.order_type, request.time_in_force) {
569 (_, TimeInForce::Gtc) | (OrderType::Market | OrderType::StopMarket, TimeInForce::Ioc) => {
573 Ok(None)
574 }
575 (OrderType::Limit | OrderType::StopLimit, TimeInForce::Ioc) => Ok(Some("ioc")),
576 (OrderType::Limit | OrderType::StopLimit, TimeInForce::Fok) => Ok(Some("fok")),
577 (OrderType::Market | OrderType::StopMarket, TimeInForce::Fok) => {
578 Err(Error::unsupported_field(
579 "Upbit",
580 "a FOK time-in-force on a market order",
581 "Upbit accepts time_in_force only on a limit order",
582 ))
583 }
584 }
585}
586
587fn ord_type_str(order_type: OrderType, side: OrderSide) -> &'static str {
588 match order_type {
589 OrderType::Limit | OrderType::StopLimit => "limit",
590 OrderType::Market | OrderType::StopMarket => match side {
592 OrderSide::Buy => "price",
593 OrderSide::Sell => "market",
594 },
595 }
596}
597
598fn parse_side(raw: &str) -> Result<OrderSide> {
599 match raw {
600 "bid" => Ok(OrderSide::Buy),
601 "ask" => Ok(OrderSide::Sell),
602 other => Err(Error::Deserialization(format!("unknown side {other:?}"))),
603 }
604}
605
606fn parse_order_type(raw: &str) -> OrderType {
607 match raw {
608 "price" | "market" => OrderType::Market,
609 _ => OrderType::Limit,
610 }
611}
612
613fn parse_status(raw: &str) -> Result<OrderStatus> {
614 match raw {
615 "wait" | "watch" => Ok(OrderStatus::New),
616 "done" => Ok(OrderStatus::Filled),
617 "cancel" => Ok(OrderStatus::Canceled),
618 other => Err(Error::Deserialization(format!("unknown state {other:?}"))),
619 }
620}
621
622fn nonzero(value: Decimal) -> Option<Decimal> {
623 (value > Decimal::ZERO).then_some(value)
624}
625
626fn decimal_value(field: &serde_json::Value) -> Result<Decimal> {
627 match field {
628 serde_json::Value::String(s) => parse_decimal(s),
629 serde_json::Value::Number(n) => parse_decimal(&n.to_string()),
630 other => Err(Error::Deserialization(format!("not a number: {other}"))),
631 }
632}
633
634fn decimal_field(value: &serde_json::Value, key: &str) -> Result<Decimal> {
635 let field = value
636 .get(key)
637 .ok_or_else(|| Error::Deserialization(format!("missing field {key:?}")))?;
638 decimal_value(field)
639}
640
641fn f64_field(value: &serde_json::Value, key: &str) -> Result<f64> {
642 let field = value
643 .get(key)
644 .ok_or_else(|| Error::Deserialization(format!("missing field {key:?}")))?;
645 field
646 .as_f64()
647 .or_else(|| field.as_str().and_then(|s| s.parse().ok()))
648 .ok_or_else(|| Error::Deserialization(format!("field {key:?} not a number")))
649}
650
651fn split_wire_symbol(wire: &str) -> Symbol {
652 match wire.split_once('-') {
653 Some((quote, base)) if !quote.is_empty() && !base.is_empty() => Symbol::new(base, quote),
654 _ => Symbol::new(wire, ""),
655 }
656}
657
658fn parse_candle(candle: &serde_json::Value) -> Result<Candle> {
659 let ts = candle
660 .get("timestamp")
661 .and_then(serde_json::Value::as_i64)
662 .ok_or_else(|| Error::Deserialization("candle timestamp missing".to_string()))?;
663 Candle::new(
664 f64_field(candle, "opening_price")?,
665 f64_field(candle, "high_price")?,
666 f64_field(candle, "low_price")?,
667 f64_field(candle, "trade_price")?,
668 f64_field(candle, "candle_acc_trade_volume")?,
669 ts,
670 )
671 .map_err(|e| Error::Deserialization(e.to_string()))
672}
673
674fn order_from_value(symbol: Symbol, order: &serde_json::Value) -> Result<Order> {
675 let side = order
676 .get("side")
677 .and_then(serde_json::Value::as_str)
678 .unwrap_or("");
679 let ord_type = order
680 .get("ord_type")
681 .and_then(serde_json::Value::as_str)
682 .unwrap_or("limit");
683 let state = order
684 .get("state")
685 .and_then(serde_json::Value::as_str)
686 .unwrap_or("wait");
687 Ok(Order {
688 id: order
689 .get("uuid")
690 .and_then(serde_json::Value::as_str)
691 .unwrap_or("")
692 .to_string(),
693 client_order_id: order
694 .get("identifier")
695 .and_then(serde_json::Value::as_str)
696 .filter(|s| !s.is_empty())
697 .map(str::to_string),
698 symbol,
699 side: parse_side(side)?,
700 order_type: parse_order_type(ord_type),
701 status: parse_status(state)?,
702 quantity: decimal_field(order, "volume").unwrap_or(Decimal::ZERO),
703 filled_quantity: decimal_field(order, "executed_volume").unwrap_or(Decimal::ZERO),
704 price: decimal_field(order, "price").ok().and_then(nonzero),
705 average_price: None,
706 })
707}
708
709fn parse_ws_message(text: &str) -> Result<Option<Event>> {
710 let value: serde_json::Value =
711 serde_json::from_str(text).map_err(|e| Error::Deserialization(e.to_string()))?;
712 let Some(kind) = value.get("type").and_then(serde_json::Value::as_str) else {
713 return Ok(None); };
715 let code = value
716 .get("code")
717 .and_then(serde_json::Value::as_str)
718 .unwrap_or("");
719 let symbol = split_wire_symbol(code);
720
721 match kind {
722 "trade" => {
723 let ask_bid = value
724 .get("ask_bid")
725 .and_then(serde_json::Value::as_str)
726 .unwrap_or("");
727 Ok(Some(Event::Trade(TradePrint {
728 symbol,
729 price: decimal_field(&value, "trade_price")?,
730 quantity: decimal_field(&value, "trade_volume")?,
731 aggressor: if ask_bid == "ASK" {
732 OrderSide::Sell
733 } else {
734 OrderSide::Buy
735 },
736 timestamp: value
737 .get("trade_timestamp")
738 .and_then(serde_json::Value::as_i64)
739 .unwrap_or(0),
740 })))
741 }
742 "ticker" => Ok(Some(Event::Ticker(Ticker {
743 symbol,
744 last: decimal_field(&value, "trade_price")?,
745 bid: decimal_field(&value, "trade_price").unwrap_or(Decimal::ZERO),
746 ask: decimal_field(&value, "trade_price").unwrap_or(Decimal::ZERO),
747 volume: decimal_field(&value, "acc_trade_volume_24h").unwrap_or(Decimal::ZERO),
748 timestamp: value
749 .get("trade_timestamp")
750 .and_then(serde_json::Value::as_i64)
751 .unwrap_or(0),
752 }))),
753 "orderbook" => {
754 let units = value
755 .get("orderbook_units")
756 .and_then(serde_json::Value::as_array);
757 let mut bids = Vec::new();
758 let mut asks = Vec::new();
759 if let Some(units) = units {
760 for unit in units {
761 bids.push(BookLevel {
762 price: decimal_field(unit, "bid_price")?,
763 quantity: decimal_field(unit, "bid_size")?,
764 });
765 asks.push(BookLevel {
766 price: decimal_field(unit, "ask_price")?,
767 quantity: decimal_field(unit, "ask_size")?,
768 });
769 }
770 }
771 Ok(Some(Event::BookSnapshot(OrderBookSnapshot {
772 symbol,
773 last_update_id: 0,
774 bids,
775 asks,
776 timestamp: value
777 .get("timestamp")
778 .and_then(serde_json::Value::as_i64)
779 .unwrap_or(0),
780 })))
781 }
782 _ => Ok(None),
783 }
784}
785
786impl MarketData for Upbit {
787 fn ticker(&mut self, symbol: &Symbol) -> Result<Ticker> {
788 Upbit::ticker(self, symbol)
789 }
790 fn klines(&mut self, symbol: &Symbol, interval: &str, limit: u32) -> Result<Vec<Candle>> {
791 Upbit::klines(self, symbol, interval, limit)
792 }
793 fn order_book(&mut self, symbol: &Symbol, depth: u32) -> Result<OrderBookSnapshot> {
794 Upbit::order_book(self, symbol, depth)
795 }
796 fn subscribe_trades(&mut self, symbol: &Symbol) -> Result<()> {
797 Upbit::subscribe_trades(self, symbol)
798 }
799 fn subscribe_book(&mut self, symbol: &Symbol) -> Result<()> {
800 Upbit::subscribe_book(self, symbol)
801 }
802 fn subscribe_ticker(&mut self, symbol: &Symbol) -> Result<()> {
803 Upbit::subscribe_ticker(self, symbol)
804 }
805 fn poll_events(&mut self) -> Vec<Event> {
806 Upbit::poll_events(self)
807 }
808}
809
810impl Execution for Upbit {
811 fn place_order(&mut self, request: &OrderRequest) -> Result<Order> {
812 Upbit::place_order(self, request)
813 }
814 fn cancel_order(&mut self, symbol: &Symbol, order_id: &str) -> Result<()> {
815 Upbit::cancel_order(self, symbol, order_id)
816 }
817 fn query_order(&mut self, symbol: &Symbol, order_id: &str) -> Result<Order> {
818 Upbit::query_order(self, symbol, order_id)
819 }
820 fn open_orders(&mut self, symbol: Option<&Symbol>) -> Result<Vec<Order>> {
821 Upbit::open_orders(self, symbol)
822 }
823 fn balances(&mut self) -> Result<Vec<Balance>> {
824 Upbit::balances(self)
825 }
826}
827
828impl Exchange for Upbit {
829 fn name(&self) -> &'static str {
830 "upbit"
831 }
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837 use crate::transport::{MockHttpTransport, MockWsTransport};
838 use rust_decimal_macros::dec;
839 use std::sync::Arc;
840
841 struct ArcTransport(Arc<MockHttpTransport>);
842 impl HttpTransport for ArcTransport {
843 fn execute(&self, request: &HttpRequest) -> Result<HttpResponse> {
844 self.0.execute(request)
845 }
846 }
847 struct ArcWs(Arc<MockWsTransport>);
848 impl WsTransport for ArcWs {
849 fn connect(&self, url: &str) -> Result<Box<dyn WsConnection>> {
850 self.0.connect(url)
851 }
852 }
853
854 fn symbol() -> Symbol {
855 Symbol::new("BTC", "USDT")
856 }
857
858 fn client() -> (Upbit, Arc<MockHttpTransport>) {
859 let mock = Arc::new(MockHttpTransport::new());
860 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
861 (
862 Upbit::with_http(Box::new(ArcTransport(Arc::clone(&mock))), &opts),
863 mock,
864 )
865 }
866
867 fn signed_client(now_ms: i64) -> (Upbit, Arc<MockHttpTransport>) {
868 let mock = Arc::new(MockHttpTransport::new());
869 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
870 let upbit = Upbit::with_credentials(
871 Box::new(ArcTransport(Arc::clone(&mock))),
872 &opts,
873 Credentials::new("APIKEY", "SECRET"),
874 )
875 .with_clock(Box::new(move || now_ms));
876 (upbit, mock)
877 }
878
879 #[test]
885 fn the_public_reads_carry_the_venue_stamp() {
886 let (upbit, mock) = signed_client(1000);
887 mock.push_json(
888 200,
889 r#"[{"market":"KRW-BTC","trade_timestamp":1788396652184,"trade_price":106112000.0,"acc_trade_volume_24h":123.4}]"#,
890 );
891 let ticker = upbit.ticker(&Symbol::new("BTC", "KRW")).unwrap();
892 assert_eq!(ticker.timestamp, 1_788_396_652_184);
893
894 let (upbit, mock) = signed_client(1000);
895 mock.push_json(
896 200,
897 r#"[{"market":"KRW-BTC","timestamp":1788396655466,"orderbook_units":[{"bid_price":106101000,"bid_size":0.002,"ask_price":106112000,"ask_size":0.06}]}]"#,
898 );
899 let book = upbit.order_book(&Symbol::new("BTC", "KRW"), 5).unwrap();
900 assert_eq!(book.timestamp, 1_788_396_655_466);
901 }
902
903 #[test]
904 fn spot_only_fields_are_refused_and_the_client_id_is_carried() {
905 let (upbit, mock) = signed_client(1000);
906 let reduce = OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)).reduce_only();
907 let err = upbit.place_order(&reduce).unwrap_err();
908 assert!(matches!(err, Error::Exchange { ref code, .. } if code == "unsupported"));
909 assert_eq!(mock.recorded_requests(), Vec::new());
910
911 let (upbit, mock) = signed_client(1000);
912 mock.push_json(200, "{}");
913 let identified =
914 OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)).with_client_order_id("abc-123");
915 let _ = upbit.place_order(&identified);
916 let url = mock.recorded_requests()[0].url.clone();
917 let body = mock.recorded_requests()[0].body.clone().unwrap_or_default();
918 assert!(url.contains("identifier=abc-123") || body.contains("abc-123"));
919 }
920
921 #[test]
922 fn wire_symbol_is_quote_first() {
923 assert_eq!(Upbit::wire_symbol(&symbol()), "USDT-BTC");
924 assert_eq!(split_wire_symbol("USDT-BTC"), symbol());
925 }
926
927 #[test]
928 fn ticker_uses_trade_price() {
929 let (upbit, mock) = client();
930 mock.push_json(
931 200,
932 r#"[{"market":"USDT-BTC","trade_price":20000.5,"acc_trade_volume_24h":1234.0}]"#,
933 );
934 let ticker = upbit.ticker(&symbol()).unwrap();
935 assert_eq!(ticker.last, dec!(20000.5));
936 assert_eq!(ticker.bid, dec!(20000.5));
937 assert_eq!(ticker.volume, dec!(1234));
938 assert_eq!(
939 mock.recorded_requests()[0].url,
940 "https://api.upbit.com/v1/ticker?markets=USDT-BTC"
941 );
942 }
943
944 #[test]
945 fn klines_reversed() {
946 let (upbit, mock) = client();
947 mock.push_json(
948 200,
949 r#"[{"timestamp":1700003600000,"opening_price":105,"high_price":106,"low_price":104,
950 "trade_price":105.5,"candle_acc_trade_volume":2},
951 {"timestamp":1700000000000,"opening_price":100,"high_price":110,"low_price":95,
952 "trade_price":105,"candle_acc_trade_volume":12}]"#,
953 );
954 let candles = upbit.klines(&symbol(), "1h", 2).unwrap();
955 assert_eq!(candles[0].timestamp, 1_700_000_000_000);
956 assert!((candles[0].high - 110.0).abs() < 1e-9);
957 assert!(mock.recorded_requests()[0]
958 .url
959 .contains("/v1/candles/minutes/60"));
960 }
961
962 #[test]
963 fn order_book_splits_units() {
964 let (upbit, mock) = client();
965 mock.push_json(
966 200,
967 r#"[{"market":"USDT-BTC","orderbook_units":[
968 {"ask_price":101,"bid_price":100,"ask_size":2,"bid_size":1}]}]"#,
969 );
970 let book = upbit.order_book(&symbol(), 5).unwrap();
971 assert_eq!(book.bids[0], BookLevel::new(dec!(100), dec!(1)));
972 assert_eq!(book.asks[0], BookLevel::new(dec!(101), dec!(2)));
973 }
974
975 #[test]
976 fn place_order_signs_with_hs512_jwt_and_query_hash() {
977 let (upbit, mock) = signed_client(1000);
978 mock.push_json(
979 200,
980 r#"{"uuid":"U1","market":"USDT-BTC","side":"bid","ord_type":"limit","state":"wait",
981 "volume":"1","executed_volume":"0","price":"100"}"#,
982 );
983 let order = upbit
984 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
985 .unwrap();
986 assert_eq!(order.id, "U1");
987 assert_eq!(order.side, OrderSide::Buy);
988
989 let req = &mock.recorded_requests()[0];
990 assert_eq!(req.method, HttpMethod::Post);
991 let auth = req
992 .headers
993 .iter()
994 .find(|(k, _)| k == "Authorization")
995 .map(|(_, v)| v.strip_prefix("Bearer ").unwrap())
996 .unwrap();
997 let query = "market=USDT-BTC&side=bid&ord_type=limit&volume=1&price=100";
999 let query_hash = sha512_hex(query.as_bytes());
1000 let header = br#"{"alg":"HS512","typ":"JWT"}"#;
1001 let payload = serde_json::json!({
1002 "access_key":"APIKEY","nonce":"wkex-3e8",
1003 "query_hash":query_hash,"query_hash_alg":"SHA512"
1004 });
1005 let signing_input = format!(
1006 "{}.{}",
1007 b64url(header),
1008 b64url(payload.to_string().as_bytes())
1009 );
1010 let sig = hmac_sha512_bytes(b"SECRET", signing_input.as_bytes());
1011 assert_eq!(auth, format!("{signing_input}.{}", b64url(&sig)));
1012 assert_eq!(req.body.as_deref(), Some(query));
1013 }
1014
1015 #[test]
1016 fn query_and_balances() {
1017 let (upbit, mock) = signed_client(1000);
1018 mock.push_json(
1019 200,
1020 r#"{"uuid":"U1","side":"ask","ord_type":"limit","state":"done","volume":"2",
1021 "executed_volume":"2","price":"100"}"#,
1022 );
1023 let order = upbit.query_order(&symbol(), "U1").unwrap();
1024 assert_eq!(order.status, OrderStatus::Filled);
1025 assert_eq!(order.side, OrderSide::Sell);
1026 assert_eq!(order.filled_quantity, dec!(2));
1027
1028 mock.push_json(
1029 200,
1030 r#"[{"currency":"USDT","balance":"100.5","locked":"25.5"}]"#,
1031 );
1032 let bals = upbit.balances().unwrap();
1033 assert_eq!(bals[0].asset, "USDT");
1034 assert_eq!(bals[0].total(), dec!(126));
1035 let reqs = mock.recorded_requests();
1037 assert!(reqs[1].headers.iter().any(|(k, _)| k == "Authorization"));
1038 }
1039
1040 #[test]
1041 fn signed_requires_credentials() {
1042 let (upbit, _) = client();
1043 assert!(matches!(
1044 upbit.balances().unwrap_err(),
1045 Error::InvalidCredentials(_)
1046 ));
1047 }
1048
1049 #[test]
1050 fn ws_parses_trade_and_orderbook() {
1051 let ws = Arc::new(MockWsTransport::new());
1052 ws.push_connection(vec![
1053 Ok(Some(
1054 r#"{"type":"trade","code":"USDT-BTC","trade_price":100,"trade_volume":0.5,
1055 "ask_bid":"BID","trade_timestamp":1700}"#
1056 .to_string(),
1057 )),
1058 Ok(Some(
1059 r#"{"type":"orderbook","code":"USDT-BTC","orderbook_units":[
1060 {"ask_price":101,"bid_price":100,"ask_size":2,"bid_size":1}]}"#
1061 .to_string(),
1062 )),
1063 ]);
1064 let http = Arc::new(MockHttpTransport::new());
1065 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
1066 let mut upbit = Upbit::with_http(Box::new(ArcTransport(http)), &opts)
1067 .with_ws(Box::new(ArcWs(Arc::clone(&ws))));
1068 upbit.subscribe_trades(&symbol()).unwrap();
1069 assert!(ws.sent()[0].contains(r#""type":"trade""#));
1070 assert!(ws.sent()[0].contains(r#""codes":["USDT-BTC"]"#));
1071
1072 let events = upbit.poll_events();
1073 assert_eq!(events.len(), 2);
1074 let Event::Trade(t) = &events[0] else {
1075 panic!("expected trade")
1076 };
1077 assert_eq!(t.aggressor, OrderSide::Buy);
1078 assert_eq!(t.symbol, symbol());
1079 assert!(matches!(events[1], Event::BookSnapshot(_)));
1080 }
1081
1082 #[test]
1083 fn works_as_a_boxed_exchange() {
1084 let (upbit, mock) = signed_client(1000);
1085 mock.push_json(
1086 200,
1087 r#"{"uuid":"O1","side":"bid","ord_type":"limit","state":"wait","volume":"1","price":"100"}"#,
1088 );
1089 let mut exchange: Box<dyn Exchange> = Box::new(upbit);
1090 assert_eq!(exchange.name(), "upbit");
1091 let order = exchange
1092 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
1093 .unwrap();
1094 assert_eq!(order.id, "O1");
1095 }
1096
1097 #[test]
1098 fn system_clock_is_sane() {
1099 assert!(system_now_ms() > 1_600_000_000_000);
1100 }
1101
1102 #[test]
1106 fn debug_reports_state_without_credentials() {
1107 let (client, _http) = signed_client(1_700_000_000_000);
1108 let rendered = format!("{client:?}");
1109
1110 assert!(rendered.starts_with("Upbit {"));
1111 assert!(rendered.contains("authenticated: true"));
1112 assert!(!rendered.contains("SECRET"));
1113 assert!(!rendered.contains("APIKEY"));
1114 }
1115
1116 #[test]
1124 fn rate_limit_carries_the_venues_advised_wait() {
1125 let (upbit, mock) = client();
1126 mock.push_response(
1127 crate::transport::HttpResponse::new(
1128 429,
1129 r#"{"error":{"name":"too_many_requests","message":"slow"}}"#,
1130 )
1131 .with_header("Retry-After", "2.5"),
1132 );
1133 let err = upbit.ticker(&symbol()).unwrap_err();
1134 let wait = std::time::Duration::from_millis(2500);
1135 assert!(matches!(err, Error::RateLimited { retry_after: Some(d) } if d == wait));
1136 }
1137
1138 #[test]
1150 fn two_signed_calls_on_a_frozen_clock_get_different_nonces() {
1151 let (upbit, mock) = signed_client(1_000);
1152 mock.push_json(200, r#"{"uuid":"U1","side":"bid","state":"wait"}"#);
1153 mock.push_json(200, r#"{"uuid":"U2","side":"bid","state":"wait"}"#);
1154
1155 upbit
1156 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
1157 .unwrap();
1158 upbit
1159 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
1160 .unwrap();
1161
1162 let nonce_of = |index: usize| -> String {
1163 let request = &mock.recorded_requests()[index];
1164 let jwt = request
1165 .headers
1166 .iter()
1167 .find(|(name, _)| name == "Authorization")
1168 .map(|(_, value)| value.strip_prefix("Bearer ").unwrap())
1169 .unwrap();
1170 let payload = jwt.split('.').nth(1).unwrap();
1171 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
1172 .decode(payload)
1173 .unwrap();
1174 let value: serde_json::Value = serde_json::from_slice(&decoded).unwrap();
1175 value["nonce"].as_str().unwrap().to_string()
1176 };
1177
1178 let first = nonce_of(0);
1179 let second = nonce_of(1);
1180 assert_eq!(first, "wkex-3e8", "the first still tracks the clock");
1181 assert_ne!(first, second, "a repeated nonce is refused by the venue");
1182 }
1183}