Skip to main content

wickra_exchange_core/exchanges/
upbit.rs

1//! Upbit — the tenth exchange.
2//!
3//! Upbit authenticates with a **JWT HS512**: the payload carries `access_key`
4//! and a `nonce`, plus — for parameterised requests — a `query_hash` (hex
5//! SHA-512 of the form-encoded parameters) and `query_hash_alg="SHA512"`, signed
6//! HMAC-SHA512 with the secret. Markets are **quote-first** (`USDT-BTC`), and
7//! market-data JSON encodes numbers as JSON numbers. There is no envelope;
8//! errors come back as an HTTP error status with `{error:{name, message}}`.
9
10use 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
42/// An Upbit client over injected transports.
43pub struct Upbit {
44    http: Box<dyn HttpTransport>,
45    ws: Option<Box<dyn WsTransport>>,
46    rest_base: String,
47    /// The market this client was built for.
48    ///
49    /// This venue serves spot only, and the market was previously dropped on the
50    /// floor: a client built for futures answered spot questions with spot data
51    /// and said nothing.
52    market_type: MarketType,
53    credentials: Option<Credentials>,
54    now_ms: Box<dyn Fn() -> i64 + Send + Sync>,
55    /// Upbit refuses a JWT whose `nonce` it has seen before. The wall clock in
56    /// milliseconds repeats when two signed calls land inside one millisecond,
57    /// so the nonce is forced strictly upward instead -- the same generator
58    /// Kraken uses, for the same reason.
59    nonces: NonceGenerator,
60    connection: Option<Box<dyn WsConnection>>,
61    sub_messages: Vec<String>,
62}
63
64/// Hand-written: the client holds `Box<dyn HttpTransport>`, `Box<dyn WsTransport>`
65/// and a `Box<dyn Fn() -> i64>` clock, none of which a derive can reach. The
66/// transports are shown by whether a connection is open rather than by value, and
67/// the credentials only by whether they are set -- printing them would put secret
68/// material into every log line that formats a client.
69impl 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    /// Refuse a market this client does not route.
83    ///
84    /// Called at every seam that reaches the venue -- the HTTP helpers and each
85    /// WebSocket connect -- so an unrouted market is refused before a request
86    /// is built rather than answered by whichever market the URL happened to
87    /// name. See [`ensure_market_is_routed`](super::ensure_market_is_routed)
88    /// for what each client used to answer instead.
89    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    /// Build a public Upbit client.
112    #[must_use]
113    pub fn with_http(http: Box<dyn HttpTransport>, options: &ExchangeOptions) -> Self {
114        Self::build(http, options, None)
115    }
116
117    /// Build an authenticated Upbit client.
118    #[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    /// Override the timestamp/nonce source (deterministic signing in tests).
128    #[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    /// Attach a WebSocket transport.
135    #[must_use]
136    pub fn with_ws(mut self, ws: Box<dyn WsTransport>) -> Self {
137        self.ws = Some(ws);
138        self
139    }
140
141    /// The Upbit wire market for a canonical [`Symbol`] (`BTC/USDT` -> `USDT-BTC`,
142    /// quote first).
143    #[must_use]
144    pub fn wire_symbol(symbol: &Symbol) -> String {
145        format!("{}-{}", symbol.quote(), symbol.base())
146    }
147
148    /// A ticker for `symbol`. Upbit's ticker has no separate bid/ask, so both
149    /// mirror the last trade price.
150    ///
151    /// # Errors
152    /// Returns an [`Error`] if the request fails or the symbol is unknown.
153    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    /// Up to `limit` candles for `symbol` at `interval` (unified). Upbit returns
175    /// newest-first; the result is chronological.
176    ///
177    /// # Errors
178    /// Returns an [`Error`] if the request fails or a row cannot be parsed.
179    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    /// A depth snapshot of `symbol`.
192    ///
193    /// # Errors
194    /// Returns an [`Error`] if the request fails or the response cannot be parsed.
195    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    /// Subscribe to the public trade stream for `symbol`.
231    ///
232    /// # Errors
233    /// Returns [`Error::NotConnected`] if no WebSocket transport is configured.
234    pub fn subscribe_trades(&mut self, symbol: &Symbol) -> Result<()> {
235        self.subscribe(symbol, "trade")
236    }
237
238    /// Subscribe to the order-book stream for `symbol`.
239    ///
240    /// # Errors
241    /// See [`subscribe_trades`](Self::subscribe_trades).
242    pub fn subscribe_book(&mut self, symbol: &Symbol) -> Result<()> {
243        self.subscribe(symbol, "orderbook")
244    }
245
246    /// Subscribe to the ticker stream for `symbol`.
247    ///
248    /// # Errors
249    /// See [`subscribe_trades`](Self::subscribe_trades).
250    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    /// Drain all stream events available since the last call. Non-blocking.
277    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    /// Place an order.
298    ///
299    /// # Errors
300    /// Returns an [`Error`] if the order is invalid, credentials are missing, or
301    /// the venue rejects it.
302    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", &params)?;
326        order_from_value(request.symbol.clone(), &value)
327    }
328
329    /// Cancel an open order by venue id.
330    ///
331    /// # Errors
332    /// Returns an [`Error`] if credentials are missing or the venue rejects it.
333    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    /// Query a single order by venue id.
343    ///
344    /// # Errors
345    /// Returns an [`Error`] if credentials are missing or the order is unknown.
346    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    /// Open orders, optionally filtered to one `symbol`.
356    ///
357    /// # Errors
358    /// Returns an [`Error`] if credentials are missing or the request fails.
359    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", &params)?;
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    /// Account balances.
385    ///
386    /// # Errors
387    /// Returns an [`Error`] if credentials are missing or the request fails.
388    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    /// Sign with a JWT HS512 (Authorization: Bearer). Parameterised requests add
417    /// a hex-SHA512 `query_hash`; the parameters are sent form-encoded.
418    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        // Default (and "1h") -> 60-minute candles.
486        _ => "/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
538/// Upbit is a spot-only venue whose order API carries `market`, `side`,
539/// `ord_type`, `volume`, `price` and `time_in_force` -- and nothing else. The
540/// flags it has no field for are refused rather than dropped, so an order that
541/// reaches the venue is the order that was asked for.
542///
543/// `time_in_force` is `ioc` or `fok`, and only on a limit order; a market buy
544/// (`price`) or market sell (`market`) is already immediate-or-cancel and has no
545/// fill-or-kill spelling.
546fn 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        // A market order is immediate-or-cancel by construction, so GTC (the
570        // default) and IOC both ask for what it already does and need no field;
571        // a FOK asks for something it cannot be.
572        (_, 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        // Upbit market buys are priced by total ("price"), market sells by volume.
591        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); // status frame
714    };
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    /// Upbit is spot-only and its order API carries no reduce-only flag, so a
880    /// caller asking to reduce a position is asking for something this venue has
881    /// no concept of. The client order id it *does* carry, as `identifier`.
882    /// Upbit stamps the ticker with `trade_timestamp` and the book with
883    /// `timestamp`. Payloads captured from the live endpoints.
884    #[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        // Reconstruct the HS512 JWT deterministically.
998        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        // The accounts request has no query hash (no params) but still carries a JWT.
1036        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    /// `Debug` reports connection state, never secret material. A client is
1103    /// formatted into logs and error messages, so anything it prints is
1104    /// somewhere a credential must not be.
1105    #[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    /// A rate-limited response carries the venue's advised wait.
1117    ///
1118    /// The limit itself is recognised from the body -- a code in the error
1119    /// envelope -- while the wait arrives in the `Retry-After` header, so the
1120    /// two are read in different places. Until they were joined, every
1121    /// `RateLimited` this client raised carried `retry_after: None`: a field
1122    /// the error type documents, and that nothing in the crate ever filled.
1123    #[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    /// Two signed calls inside one millisecond get different nonces.
1139    ///
1140    /// Upbit refuses a JWT whose `nonce` it has already seen. The nonce used to
1141    /// be the wall clock in milliseconds, so a client issuing two signed calls
1142    /// fast enough sent the same one twice and the second was rejected -- the
1143    /// same defect `NonceGenerator` was written for and applied to Kraken,
1144    /// which was the only venue using it.
1145    ///
1146    /// The clock here is frozen, which is the worst case rather than an
1147    /// artificial one: it is what a burst inside a single millisecond looks
1148    /// like.
1149    #[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}