Skip to content

Commit 8179b04

Browse files
valeratradesclaude
andcommitted
feat: add Kucoin exchange support
Implements Kucoin exchange adapter and integration following Binance/Bybit patterns. Features: - HTTP authentication with HMAC-SHA256 (API v2) - Proper passphrase encryption - price() method for spot trading pairs (tested) - asset_balance() and balances() methods - Example code with public endpoint test Endpoints: - GET /api/v1/market/orderbook/level1 (public) - GET /api/v1/accounts (authenticated) Auth requires: KUCOIN_API_PUBKEY, KUCOIN_API_SECRET, KUCOIN_API_PASSPHRASE 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 11c7ca6 commit 8179b04

File tree

13 files changed

+637
-2
lines changed

13 files changed

+637
-2
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ get_last_with_len = "allow"
1616

1717
[workspace.dependencies]
1818
async-trait = "^0.1.89"
19+
base64 = "^0.22.1"
1920
cfg-if = "^1.0.4"
2021
derive-new = "^0.7.0"
2122
derive_more = { version = "^2.0.1", features = ["deref", "deref_mut"] }

examples/kucoin/market.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
use std::str::FromStr;
2+
3+
use secrecy::SecretString;
4+
use v_exchanges::{Exchange, Instrument, Kucoin, Symbol};
5+
use v_exchanges_adapters::kucoin::KucoinOption;
6+
use v_utils::trades::{Asset, Pair};
7+
8+
#[tokio::main]
9+
async fn main() -> eyre::Result<()> {
10+
let mut kucoin = Kucoin::default();
11+
12+
// Test public endpoint - get BTC-USDT price
13+
println!("Testing public endpoint (get price)...");
14+
let symbol = Symbol {
15+
pair: Pair::from_str("BTC/USDT")?,
16+
instrument: Instrument::Spot,
17+
};
18+
let price = kucoin.price(symbol, None).await?;
19+
println!("BTC-USDT price: {}", price);
20+
21+
// Test authenticated endpoints if credentials are available
22+
if let (Ok(pubkey), Ok(secret), Ok(passphrase)) = (std::env::var("KUCOIN_API_PUBKEY"), std::env::var("KUCOIN_API_SECRET"), std::env::var("KUCOIN_API_PASSPHRASE")) {
23+
println!("\nTesting authenticated endpoints...");
24+
kucoin.update_default_option(KucoinOption::Pubkey(pubkey));
25+
kucoin.update_default_option(KucoinOption::Secret(SecretString::from(secret)));
26+
kucoin.update_default_option(KucoinOption::Passphrase(SecretString::from(passphrase)));
27+
28+
// Test get balances
29+
println!("Getting account balances...");
30+
let balances = kucoin.balances(Instrument::Spot, None).await?;
31+
println!("Total balances: {} (Total USD: {})", balances.len(), balances.total);
32+
for balance in balances.iter().take(5) {
33+
println!(" {:?}: {} (USD: {:?})", balance.asset, balance.underlying, balance.usd);
34+
}
35+
36+
// Test get specific asset balance
37+
println!("\nGetting USDT balance...");
38+
let usdt_balance = kucoin.asset_balance(Asset::new("USDT"), Instrument::Spot, None).await?;
39+
println!("USDT balance: {} (USD: {:?})", usdt_balance.underlying, usdt_balance.usd);
40+
} else {
41+
println!("\nSkipping authenticated tests - credentials not found in environment");
42+
println!("Set KUCOIN_API_PUBKEY, KUCOIN_API_SECRET, and KUCOIN_API_PASSPHRASE to test authenticated endpoints");
43+
}
44+
45+
Ok(())
46+
}

v_exchanges/Cargo.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,13 @@ ignored = ["derive-new", "color-eyre", "serde", "tokio", "v_utils"]
2727
default = ["major", "data"]
2828

2929
full = ["major", "data", "bitflyer", "coincheck"]
30-
major = ["binance", "bybit", "mexc"]
30+
major = ["binance", "bybit", "kucoin", "mexc"]
3131

3232
binance = ["v_exchanges_adapters/binance"]
3333
bybit = ["v_exchanges_adapters/bybit"]
3434
bitflyer = ["v_exchanges_adapters/bitflyer"]
3535
coincheck = ["v_exchanges_adapters/coincheck"]
36+
kucoin = ["v_exchanges_adapters/kucoin"]
3637
mexc = ["v_exchanges_adapters/mexc"]
3738
data = ["dep:reqwest"]
3839

@@ -102,6 +103,11 @@ name = "bybit_market"
102103
required-features = ["bybit"]
103104
path = "../examples/bybit/market.rs"
104105

106+
[[example]]
107+
name = "kucoin_market"
108+
required-features = ["kucoin"]
109+
path = "../examples/kucoin/market.rs"
110+
105111
[[example]]
106112
name = "mexc"
107113
required-features = ["mexc"]

v_exchanges/src/core.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,7 @@ define_str_enum! {
342342
pub enum ExchangeName {
343343
Binance => "binance",
344344
Bybit => "bybit",
345+
Kucoin => "kucoin",
345346
Mexc => "mexc",
346347
BitFlyer => "bitflyer",
347348
Coincheck => "coincheck",
@@ -355,6 +356,8 @@ impl ExchangeName {
355356
Self::Binance => Box::new(crate::Binance(Client::default())),
356357
#[cfg(feature = "bybit")]
357358
Self::Bybit => Box::new(crate::Bybit(Client::default())),
359+
#[cfg(feature = "kucoin")]
360+
Self::Kucoin => Box::new(crate::Kucoin(Client::default())),
358361
#[cfg(feature = "mexc")]
359362
Self::Mexc => Box::new(crate::Mexc(Client::default())),
360363
_ => unimplemented!(),

v_exchanges/src/kucoin/account.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
use adapters::Client;
2+
use serde::{Deserialize, Serialize};
3+
use serde_json::json;
4+
use serde_with::{DisplayFromStr, serde_as};
5+
use tracing::warn;
6+
use v_exchanges_adapters::kucoin::{KucoinAuth, KucoinHttpUrl, KucoinOption};
7+
use v_utils::trades::Asset;
8+
9+
use crate::{
10+
ExchangeResult,
11+
core::{AssetBalance, Balances},
12+
};
13+
14+
pub async fn asset_balance(client: &v_exchanges_adapters::Client, asset: Asset, _recv_window: Option<u16>) -> ExchangeResult<AssetBalance> {
15+
assert!(client.is_authenticated::<KucoinOption>());
16+
let balances: Balances = balances(client, None).await?;
17+
let balance: AssetBalance = balances.iter().find(|b| b.asset == asset).copied().unwrap_or_else(|| {
18+
warn!("No balance found for asset: {:?}", asset);
19+
AssetBalance { asset, ..Default::default() }
20+
});
21+
Ok(balance)
22+
}
23+
24+
pub async fn balances(client: &Client, _recv_window: Option<u16>) -> ExchangeResult<Balances> {
25+
assert!(client.is_authenticated::<KucoinOption>());
26+
27+
let options = vec![KucoinOption::HttpAuth(KucoinAuth::Sign), KucoinOption::HttpUrl(KucoinHttpUrl::Spot)];
28+
let account_response: AccountResponse = client.get("/api/v1/accounts", &json!({}), options).await?;
29+
30+
let mut vec_balance = Vec::new();
31+
let total_usd = 0.0;
32+
33+
for account in &account_response.data {
34+
// Only include accounts with non-zero balances
35+
if account.balance > 0.0 {
36+
vec_balance.push(AssetBalance {
37+
asset: (&*account.currency).into(),
38+
underlying: account.balance,
39+
usd: None, // Kucoin doesn't provide USD values in this endpoint
40+
});
41+
}
42+
}
43+
44+
let balances = Balances::new(vec_balance, total_usd.into());
45+
Ok(balances)
46+
}
47+
48+
#[derive(Debug, Deserialize, Serialize)]
49+
#[serde(rename_all = "camelCase")]
50+
pub struct AccountResponse {
51+
pub code: String,
52+
pub data: Vec<AccountData>,
53+
}
54+
55+
#[serde_as]
56+
#[derive(Debug, Deserialize, Serialize)]
57+
#[serde(rename_all = "camelCase")]
58+
pub struct AccountData {
59+
pub id: String,
60+
pub currency: String,
61+
#[serde(rename = "type")]
62+
pub account_type: String,
63+
#[serde_as(as = "DisplayFromStr")]
64+
pub balance: f64,
65+
#[serde_as(as = "DisplayFromStr")]
66+
pub available: f64,
67+
#[serde_as(as = "DisplayFromStr")]
68+
pub holds: f64,
69+
}

v_exchanges/src/kucoin/market.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
use serde::{Deserialize, Serialize};
2+
use serde_json::json;
3+
use serde_with::{DisplayFromStr, serde_as};
4+
use v_exchanges_adapters::kucoin::{KucoinHttpUrl, KucoinOption};
5+
use v_utils::trades::Pair;
6+
7+
use crate::ExchangeResult;
8+
9+
// price {{{
10+
pub async fn price(client: &v_exchanges_adapters::Client, pair: Pair, _recv_window: Option<u16>) -> ExchangeResult<f64> {
11+
let symbol = format!("{}-{}", pair.base(), pair.quote());
12+
let params = json!({
13+
"symbol": symbol,
14+
});
15+
let options = vec![KucoinOption::HttpUrl(KucoinHttpUrl::Spot)];
16+
let response: TickerResponse = client.get("/api/v1/market/orderbook/level1", &params, options).await?;
17+
Ok(response.data.price)
18+
}
19+
20+
#[derive(Debug, Deserialize, Serialize)]
21+
#[serde(rename_all = "camelCase")]
22+
pub struct TickerResponse {
23+
pub code: String,
24+
pub data: TickerData,
25+
}
26+
27+
#[serde_as]
28+
#[derive(Debug, Deserialize, Serialize)]
29+
#[serde(rename_all = "camelCase")]
30+
pub struct TickerData {
31+
pub time: i64,
32+
pub sequence: String,
33+
#[serde_as(as = "DisplayFromStr")]
34+
pub price: f64,
35+
#[serde_as(as = "DisplayFromStr")]
36+
pub size: f64,
37+
#[serde_as(as = "DisplayFromStr")]
38+
pub best_bid: f64,
39+
#[serde_as(as = "DisplayFromStr")]
40+
pub best_bid_size: f64,
41+
#[serde_as(as = "DisplayFromStr")]
42+
pub best_ask: f64,
43+
#[serde_as(as = "DisplayFromStr")]
44+
pub best_ask_size: f64,
45+
}
46+
//,}}}

v_exchanges/src/kucoin/mod.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
mod account;
2+
mod market;
3+
4+
use adapters::kucoin::KucoinOption;
5+
use secrecy::SecretString;
6+
use v_exchanges_adapters::Client;
7+
use v_utils::trades::Asset;
8+
9+
use crate::{
10+
Balances, ExchangeName, ExchangeResult, Instrument, Symbol,
11+
core::{AssetBalance, Exchange},
12+
};
13+
14+
#[derive(Clone, Debug, Default, derive_more::Deref, derive_more::DerefMut)]
15+
pub struct Kucoin(pub Client);
16+
17+
#[async_trait::async_trait]
18+
impl Exchange for Kucoin {
19+
fn name(&self) -> ExchangeName {
20+
ExchangeName::Kucoin
21+
}
22+
23+
fn auth(&mut self, pubkey: String, secret: SecretString) {
24+
self.update_default_option(KucoinOption::Pubkey(pubkey));
25+
self.update_default_option(KucoinOption::Secret(secret));
26+
// Note: Passphrase needs to be set separately via KucoinOption::Passphrase
27+
}
28+
29+
fn set_recv_window(&mut self, _recv_window: u16) {
30+
// Kucoin doesn't use recv_window in the same way as Binance/Bybit
31+
}
32+
33+
async fn price(&self, symbol: Symbol, recv_window: Option<u16>) -> ExchangeResult<f64> {
34+
match symbol.instrument {
35+
Instrument::Spot => market::price(self, symbol.pair, recv_window).await,
36+
_ => unimplemented!(),
37+
}
38+
}
39+
40+
async fn asset_balance(&self, asset: Asset, _instrument: Instrument, recv_window: Option<u16>) -> ExchangeResult<AssetBalance> {
41+
account::asset_balance(self, asset, recv_window).await
42+
}
43+
44+
async fn balances(&self, _instrument: Instrument, recv_window: Option<u16>) -> ExchangeResult<Balances> {
45+
account::balances(self, recv_window).await
46+
}
47+
}

v_exchanges/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ pub mod prelude {
2222
pub use crate::bybit::Bybit;
2323
#[cfg(feature = "coincheck")]
2424
pub use crate::coincheck::Coincheck;
25+
#[cfg(feature = "kucoin")]
26+
pub use crate::kucoin::Kucoin;
2527
#[cfg(feature = "mexc")]
2628
pub use crate::mexc::Mexc;
2729
#[cfg(feature = "data")]
@@ -40,6 +42,10 @@ pub mod binance;
4042
#[cfg_attr(docsrs, doc(cfg(feature = "bybit")))]
4143
pub mod bybit;
4244

45+
#[cfg(feature = "kucoin")]
46+
#[cfg_attr(docsrs, doc(cfg(feature = "kucoin")))]
47+
pub mod kucoin;
48+
4349
#[cfg(feature = "mexc")]
4450
#[cfg_attr(docsrs, doc(cfg(feature = "mexc")))]
4551
pub mod mexc;

v_exchanges_adapters/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,12 @@ rustdoc-args = ["--cfg", "docsrs"]
2626
[features]
2727
default = ["native-tls"]
2828

29-
full = ["binance", "bitflyer", "bybit", "coincheck", "mexc"]
29+
full = ["binance", "bitflyer", "bybit", "coincheck", "kucoin", "mexc"]
3030
binance = ["dep:rand"]
3131
bitflyer = ["dep:rand"]
3232
bybit = ["dep:rand"]
3333
coincheck = []
34+
kucoin = []
3435
mexc = []
3536

3637
native-tls = ["v_exchanges_api_generics/native-tls"]
@@ -39,6 +40,7 @@ rustls-tls-native-roots = ["v_exchanges_api_generics/rustls-tls-native-roots"]
3940
rustls-tls-webpki-roots = ["v_exchanges_api_generics/rustls-tls-webpki-roots"]
4041

4142
[dependencies]
43+
base64.workspace = true
4244
derive-new.workspace = true
4345
derive_more.workspace = true
4446
eyre.workspace = true

0 commit comments

Comments
 (0)