Skip to content

Commit 13d7932

Browse files
Michael YuanMichael Yuan
authored andcommitted
Add Coinbase Advanced Trade API support
- Add coinbase_api_key and coinbase_api_secret to config - Create src/coinbase.rs module with spot trading support - spot_order() for limit buy/sell orders - get_accounts() for balance retrieval - get_orders() for open order listing - cancel_order() for order cancellation - HMAC-SHA256 request signing - Update all command files to handle 'coinbase' exchange: - order.rs: route spot orders to Coinbase - perp.rs: error for Coinbase (perps not supported) - options.rs: error for Coinbase (options not supported) - balance.rs: route to coinbase::get_accounts() - positions.rs: error for Coinbase (spot-only exchange) - orders.rs: route to coinbase::get_orders() - cancel.rs: handle 'coinbase:ORDER_ID' format - Update resolve_exchange() auto priority: Hyperliquid > Coinbase > Binance - Add uuid dependency for client_order_id generation - Update config.toml.default with Coinbase API key fields - Zero clippy warnings, clean release build
1 parent 5b8f3cc commit 13d7932

12 files changed

Lines changed: 530 additions & 48 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ regex = "1"
2828
openssl = { version = "0.10", features = ["vendored"] }
2929
hmac = "0.12"
3030
sha2 = "0.10"
31+
uuid = { version = "1", features = ["v4"] }

config.toml.default

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,8 @@ testnet = false
3737
# Sign up at https://www.binance.com/
3838
# binance_api_key = "..."
3939
# binance_api_secret = "..."
40+
41+
# Coinbase Advanced Trade API credentials — enables spot trading on Coinbase
42+
# Sign up at https://www.coinbase.com/
43+
# coinbase_api_key = "..."
44+
# coinbase_api_secret = "..."

src/coinbase.rs

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
use anyhow::{bail, Context, Result};
2+
use hmac::{Hmac, Mac};
3+
use reqwest::Client;
4+
use serde_json::json;
5+
use sha2::Sha256;
6+
use std::time::{SystemTime, UNIX_EPOCH};
7+
8+
type HmacSha256 = Hmac<Sha256>;
9+
10+
const BASE_URL: &str = "https://api.coinbase.com";
11+
12+
/// Sign a request with HMAC-SHA256
13+
/// timestamp + method + requestPath + body
14+
pub fn sign_request(secret: &str, timestamp: &str, method: &str, path: &str, body: &str) -> String {
15+
let message = format!("{}{}{}{}", timestamp, method, path, body);
16+
let mut mac =
17+
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
18+
mac.update(message.as_bytes());
19+
let result = mac.finalize();
20+
hex::encode(result.into_bytes())
21+
}
22+
23+
/// Get current timestamp in seconds
24+
fn timestamp() -> String {
25+
SystemTime::now()
26+
.duration_since(UNIX_EPOCH)
27+
.unwrap()
28+
.as_secs()
29+
.to_string()
30+
}
31+
32+
/// Place a spot limit order on Coinbase
33+
#[allow(clippy::too_many_arguments)]
34+
pub async fn spot_order(
35+
client: &Client,
36+
api_key: &str,
37+
api_secret: &str,
38+
symbol: &str,
39+
side: &str, // "BUY" or "SELL"
40+
size: f64,
41+
price: f64,
42+
json_output: bool,
43+
) -> Result<()> {
44+
let ts = timestamp();
45+
let path = "/api/v3/brokerage/orders";
46+
47+
// Generate client_order_id
48+
let client_order_id = uuid::Uuid::new_v4().to_string();
49+
50+
// Coinbase uses BTC-USD format (not BTCUSDT)
51+
let product_id = format!("{}-USD", symbol.to_uppercase());
52+
53+
let body = json!({
54+
"client_order_id": client_order_id,
55+
"product_id": product_id,
56+
"side": side,
57+
"order_configuration": {
58+
"limit_limit_gtc": {
59+
"base_size": format!("{:.8}", size),
60+
"limit_price": format!("{:.2}", price),
61+
}
62+
}
63+
});
64+
65+
let body_str = serde_json::to_string(&body)?;
66+
let signature = sign_request(api_secret, &ts, "POST", path, &body_str);
67+
68+
let url = format!("{}{}", BASE_URL, path);
69+
70+
let response = client
71+
.post(&url)
72+
.header("CB-ACCESS-KEY", api_key)
73+
.header("CB-ACCESS-SIGN", signature)
74+
.header("CB-ACCESS-TIMESTAMP", ts)
75+
.header("Content-Type", "application/json")
76+
.body(body_str)
77+
.send()
78+
.await
79+
.context("Failed to send Coinbase spot order")?;
80+
81+
let status = response.status();
82+
let response_body: serde_json::Value =
83+
response.json().await.context("Failed to parse response")?;
84+
85+
if !status.is_success() {
86+
let error_msg = if let Some(msg) = response_body.get("message") {
87+
format!("Coinbase API error: {}", msg)
88+
} else if let Some(msg) = response_body.get("error") {
89+
format!("Coinbase API error: {}", msg)
90+
} else {
91+
format!("Coinbase API error: {:?}", response_body)
92+
};
93+
bail!(error_msg);
94+
}
95+
96+
let result = json!({
97+
"exchange": "coinbase",
98+
"market": "spot",
99+
"action": side.to_lowercase(),
100+
"symbol": product_id,
101+
"quantity": size,
102+
"price": price,
103+
"response": response_body,
104+
});
105+
106+
if json_output {
107+
println!("{}", serde_json::to_string_pretty(&result)?);
108+
} else {
109+
println!("\n ✅ Coinbase spot {} order placed!", side.to_lowercase());
110+
println!(
111+
" Order ID: {}",
112+
response_body.get("order_id").unwrap_or(&json!(null))
113+
);
114+
println!(" Product: {}", product_id);
115+
println!(" Quantity: {:.8}", size);
116+
println!(" Price: ${:.2}\n", price);
117+
}
118+
119+
Ok(())
120+
}
121+
122+
/// Get account balances
123+
pub async fn get_accounts(
124+
client: &Client,
125+
api_key: &str,
126+
api_secret: &str,
127+
json_output: bool,
128+
) -> Result<()> {
129+
let ts = timestamp();
130+
let path = "/api/v3/brokerage/accounts";
131+
let signature = sign_request(api_secret, &ts, "GET", path, "");
132+
133+
let url = format!("{}{}", BASE_URL, path);
134+
135+
let response = client
136+
.get(&url)
137+
.header("CB-ACCESS-KEY", api_key)
138+
.header("CB-ACCESS-SIGN", signature)
139+
.header("CB-ACCESS-TIMESTAMP", ts)
140+
.send()
141+
.await
142+
.context("Failed to fetch Coinbase accounts")?;
143+
144+
let status = response.status();
145+
let body: serde_json::Value = response.json().await.context("Failed to parse response")?;
146+
147+
if !status.is_success() {
148+
let error_msg = if let Some(msg) = body.get("message") {
149+
format!("Coinbase API error: {}", msg)
150+
} else {
151+
format!("Coinbase API error: {:?}", body)
152+
};
153+
bail!(error_msg);
154+
}
155+
156+
if json_output {
157+
println!(
158+
"{}",
159+
serde_json::to_string_pretty(&json!({
160+
"exchange": "coinbase",
161+
"accounts": body,
162+
}))?
163+
);
164+
return Ok(());
165+
}
166+
167+
println!("\n 💰 Coinbase Account Balance\n");
168+
169+
if let Some(accounts) = body.get("accounts").and_then(|v| v.as_array()) {
170+
for account in accounts {
171+
let currency = account
172+
.get("currency")
173+
.and_then(|v| v.as_str())
174+
.unwrap_or("");
175+
let available_balance: f64 = account
176+
.get("available_balance")
177+
.and_then(|v| v.get("value"))
178+
.and_then(|v| v.as_str())
179+
.and_then(|s| s.parse().ok())
180+
.unwrap_or(0.0);
181+
let hold: f64 = account
182+
.get("hold")
183+
.and_then(|v| v.get("value"))
184+
.and_then(|v| v.as_str())
185+
.and_then(|s| s.parse().ok())
186+
.unwrap_or(0.0);
187+
188+
if available_balance > 0.0 || hold > 0.0 {
189+
use colored::Colorize;
190+
println!(
191+
" {}: {} (available: {}, hold: {})",
192+
currency.cyan(),
193+
available_balance + hold,
194+
available_balance,
195+
hold
196+
);
197+
}
198+
}
199+
}
200+
201+
println!();
202+
Ok(())
203+
}
204+
205+
/// Get open orders
206+
pub async fn get_orders(
207+
client: &Client,
208+
api_key: &str,
209+
api_secret: &str,
210+
symbol: Option<&str>,
211+
_json_output: bool,
212+
) -> Result<serde_json::Value> {
213+
let ts = timestamp();
214+
let mut path = "/api/v3/brokerage/orders/historical/batch?order_status=OPEN".to_string();
215+
216+
if let Some(sym) = symbol {
217+
let product_id = format!("{}-USD", sym.to_uppercase());
218+
path.push_str(&format!("&product_id={}", product_id));
219+
}
220+
221+
let signature = sign_request(api_secret, &ts, "GET", &path, "");
222+
223+
let url = format!("{}{}", BASE_URL, path);
224+
225+
let response = client
226+
.get(&url)
227+
.header("CB-ACCESS-KEY", api_key)
228+
.header("CB-ACCESS-SIGN", signature)
229+
.header("CB-ACCESS-TIMESTAMP", ts)
230+
.send()
231+
.await
232+
.context("Failed to fetch Coinbase orders")?;
233+
234+
let status = response.status();
235+
let body: serde_json::Value = response.json().await.context("Failed to parse response")?;
236+
237+
if !status.is_success() {
238+
let error_msg = if let Some(msg) = body.get("message") {
239+
format!("Coinbase API error: {}", msg)
240+
} else {
241+
format!("Coinbase API error: {:?}", body)
242+
};
243+
bail!(error_msg);
244+
}
245+
246+
Ok(body)
247+
}
248+
249+
/// Cancel an order
250+
pub async fn cancel_order(
251+
client: &Client,
252+
api_key: &str,
253+
api_secret: &str,
254+
order_id: &str,
255+
json_output: bool,
256+
) -> Result<()> {
257+
let ts = timestamp();
258+
let path = "/api/v3/brokerage/orders/batch_cancel";
259+
260+
let body = json!({
261+
"order_ids": [order_id]
262+
});
263+
264+
let body_str = serde_json::to_string(&body)?;
265+
let signature = sign_request(api_secret, &ts, "POST", path, &body_str);
266+
267+
let url = format!("{}{}", BASE_URL, path);
268+
269+
let response = client
270+
.post(&url)
271+
.header("CB-ACCESS-KEY", api_key)
272+
.header("CB-ACCESS-SIGN", signature)
273+
.header("CB-ACCESS-TIMESTAMP", ts)
274+
.header("Content-Type", "application/json")
275+
.body(body_str)
276+
.send()
277+
.await
278+
.context("Failed to cancel Coinbase order")?;
279+
280+
let status = response.status();
281+
let response_body: serde_json::Value =
282+
response.json().await.context("Failed to parse response")?;
283+
284+
if !status.is_success() {
285+
let error_msg = if let Some(msg) = response_body.get("message") {
286+
format!("Coinbase API error: {}", msg)
287+
} else {
288+
format!("Coinbase API error: {:?}", response_body)
289+
};
290+
bail!(error_msg);
291+
}
292+
293+
if json_output {
294+
println!(
295+
"{}",
296+
serde_json::to_string_pretty(&json!({
297+
"exchange": "coinbase",
298+
"order_id": order_id,
299+
"result": response_body,
300+
}))?
301+
);
302+
} else {
303+
use colored::Colorize;
304+
println!("\n ✅ Coinbase order cancelled!");
305+
println!(" Order ID: {}\n", order_id.cyan());
306+
}
307+
308+
Ok(())
309+
}

src/commands/balance.rs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use colored::Colorize;
33
use serde_json::{json, Value};
44
use tabled::{settings::Style, Table, Tabled};
55

6-
use crate::{binance, config};
6+
use crate::{binance, coinbase, config};
77

88
#[derive(Tabled)]
99
struct BalanceRow {
@@ -20,24 +20,25 @@ struct BalanceRow {
2020
/// Resolve which exchange to use
2121
fn resolve_exchange(exchange: &str) -> Result<String> {
2222
match exchange {
23-
"hyperliquid" | "binance" => Ok(exchange.to_string()),
23+
"hyperliquid" | "binance" | "coinbase" => Ok(exchange.to_string()),
2424
"auto" => {
2525
let has_hl = config::load_hl_config().is_ok();
26+
let has_coinbase = config::coinbase_credentials().is_some();
2627
let has_binance = config::binance_credentials().is_some();
2728

28-
if has_hl && !has_binance {
29+
// Priority: Hyperliquid > Coinbase > Binance
30+
if has_hl {
2931
Ok("hyperliquid".to_string())
30-
} else if has_binance && !has_hl {
32+
} else if has_coinbase {
33+
Ok("coinbase".to_string())
34+
} else if has_binance {
3135
Ok("binance".to_string())
32-
} else if has_hl && has_binance {
33-
// Default to Hyperliquid
34-
Ok("hyperliquid".to_string())
3536
} else {
36-
bail!("No exchange configured. Set up Hyperliquid wallet or Binance API keys in ~/.fintool/config.toml")
37+
bail!("No exchange configured. Set up Hyperliquid wallet, Coinbase API keys, or Binance API keys in ~/.fintool/config.toml")
3738
}
3839
}
3940
_ => bail!(
40-
"Invalid exchange: {}. Use hyperliquid, binance, or auto",
41+
"Invalid exchange: {}. Use hyperliquid, binance, coinbase, or auto",
4142
exchange
4243
),
4344
}
@@ -46,6 +47,14 @@ fn resolve_exchange(exchange: &str) -> Result<String> {
4647
pub async fn run(exchange: &str, json_output: bool) -> Result<()> {
4748
let exchange = resolve_exchange(exchange)?;
4849

50+
if exchange == "coinbase" {
51+
let (api_key, api_secret) = config::coinbase_credentials()
52+
.ok_or_else(|| anyhow::anyhow!("Coinbase API credentials not configured"))?;
53+
54+
let client = reqwest::Client::new();
55+
return coinbase::get_accounts(&client, &api_key, &api_secret, json_output).await;
56+
}
57+
4958
if exchange == "binance" {
5059
let (api_key, api_secret) = config::binance_credentials()
5160
.ok_or_else(|| anyhow::anyhow!("Binance API credentials not configured"))?;

0 commit comments

Comments
 (0)