mod parse;
mod private;
mod rest;
mod stream;
mod travel_rule;
mod wallet;
use futures_core::Stream;
use futures_util::StreamExt;
use rust_decimal::Decimal;
use crate::adapter::{Adapter, BoxFuture};
use crate::error::{Error, Result};
use crate::feature::Feature;
use crate::request::{
CancelOrdersRequest, CandleRequest, DepositAddressRequest, OrderHistoryRequest,
OrderLookupRequest, OrderRequest, TransferHistoryRequest, TransferLookupRequest,
WithdrawRequest,
};
use crate::stream::{AccountStream, MarketStream};
use crate::transport::{HttpTransport, WsCommand, WsConnect, WsSession, ws};
use crate::types::{
AccountEvent, AssetNetwork, Balance, CancelOrdersResult, Candle, Deposit, DepositAddress,
DepositAddressEntry, Exchange, Market, MarketEvent, MarketInfo, MarketKind, Network, Order,
OrderBook, OrderRules, Page, Side, StreamConfig, Subscription, Ticker, TimeInForce, Trade,
TransferDestination, Withdrawal, WithdrawalQuote,
};
pub use travel_rule::{UpbitTravelRuleVasp, UpbitTravelRuleVerification};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum UpbitRegion {
#[default]
Korea,
Singapore,
Indonesia,
Thailand,
}
impl UpbitRegion {
pub(crate) const fn rest_base_url(self) -> &'static str {
match self {
Self::Korea => "https://api.upbit.com",
Self::Singapore => "https://sg-api.upbit.com",
Self::Indonesia => "https://id-api.upbit.com",
Self::Thailand => "https://th-api.upbit.com",
}
}
pub(crate) const fn websocket_url(self) -> &'static str {
match self {
Self::Korea => "wss://api.upbit.com/websocket/v1",
Self::Singapore => "wss://sg-api.upbit.com/websocket/v1",
Self::Indonesia => "wss://id-api.upbit.com/websocket/v1",
Self::Thailand => "wss://th-api.upbit.com/websocket/v1",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct UpbitMarketEvent {
pub warning: bool,
pub cautions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpbitYearCandle {
pub market: Market,
pub open_time: crate::types::Timestamp,
pub korea_open_time: Option<crate::types::Timestamp>,
pub timestamp: crate::types::Timestamp,
pub open: Decimal,
pub high: Decimal,
pub low: Decimal,
pub close: Decimal,
pub volume: Decimal,
pub quote_volume: Decimal,
pub first_day_of_period: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpbitOrderBookInstrument {
pub market: Market,
pub quote_currency: String,
pub tick_size: Decimal,
pub supported_levels: Vec<Decimal>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpbitDepositInfo {
pub asset: String,
pub network: Option<Network>,
pub provider_network: Option<String>,
pub is_deposit_possible: bool,
pub deposit_impossible_reason: Option<String>,
pub minimum_deposit_amount: Decimal,
pub minimum_deposit_confirmations: u64,
pub decimal_precision: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpbitOrderDirection {
Ascending,
Descending,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpbitBatchCancelScope {
All,
QuoteCurrencies {
values: Vec<String>,
},
Pairs {
values: Vec<Market>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpbitBatchCancelRequest {
pub scope: UpbitBatchCancelScope,
pub excluded_pairs: Option<Vec<Market>>,
pub side: Option<Side>,
pub count: Option<u32>,
pub order_by: Option<UpbitOrderDirection>,
}
impl UpbitBatchCancelRequest {
pub fn new(scope: UpbitBatchCancelScope) -> Self {
Self {
scope,
excluded_pairs: None,
side: None,
count: None,
order_by: None,
}
}
#[must_use]
pub fn excluded_pairs(mut self, pairs: impl Into<Vec<Market>>) -> Self {
self.excluded_pairs = Some(pairs.into());
self
}
#[must_use]
pub fn side(mut self, side: Side) -> Self {
self.side = Some(side);
self
}
#[must_use]
pub fn count(mut self, count: u32) -> Self {
self.count = Some(count);
self
}
#[must_use]
pub fn order_by(mut self, order_by: UpbitOrderDirection) -> Self {
self.order_by = Some(order_by);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpbitOrderReference {
Uuid(String),
Identifier(String),
}
impl UpbitOrderReference {
pub fn uuid(value: impl Into<String>) -> Self {
Self::Uuid(value.into())
}
pub fn identifier(value: impl Into<String>) -> Self {
Self::Identifier(value.into())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpbitOrderVolume {
Amount(Decimal),
RemainOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpbitSmpType {
CancelMaker,
CancelTaker,
Reduce,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpbitCancelAndNewOrder {
Limit {
volume: UpbitOrderVolume,
price: Decimal,
time_in_force: Option<TimeInForce>,
},
MarketBuy {
price: Decimal,
},
MarketSell {
volume: UpbitOrderVolume,
},
BestBuy {
price: Decimal,
time_in_force: TimeInForce,
},
BestSell {
volume: UpbitOrderVolume,
time_in_force: TimeInForce,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpbitCancelAndNewOrderRequest {
pub previous_order: UpbitOrderReference,
pub new_order: UpbitCancelAndNewOrder,
pub new_identifier: Option<String>,
pub new_smp_type: Option<UpbitSmpType>,
}
impl UpbitCancelAndNewOrderRequest {
pub fn new(previous_order: UpbitOrderReference, new_order: UpbitCancelAndNewOrder) -> Self {
Self {
previous_order,
new_order,
new_identifier: None,
new_smp_type: None,
}
}
#[must_use]
pub fn new_identifier(mut self, value: impl Into<String>) -> Self {
self.new_identifier = Some(value.into());
self
}
#[must_use]
pub fn new_smp_type(mut self, value: UpbitSmpType) -> Self {
self.new_smp_type = Some(value);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpbitCancelAndNewOrderResult {
pub previous_order: Order,
pub new_order_uuid: Option<String>,
pub new_order_identifier: Option<String>,
}
impl UpbitCancelAndNewOrderResult {
pub fn replacement_created(&self) -> bool {
self.new_order_uuid.is_some()
}
}
#[derive(Debug, Clone)]
pub struct UpbitAdapter {
region: UpbitRegion,
credentials: Option<UpbitCredentials>,
http: std::result::Result<HttpTransport, Error>,
}
#[derive(Debug, Clone)]
pub(crate) struct UpbitCredentials {
pub(crate) access_key: String,
pub(crate) secret_key: String,
}
impl UpbitAdapter {
pub fn new() -> Self {
Self::with_region(UpbitRegion::Korea)
}
pub fn with_region(region: UpbitRegion) -> Self {
Self {
region,
credentials: None,
http: HttpTransport::new(region.rest_base_url()),
}
}
#[must_use]
pub fn with_credentials(
mut self,
access_key: impl Into<String>,
secret_key: impl Into<String>,
) -> Self {
self.credentials = Some(UpbitCredentials {
access_key: access_key.into(),
secret_key: secret_key.into(),
});
self
}
pub fn region(&self) -> UpbitRegion {
self.region
}
pub async fn order_books(
&self,
markets: &[Market],
depth: Option<u32>,
) -> Result<Vec<OrderBook>> {
rest::order_books(self.http()?, markets, depth).await
}
pub async fn order_books_at_level(
&self,
markets: &[Market],
level: Decimal,
depth: Option<u32>,
) -> Result<Vec<OrderBook>> {
if self.region != UpbitRegion::Korea {
return Err(Error::unsupported(
Feature::OrderBook,
Exchange::Upbit.id(),
"order-book aggregation levels are available only in the Upbit Korea region",
));
}
rest::order_books_at_level(self.http()?, markets, level, depth).await
}
pub async fn tickers(&self, markets: &[Market]) -> Result<Vec<Ticker>> {
rest::tickers(self.http()?, markets).await
}
pub async fn tickers_by_quote(&self, quote_currencies: &[String]) -> Result<Vec<Ticker>> {
rest::tickers_by_quote(self.http()?, quote_currencies).await
}
pub async fn year_candles(
&self,
market: &Market,
to: Option<crate::types::Timestamp>,
count: Option<u32>,
) -> Result<Vec<UpbitYearCandle>> {
rest::year_candles(self.http()?, market, to, count).await
}
pub async fn orderbook_instruments(
&self,
markets: &[Market],
) -> Result<Vec<UpbitOrderBookInstrument>> {
rest::orderbook_instruments(self.http()?, markets).await
}
pub async fn market_events(&self) -> Result<Vec<(Market, UpbitMarketEvent)>> {
rest::market_events(self.http()?).await
}
pub async fn test_order(&self, request: &OrderRequest) -> Result<Order> {
private::test_order(self.credentials()?, self.http()?, request).await
}
pub async fn deposit_info(&self, asset: &str, network: &Network) -> Result<UpbitDepositInfo> {
wallet::deposit_info(self.credentials()?, self.http()?, asset, network).await
}
pub async fn travel_rule_vasps(&self) -> Result<Vec<UpbitTravelRuleVasp>> {
travel_rule::ensure_supported_region(self.region)?;
travel_rule::vasps(self.region, self.credentials()?, self.http()?).await
}
pub async fn verify_travel_rule_by_uuid(
&self,
deposit_uuid: &str,
vasp_uuid: &str,
) -> Result<UpbitTravelRuleVerification> {
travel_rule::ensure_supported_region(self.region)?;
travel_rule::verify_by_uuid(
self.region,
self.credentials()?,
self.http()?,
deposit_uuid,
vasp_uuid,
)
.await
}
pub async fn verify_travel_rule_by_txid(
&self,
txid: &str,
vasp_uuid: &str,
currency: &str,
net_type: &str,
) -> Result<UpbitTravelRuleVerification> {
travel_rule::ensure_supported_region(self.region)?;
travel_rule::verify_by_txid(
self.region,
self.credentials()?,
self.http()?,
txid,
vasp_uuid,
currency,
net_type,
)
.await
}
pub async fn batch_cancel_open_orders(
&self,
request: &UpbitBatchCancelRequest,
) -> Result<CancelOrdersResult> {
private::batch_cancel_open_orders(self.credentials()?, self.http()?, request).await
}
pub async fn cancel_and_new_order(
&self,
request: &UpbitCancelAndNewOrderRequest,
) -> Result<UpbitCancelAndNewOrderResult> {
private::cancel_and_new_order(self.credentials()?, self.http()?, request).await
}
pub(crate) fn is_authenticated(&self) -> bool {
self.credentials.is_some()
}
fn http(&self) -> Result<&HttpTransport> {
self.http.as_ref().map_err(Clone::clone)
}
fn credentials(&self) -> Result<&UpbitCredentials> {
self.credentials.as_ref().ok_or_else(|| {
Error::auth(
"this Upbit adapter has no credentials; add them with \
`UpbitAdapter::with_credentials`",
)
})
}
fn validate_withdrawal_destination(&self, request: &WithdrawRequest) -> Result<()> {
if self.region == UpbitRegion::Indonesia
&& !matches!(
&request.destination,
TransferDestination::Exchange(destination)
if destination.exchange == Exchange::Upbit
)
{
return Err(Error::unsupported(
Feature::Withdrawals,
"upbit",
"Upbit Indonesia external withdrawals require beneficiary fields that are not yet represented by the common withdrawal request",
));
}
Ok(())
}
}
impl Default for UpbitAdapter {
fn default() -> Self {
Self::new()
}
}
impl Adapter for UpbitAdapter {
fn exchange(&self) -> Exchange {
Exchange::Upbit
}
fn supports(&self, feature: Feature) -> bool {
if feature.is_derivatives_only() {
return false;
}
if feature == Feature::TravelRule {
return matches!(self.region, UpbitRegion::Korea | UpbitRegion::Singapore)
&& self.is_authenticated();
}
if feature.needs_credentials() {
return self.is_authenticated();
}
true
}
fn markets(&self, kind: MarketKind) -> BoxFuture<'_, Result<Vec<MarketInfo>>> {
Box::pin(async move { rest::markets(self.http()?, kind).await })
}
fn trades(&self, market: &Market, limit: Option<u32>) -> BoxFuture<'_, Result<Vec<Trade>>> {
let market = market.clone();
Box::pin(async move { rest::trades(self.http()?, &market, limit).await })
}
fn order_book(&self, market: &Market, depth: Option<u32>) -> BoxFuture<'_, Result<OrderBook>> {
let market = market.clone();
Box::pin(async move {
let books = self
.order_books(std::slice::from_ref(&market), depth)
.await?;
rest::only(books, &market)
})
}
fn ticker(&self, market: &Market) -> BoxFuture<'_, Result<Ticker>> {
let market = market.clone();
Box::pin(async move {
let tickers = self.tickers(std::slice::from_ref(&market)).await?;
rest::only(tickers, &market)
})
}
fn candles(&self, request: &CandleRequest) -> BoxFuture<'_, Result<Vec<Candle>>> {
let request = request.clone();
Box::pin(async move { rest::candles(self.http()?, &request).await })
}
fn subscribe(
&self,
subscription: &Subscription,
config: &StreamConfig,
) -> BoxFuture<'_, Result<MarketStream>> {
let frame = stream::subscribe_frame(subscription, &ticket());
let url = self.region.websocket_url().to_string();
let config = config.clone();
Box::pin(async move {
let session = ws::connect(
WsConnect {
url,
headers: None,
subscribe: WsConnect::fixed(vec![frame?]),
heartbeat: Some(stream::HEARTBEAT),
},
&config,
)
.await?;
let close = session.close_handle();
let mut decoder = stream::Decoder::default();
Ok(MarketStream::new_with_close(
events(
session,
move |frame| decoder.decode(frame),
MarketEvent::Reconnected,
),
move || async move { close.close().await },
))
})
}
fn balances(&self) -> BoxFuture<'_, Result<Vec<Balance>>> {
Box::pin(async move { private::balances(self.credentials()?, self.http()?).await })
}
fn order_rules(&self, market: &Market) -> BoxFuture<'_, Result<OrderRules>> {
let market = market.clone();
Box::pin(
async move { private::order_rules(self.credentials()?, self.http()?, &market).await },
)
}
fn asset_networks(&self, asset: &str) -> BoxFuture<'_, Result<Vec<AssetNetwork>>> {
let asset = asset.to_string();
Box::pin(
async move { wallet::asset_networks(self.credentials()?, self.http()?, &asset).await },
)
}
fn deposit_addresses(&self) -> BoxFuture<'_, Result<Vec<DepositAddressEntry>>> {
Box::pin(async move { wallet::deposit_addresses(self.credentials()?, self.http()?).await })
}
fn deposit_address(
&self,
request: &DepositAddressRequest,
) -> BoxFuture<'_, Result<DepositAddress>> {
let request = request.clone();
Box::pin(async move {
wallet::deposit_address(self.credentials()?, self.http()?, &request).await
})
}
fn create_deposit_address(
&self,
request: &DepositAddressRequest,
) -> BoxFuture<'_, Result<DepositAddress>> {
let request = request.clone();
Box::pin(async move {
wallet::create_deposit_address(self.credentials()?, self.http()?, &request).await
})
}
fn prepare_withdrawal(
&self,
request: &WithdrawRequest,
) -> BoxFuture<'_, Result<WithdrawalQuote>> {
let request = request.clone();
Box::pin(async move {
self.validate_withdrawal_destination(&request)?;
wallet::prepare_withdrawal(self.credentials()?, self.http()?, &request).await
})
}
fn withdraw(&self, request: &WithdrawRequest) -> BoxFuture<'_, Result<Withdrawal>> {
let request = request.clone();
Box::pin(async move {
self.validate_withdrawal_destination(&request)?;
wallet::withdraw(self.credentials()?, self.http()?, &request).await
})
}
fn deposit(&self, request: &TransferLookupRequest) -> BoxFuture<'_, Result<Deposit>> {
let request = request.clone();
Box::pin(async move { wallet::deposit(self.credentials()?, self.http()?, &request).await })
}
fn withdrawal(&self, request: &TransferLookupRequest) -> BoxFuture<'_, Result<Withdrawal>> {
let request = request.clone();
Box::pin(
async move { wallet::withdrawal(self.credentials()?, self.http()?, &request).await },
)
}
fn cancel_withdrawal(&self, withdrawal_id: &str) -> BoxFuture<'_, Result<()>> {
let withdrawal_id = withdrawal_id.to_owned();
Box::pin(async move {
wallet::cancel_withdrawal(self.credentials()?, self.http()?, &withdrawal_id).await
})
}
fn deposits(&self, request: &TransferHistoryRequest) -> BoxFuture<'_, Result<Page<Deposit>>> {
let request = request.clone();
Box::pin(async move { wallet::deposits(self.credentials()?, self.http()?, &request).await })
}
fn withdrawals(
&self,
request: &TransferHistoryRequest,
) -> BoxFuture<'_, Result<Page<Withdrawal>>> {
let request = request.clone();
Box::pin(
async move { wallet::withdrawals(self.credentials()?, self.http()?, &request).await },
)
}
fn open_orders(&self, market: Option<&Market>) -> BoxFuture<'_, Result<Vec<Order>>> {
let market = market.cloned();
Box::pin(async move {
private::open_orders(self.credentials()?, self.http()?, market.as_ref()).await
})
}
fn order(&self, market: &Market, order_id: &str) -> BoxFuture<'_, Result<Order>> {
let market = market.clone();
let order_id = order_id.to_string();
Box::pin(async move {
private::order(self.credentials()?, self.http()?, &market, &order_id).await
})
}
fn order_by_client_id(&self, market: &Market, client_id: &str) -> BoxFuture<'_, Result<Order>> {
let market = market.clone();
let client_id = client_id.to_string();
Box::pin(async move {
private::order_by_client_id(self.credentials()?, self.http()?, &market, &client_id)
.await
})
}
fn orders_by_ids(&self, request: &OrderLookupRequest) -> BoxFuture<'_, Result<Vec<Order>>> {
let request = request.clone();
Box::pin(async move {
private::orders_by_ids(self.credentials()?, self.http()?, &request).await
})
}
fn order_history(&self, request: &OrderHistoryRequest) -> BoxFuture<'_, Result<Page<Order>>> {
let request = request.clone();
Box::pin(async move {
private::order_history(self.credentials()?, self.http()?, &request).await
})
}
fn place_order(&self, request: &OrderRequest) -> BoxFuture<'_, Result<Order>> {
let request = request.clone();
Box::pin(
async move { private::place_order(self.credentials()?, self.http()?, &request).await },
)
}
fn cancel_order(&self, market: &Market, order_id: &str) -> BoxFuture<'_, Result<()>> {
let market = market.clone();
let order_id = order_id.to_string();
Box::pin(async move {
private::cancel_order(self.credentials()?, self.http()?, &market, &order_id).await
})
}
fn cancel_order_by_client_id(
&self,
market: &Market,
client_id: &str,
) -> BoxFuture<'_, Result<()>> {
let market = market.clone();
let client_id = client_id.to_string();
Box::pin(async move {
private::cancel_order_by_client_id(
self.credentials()?,
self.http()?,
&market,
&client_id,
)
.await
})
}
fn cancel_orders(
&self,
request: &CancelOrdersRequest,
) -> BoxFuture<'_, Result<CancelOrdersResult>> {
let request = request.clone();
Box::pin(async move {
private::cancel_orders(self.credentials()?, self.http()?, &request).await
})
}
fn subscribe_account(&self, config: &StreamConfig) -> BoxFuture<'_, Result<AccountStream>> {
let url = format!("{}/private", self.region.websocket_url());
let config = config.clone();
Box::pin(async move {
let credentials = self.credentials()?.clone();
let session = ws::connect(
WsConnect {
url,
headers: Some(Box::new(move || {
Ok(vec![(
private::AUTHORIZATION.to_string(),
private::authorization(&credentials, "")?,
)])
})),
subscribe: WsConnect::fixed(vec![private::subscribe_frame(&ticket())?]),
heartbeat: Some(stream::HEARTBEAT),
},
&config,
)
.await?;
let close = session.close_handle();
Ok(AccountStream::new_with_close(
events(session, private::account_events, AccountEvent::Reconnected),
move || async move { close.close().await },
))
})
}
}
fn ticket() -> String {
uuid::Uuid::new_v4().to_string()
}
fn events<T: Clone + Send + 'static>(
session: WsSession,
mut decode: impl FnMut(&str) -> Result<Vec<T>> + Send + 'static,
reconnected: T,
) -> impl Stream<Item = Result<T>> + Send {
session.flat_map(move |item| {
let items = match item {
Ok(WsCommand::Text(text)) => split(decode(&text)),
Ok(WsCommand::Binary(bytes)) => match String::from_utf8(bytes) {
Ok(text) => split(decode(&text)),
Err(err) => vec![Err(Error::decode(format!(
"upbit sent a frame that is not UTF-8: {err}"
)))],
},
Ok(WsCommand::Reconnected) => vec![Ok(reconnected.clone())],
Err(err) => vec![Err(err)],
};
futures_util::stream::iter(items)
})
}
fn split<T>(decoded: Result<Vec<T>>) -> Vec<Result<T>> {
match decoded {
Ok(items) => items.into_iter().map(Ok).collect(),
Err(err) => vec![Err(err)],
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_spot_exchange_never_claims_derivatives_features() {
let adapter = UpbitAdapter::new().with_credentials("access", "secret");
for feature in [
Feature::Positions,
Feature::Margin,
Feature::FundingRates,
Feature::FundingPayments,
Feature::MarginConfig,
Feature::ReduceOnlyOrders,
] {
assert!(!adapter.supports(feature), "{feature:?}");
}
}
#[test]
fn credentials_are_what_unlock_the_private_half() {
let public = UpbitAdapter::new();
let private = UpbitAdapter::new().with_credentials("access", "secret");
for feature in [
Feature::Balances,
Feature::AssetNetworks,
Feature::DepositAddresses,
Feature::DepositHistory,
Feature::DepositLookup,
Feature::WithdrawalQuotes,
Feature::Withdrawals,
Feature::WithdrawalHistory,
Feature::WithdrawalLookup,
Feature::WithdrawalCancellation,
Feature::Trading,
Feature::AccountStream,
] {
assert!(!public.supports(feature), "{feature:?}");
assert!(private.supports(feature), "{feature:?}");
}
}
#[test]
fn public_market_data_works_without_credentials() {
let public = UpbitAdapter::new();
for feature in [
Feature::Markets,
Feature::Trades,
Feature::OrderBook,
Feature::Ticker,
Feature::Candles,
Feature::CandleStream,
] {
assert!(public.supports(feature), "{feature:?}");
}
}
#[test]
fn travel_rule_requires_a_supported_region_and_credentials() {
assert!(!UpbitAdapter::new().supports(Feature::TravelRule));
assert!(!UpbitAdapter::with_region(UpbitRegion::Singapore).supports(Feature::TravelRule));
assert!(
UpbitAdapter::new()
.with_credentials("access", "secret")
.supports(Feature::TravelRule)
);
assert!(
UpbitAdapter::with_region(UpbitRegion::Singapore)
.with_credentials("access", "secret")
.supports(Feature::TravelRule)
);
assert!(
!UpbitAdapter::with_region(UpbitRegion::Indonesia)
.with_credentials("access", "secret")
.supports(Feature::TravelRule)
);
}
#[tokio::test]
async fn travel_rule_region_precedes_credential_validation() {
let error = UpbitAdapter::with_region(UpbitRegion::Indonesia)
.travel_rule_vasps()
.await
.expect_err("unsupported region must fail before credentials");
assert!(matches!(
error,
Error::Unsupported {
feature: Feature::TravelRule,
..
}
));
}
#[tokio::test]
async fn aggregated_order_books_fail_before_network_outside_korea() {
let singapore = UpbitAdapter::with_region(UpbitRegion::Singapore);
let market = Market::spot(Exchange::Upbit, "BTC", "SGD");
assert!(matches!(
singapore
.order_books_at_level(&[market], Decimal::ONE, Some(1))
.await,
Err(Error::Unsupported {
feature: Feature::OrderBook,
..
})
));
}
#[tokio::test]
async fn an_account_call_without_credentials_fails_before_the_network() {
let public = UpbitAdapter::new();
let market = Market::spot(Exchange::Upbit, "BTC", "KRW");
let order = crate::request::OrderRequest::market(
market.clone(),
crate::types::Side::Sell,
crate::types::Size::Base(rust_decimal::Decimal::ONE),
);
assert!(matches!(public.balances().await, Err(Error::Auth { .. })));
assert!(matches!(
public.open_orders(None).await,
Err(Error::Auth { .. })
));
assert!(matches!(
public.place_order(&order).await,
Err(Error::Auth { .. })
));
assert!(matches!(
public.test_order(&order).await,
Err(Error::Auth { .. })
));
assert!(matches!(
public
.cancel_and_new_order(&UpbitCancelAndNewOrderRequest::new(
UpbitOrderReference::uuid("order-1"),
UpbitCancelAndNewOrder::MarketSell {
volume: UpbitOrderVolume::RemainOnly,
},
))
.await,
Err(Error::Auth { .. })
));
assert!(matches!(
public
.deposit_info("BTC", &crate::types::Network::Bitcoin)
.await,
Err(Error::Auth { .. })
));
assert!(matches!(
public
.batch_cancel_open_orders(
&UpbitBatchCancelRequest::new(UpbitBatchCancelScope::All,)
)
.await,
Err(Error::Auth { .. })
));
assert!(matches!(
public.cancel_order(&market, "an-order").await,
Err(Error::Auth { .. })
));
assert!(matches!(
public.subscribe_account(&StreamConfig::default()).await,
Err(Error::Auth { .. })
));
}
#[tokio::test]
async fn the_derivatives_half_stays_at_the_trait_default() {
let adapter = UpbitAdapter::new().with_credentials("access", "secret");
let request =
crate::request::HistoryRequest::new(Market::perpetual(Exchange::Upbit, "BTC", "KRW"));
assert!(matches!(
adapter.positions(None).await,
Err(Error::Unsupported { .. })
));
assert!(matches!(
adapter.margin_summary().await,
Err(Error::Unsupported { .. })
));
assert!(matches!(
adapter.funding_rates(&request).await,
Err(Error::Unsupported { .. })
));
}
#[tokio::test]
async fn upbit_lists_no_derivatives_and_says_so_with_an_empty_answer() {
let markets = UpbitAdapter::new()
.markets(MarketKind::Perpetual)
.await
.expect("a listable kind");
assert!(markets.is_empty());
}
#[test]
fn a_frame_that_carries_no_events_yields_none_and_a_bad_one_yields_one_error() {
let mut decoder = stream::Decoder::default();
assert!(
decoder
.decode(r#"{"status":"UP"}"#)
.expect("a control frame")
.is_empty()
);
assert_eq!(split(decoder.decode("not json")).len(), 1);
assert_eq!(split(decoder.decode(r#"{"status":"UP"}"#)).len(), 0);
}
#[test]
fn each_region_is_a_separate_deployment() {
assert_eq!(UpbitAdapter::new().region(), UpbitRegion::Korea);
assert_ne!(
UpbitRegion::Korea.rest_base_url(),
UpbitRegion::Singapore.rest_base_url()
);
assert!(UpbitRegion::Thailand.websocket_url().starts_with("wss://"));
}
#[tokio::test]
async fn indonesia_external_withdrawal_fails_during_preparation_and_submission() {
use crate::types::{ChainDestination, Network};
use rust_decimal::Decimal;
let request = WithdrawRequest::new(
"BTC",
Network::Bitcoin,
Decimal::ONE,
TransferDestination::Chain(ChainDestination {
asset: "BTC".to_string(),
network: Network::Bitcoin,
address: "bc1destination".to_string(),
memo: None,
}),
);
let adapter = UpbitAdapter::with_region(UpbitRegion::Indonesia);
for result in [
adapter.prepare_withdrawal(&request).await.map(|_| ()),
adapter.withdraw(&request).await.map(|_| ()),
] {
assert!(matches!(
result,
Err(Error::Unsupported {
feature: Feature::Withdrawals,
..
})
));
}
}
}