mod parse;
mod private;
mod rest;
mod stream;
use crate::adapter::{Adapter, BoxFuture};
use crate::error::{Error, Result};
use crate::feature::Feature;
use crate::request::{CandleRequest, OrderRequest};
use crate::stream::{AccountStream, MarketStream};
use crate::transport::HttpTransport;
use crate::types::{
Balance, Candle, Exchange, Market, MarketInfo, MarketKind, Order, OrderBook, StreamConfig,
Subscription, Ticker, Timestamp, Trade,
};
pub(crate) const REST_BASE_URL: &str = "https://api.bithumb.com";
pub(crate) const WEBSOCKET_URL: &str = "wss://ws-api.bithumb.com/websocket/v1";
pub(crate) const PRIVATE_WEBSOCKET_URL: &str = "wss://ws-api.bithumb.com/websocket/v2/private";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub enum BithumbAlertStep {
Caution,
Warning,
Danger,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct BithumbMarketAlert {
pub kind: String,
pub step: BithumbAlertStep,
pub ends_at: Timestamp,
}
#[derive(Debug, Clone)]
pub struct BithumbAdapter {
credentials: Option<BithumbCredentials>,
http: Result<HttpTransport>,
}
#[derive(Debug, Clone)]
pub(crate) struct BithumbCredentials {
pub(crate) access_key: String,
pub(crate) secret_key: String,
}
impl BithumbAdapter {
pub fn new() -> Self {
Self {
credentials: None,
http: HttpTransport::new(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(BithumbCredentials {
access_key: access_key.into(),
secret_key: secret_key.into(),
});
self
}
pub async fn market_warnings(&self) -> Result<Vec<(Market, String)>> {
rest::market_warnings(self.http()?).await
}
pub async fn market_alerts(&self) -> Result<Vec<(Market, BithumbMarketAlert)>> {
rest::market_alerts(self.http()?).await
}
pub(crate) fn is_authenticated(&self) -> bool {
self.credentials.is_some()
}
fn credentials(&self) -> Result<&BithumbCredentials> {
self.credentials
.as_ref()
.ok_or_else(|| Error::auth("bithumb needs both an access key and a secret key"))
}
pub(crate) fn http(&self) -> Result<&HttpTransport> {
self.http.as_ref().map_err(Clone::clone)
}
}
impl Default for BithumbAdapter {
fn default() -> Self {
Self::new()
}
}
impl Adapter for BithumbAdapter {
fn exchange(&self) -> Exchange {
Exchange::Bithumb
}
fn supports(&self, feature: Feature) -> bool {
if feature.is_derivatives_only() {
return false;
}
if matches!(feature, Feature::CandleStream) {
return false;
}
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 { rest::order_book(self.http()?, &market, depth).await })
}
fn ticker(&self, market: &Market) -> BoxFuture<'_, Result<Ticker>> {
let market = market.clone();
Box::pin(async move { rest::ticker(self.http()?, &market).await })
}
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 subscription = subscription.clone();
let config = config.clone();
Box::pin(async move { stream::subscribe(&subscription, &config).await })
}
fn subscribe_account(&self, config: &StreamConfig) -> BoxFuture<'_, Result<AccountStream>> {
let config = config.clone();
Box::pin(async move { stream::subscribe_account(self.credentials()?, &config).await })
}
fn balances(&self) -> BoxFuture<'_, Result<Vec<Balance>>> {
Box::pin(async move { private::balances(self.http()?, self.credentials()?).await })
}
fn open_orders(&self, market: Option<&Market>) -> BoxFuture<'_, Result<Vec<Order>>> {
let market = market.cloned();
Box::pin(async move {
private::open_orders(self.http()?, self.credentials()?, market.as_ref()).await
})
}
fn place_order(&self, request: &OrderRequest) -> BoxFuture<'_, Result<Order>> {
let request = request.clone();
Box::pin(
async move { private::place_order(self.http()?, self.credentials()?, &request).await },
)
}
fn cancel_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::cancel_order(self.http()?, self.credentials()?, &market, &order_id).await
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn candles_are_available_over_rest_but_not_as_a_stream() {
let adapter = BithumbAdapter::new();
assert!(adapter.supports(Feature::Candles));
assert!(!adapter.supports(Feature::CandleStream));
}
#[test]
fn every_other_public_stream_is_available() {
let adapter = BithumbAdapter::new();
for feature in [
Feature::TradeStream,
Feature::OrderBookStream,
Feature::TickerStream,
] {
assert!(adapter.supports(feature), "{feature:?}");
}
}
#[test]
fn a_spot_exchange_never_claims_derivatives_features() {
let adapter = BithumbAdapter::new().with_credentials("access", "secret");
for feature in [Feature::Positions, Feature::Margin, Feature::FundingRates] {
assert!(!adapter.supports(feature), "{feature:?}");
}
}
#[tokio::test]
async fn subscribing_to_candles_is_refused_before_a_socket_is_opened() {
use crate::types::{Feed, Interval, Market, StreamConfig, Subscription};
let subscription = Subscription::new()
.market(Market::spot(Exchange::Bithumb, "BTC", "KRW"))
.feed(Feed::Candles(Interval::Min1));
let error = BithumbAdapter::new()
.subscribe(&subscription, &StreamConfig::default())
.await
.expect_err("bithumb publishes no candle stream");
assert!(matches!(
error,
Error::Unsupported {
feature: Feature::CandleStream,
exchange: "bithumb",
..
}
));
}
#[tokio::test]
async fn a_private_call_without_credentials_is_an_auth_failure_not_a_missing_feature() {
let error = BithumbAdapter::new()
.balances()
.await
.expect_err("no credentials were supplied");
assert!(
matches!(error, Error::Auth { .. }),
"expected an auth failure, got {error:?}"
);
}
#[test]
fn credentials_are_what_unlock_the_private_half() {
let public = BithumbAdapter::new();
let private = BithumbAdapter::new().with_credentials("access", "secret");
for feature in [Feature::Balances, Feature::Trading, Feature::AccountStream] {
assert!(!public.supports(feature), "{feature:?}");
assert!(private.supports(feature), "{feature:?}");
}
}
}