English | 中文
A unified Go SDK for interacting with multiple cryptocurrency exchanges.
Provides both low-level SDK clients (REST + WebSocket) and high-level adapters implementing a common Exchange interface — a Go-native CCXT alternative.
- Unified Interface — One API to rule them all. Switch exchanges by changing one line.
- Full Market Coverage — Perpetual Futures, Spot, and Margin trading support.
- Dual Transport — REST for queries; WebSocket for real-time streaming and low-latency order placement.
- Built-in Safety — Exchange-specific request protection, rate-limit error mapping, order validation, and slippage protection.
- Local State Management — WebSocket-maintained orderbooks, position/order tracking, and balance sync.
- Production-Ready — Battle-tested in quantitative trading systems handling thousands of orders daily.
| Exchange | Perp | Spot | Margin | Quote Currencies | Default |
|---|---|---|---|---|---|
| Binance | ✅ | ✅ | ✅ | USDT, USDC | USDT |
| OKX | ✅ | ✅ | — | USDT, USDC | USDT |
| Aster | ✅ | ✅ | — | USDT, USDC | USDC |
| Nado | ✅ | ✅ | — | USDT | USDT |
| Lighter | ✅ | ✅ | — | USDC | USDC |
| Hyperliquid | ✅ | ✅ | — | USDC | USDC |
| Bitget | ✅ | ✅ | — | USDT, USDC | USDT |
| StandX | ✅ | — | — | DUSD | DUSD |
| GRVT | ✅ | — | — | USDT | USDT |
| EdgeX | ✅ | — | — | USDC | USDC |
- Bitget currently supports the classic private API surface only.
- Bitget defaults to
OrderModeREST. ExplicitOrderModeWSis opt-in and requires Bitget to enable classic WebSocket trade access for the API key.
go get github.com/QuantProcessing/exchangesEvery exchange implements the same Exchange interface. Your strategy code never touches exchange-specific APIs:
// This function works with ANY exchange — Binance, OKX, Hyperliquid, etc.
func getSpread(ctx context.Context, adp exchanges.Exchange, symbol string) (decimal.Decimal, error) {
ob, err := adp.FetchOrderBook(ctx, symbol, 1)
if err != nil {
return decimal.Zero, err
}
return ob.Asks[0].Price.Sub(ob.Bids[0].Price), nil
}All methods accept a base currency symbol (e.g. "BTC", "ETH"). The adapter handles conversion to exchange-specific formats internally based on the configured quote currency:
| You Pass | Binance (USDT) | Binance (USDC) | OKX (USDT) | Hyperliquid |
|---|---|---|---|---|
"BTC" |
"BTCUSDT" |
"BTCUSDC" |
"BTC-USDT-SWAP" |
"BTC" |
┌─────────────────────────────────────────────────────┐
│ Your Strategy / Application │
├─────────────────────────────────────────────────────┤
│ Adapter Layer (exchanges.Exchange interface) │ ← Unified API
│ binance.Adapter / okx.Adapter / nado.Adapter │
├─────────────────────────────────────────────────────┤
│ SDK Layer (low-level REST + WebSocket clients) │ ← Exchange-specific
│ binance/sdk/ / okx/sdk/ / nado/sdk/ │
└─────────────────────────────────────────────────────┘
- Adapter Layer: Implements
exchanges.Exchange. Handles symbol mapping, order validation, slippage logic, and state management. - SDK Layer: Thin REST/WebSocket clients that map 1:1 to exchange API endpoints. You can use these directly for maximum flexibility.
package main
import (
"context"
"fmt"
exchanges "github.com/QuantProcessing/exchanges"
"github.com/QuantProcessing/exchanges/binance"
"github.com/shopspring/decimal"
)
func main() {
ctx := context.Background()
// Create a Binance perpetual adapter (defaults to USDT market)
adp, err := binance.NewAdapter(ctx, binance.Options{
APIKey: "your-api-key",
SecretKey: "your-secret-key",
// QuoteCurrency: exchanges.QuoteCurrencyUSDC, // uncomment for USDC market
})
if err != nil {
panic(err)
}
defer adp.Close()
// Fetch ticker
ticker, err := adp.FetchTicker(ctx, "BTC")
if err != nil {
panic(err)
}
fmt.Printf("BTC price: %s\n", ticker.LastPrice)
// Fetch order book
ob, err := adp.FetchOrderBook(ctx, "BTC", 5)
if err != nil {
panic(err)
}
fmt.Printf("Best bid: %s, Best ask: %s\n",
ob.Bids[0].Price, ob.Asks[0].Price)
// Place a limit order
order, err := adp.PlaceOrder(ctx, &exchanges.OrderParams{
Symbol: "BTC",
Side: exchanges.OrderSideBuy,
Type: exchanges.OrderTypeLimit,
Price: ticker.Bid,
Quantity: decimal.NewFromFloat(0.001),
})
if err != nil {
panic(err)
}
fmt.Printf("Order placed: %s\n", order.OrderID)
}// Market order with 0.5% slippage protection
// Internally converts to LIMIT IOC at (ask * 1.005) for buys
order, err := adp.PlaceOrder(ctx, &exchanges.OrderParams{
Symbol: "ETH",
Side: exchanges.OrderSideBuy,
Type: exchanges.OrderTypeMarket,
Quantity: decimal.NewFromFloat(0.1),
Slippage: decimal.NewFromFloat(0.005), // 0.5%
})// One-liner market order
order, err := exchanges.PlaceMarketOrder(ctx, adp, "BTC", exchanges.OrderSideBuy, qty)
// One-liner limit order
order, err := exchanges.PlaceLimitOrder(ctx, adp, "BTC", exchanges.OrderSideBuy, price, qty)
// Market order with slippage
order, err := exchanges.PlaceMarketOrderWithSlippage(ctx, adp, "BTC", exchanges.OrderSideBuy, qty, slippage)// Real-time order book (locally maintained)
err := adp.WatchOrderBook(ctx, "BTC", func(ob *exchanges.OrderBook) {
fmt.Printf("BTC bid: %s ask: %s\n", ob.Bids[0].Price, ob.Asks[0].Price)
})
// Pull latest snapshot anytime (zero-latency, no API call)
ob := adp.GetLocalOrderBook("BTC", 5)
// Real-time ticker
adp.WatchTicker(ctx, "BTC", func(t *exchanges.Ticker) {
fmt.Printf("Price: %s\n", t.LastPrice)
})
// Real-time order updates (fills, cancellations)
adp.WatchOrders(ctx, func(o *exchanges.Order) {
fmt.Printf("Order %s: %s\n", o.OrderID, o.Status)
})
// Real-time position updates
adp.WatchPositions(ctx, func(p *exchanges.Position) {
fmt.Printf("%s: %s %s @ %s\n", p.Symbol, p.Side, p.Quantity, p.EntryPrice)
})// Type assert for perp-specific features
if perp, ok := adp.(exchanges.PerpExchange); ok {
// Set leverage
perp.SetLeverage(ctx, "BTC", 10)
// Get positions
positions, _ := perp.FetchPositions(ctx)
for _, p := range positions {
fmt.Printf("%s: %s %s\n", p.Symbol, p.Side, p.Quantity)
}
// Get funding rate
fr, _ := perp.FetchFundingRate(ctx, "BTC")
fmt.Printf("Funding rate: %s\n", fr.FundingRate)
}// LocalState wraps any Exchange adapter — auto-subscribes to WS streams,
// maintains orders/positions/balance, and provides fan-out event subscriptions.
state := exchanges.NewLocalState(adp, nil)
err := state.Start(ctx) // REST snapshot + auto WatchOrders/WatchPositions + periodic refresh
// Read state anytime (thread-safe, zero-latency)
pos, ok := state.GetPosition("BTC")
order, ok := state.GetOrder("order-123")
balance := state.GetBalance()
// Fan-out event subscriptions (multiple consumers supported)
sub := state.SubscribeOrders()
defer sub.Unsubscribe()
go func() {
for order := range sub.C {
fmt.Printf("Order update: %s %s\n", order.OrderID, order.Status)
}
}()
// Place order with integrated tracking — no need for separate WatchOrders
result, err := state.PlaceOrder(ctx, &exchanges.OrderParams{
Symbol: "BTC",
Side: exchanges.OrderSideBuy,
Type: exchanges.OrderTypeMarket,
Quantity: decimal.NewFromFloat(0.001),
})
defer result.Done()
filled, err := result.WaitTerminal(30 * time.Second) // blocks until FILLED/CANCELLED/REJECTED// Binance — USDT market (default)
adp, _ := binance.NewAdapter(ctx, binance.Options{
APIKey: os.Getenv("BINANCE_API_KEY"), SecretKey: os.Getenv("BINANCE_SECRET_KEY"),
})
// Binance — USDC market
adpUSDC, _ := binance.NewAdapter(ctx, binance.Options{
APIKey: os.Getenv("BINANCE_API_KEY"), SecretKey: os.Getenv("BINANCE_SECRET_KEY"),
QuoteCurrency: exchanges.QuoteCurrencyUSDC,
})
// OKX — same interface, different constructor
adp, _ := okx.NewAdapter(ctx, okx.Options{
APIKey: os.Getenv("OKX_API_KEY"), SecretKey: os.Getenv("OKX_SECRET_KEY"),
Passphrase: os.Getenv("OKX_PASSPHRASE"),
})
// Hyperliquid — wallet-based auth (USDC only)
adp, _ := hyperliquid.NewAdapter(ctx, hyperliquid.Options{
PrivateKey: os.Getenv("HYPERLIQUID_PRIVATE_KEY"), AccountAddr: os.Getenv("HYPERLIQUID_ACCOUNT_ADDR"),
})
// All adapters expose the exact same Exchange interface
ticker, _ := adp.FetchTicker(ctx, "BTC")Each adapter supports a QuoteCurrency option that determines which quote currency market to connect to. If omitted, the exchange-specific default is used (CEX → USDT, DEX → USDC).
// Available quote currencies
exchanges.QuoteCurrencyUSDT // "USDT"
exchanges.QuoteCurrencyUSDC // "USDC"
exchanges.QuoteCurrencyDUSD // "DUSD" (StandX only)Passing an unsupported quote currency returns an error at construction time:
// This will fail: Hyperliquid only supports USDC
_, err := hyperliquid.NewAdapter(ctx, hyperliquid.Options{
QuoteCurrency: exchanges.QuoteCurrencyUSDT, // error!
})
// err: "hyperliquid: unsupported quote currency "USDT", supported: [USDC]"Every adapter implements these methods:
| Category | Method | Description |
|---|---|---|
| Identity | GetExchange() |
Returns exchange name (e.g. "BINANCE") |
GetMarketType() |
Returns "perp" or "spot" |
|
Close() |
Closes all connections | |
| Symbol | FormatSymbol(symbol) |
Converts "BTC" → exchange format |
ExtractSymbol(symbol) |
Converts exchange format → "BTC" |
|
ListSymbols() |
Returns all available symbols | |
| Market Data | FetchTicker(ctx, symbol) |
Latest price, bid/ask, 24h volume |
FetchOrderBook(ctx, symbol, limit) |
Order book snapshot (REST) | |
FetchTrades(ctx, symbol, limit) |
Recent trades | |
FetchKlines(ctx, symbol, interval, opts) |
Candlestick/OHLCV data | |
| Trading | PlaceOrder(ctx, params) |
Place order (market/limit/post-only) |
CancelOrder(ctx, orderID, symbol) |
Cancel a single order | |
CancelAllOrders(ctx, symbol) |
Cancel all open orders for a symbol | |
FetchOrderByID(ctx, orderID, symbol) |
Get a single order by ID, including terminal orders when supported | |
FetchOrders(ctx, symbol) |
List all visible orders for a symbol | |
FetchOpenOrders(ctx, symbol) |
List all open orders | |
| Account | FetchAccount(ctx) |
Full account: balance + positions + orders |
FetchBalance(ctx) |
Available balance only | |
FetchSymbolDetails(ctx, symbol) |
Precision & min quantity rules | |
FetchFeeRate(ctx, symbol) |
Maker/taker fee rates | |
| Orderbook | WatchOrderBook(ctx, symbol, cb) |
Subscribe to WS orderbook (blocks until ready) |
GetLocalOrderBook(symbol, depth) |
Read local WS-maintained orderbook | |
StopWatchOrderBook(ctx, symbol) |
Unsubscribe | |
| Streaming | WatchOrders(ctx, cb) |
Real-time order updates |
WatchPositions(ctx, cb) |
Real-time position updates | |
WatchTicker(ctx, symbol, cb) |
Real-time ticker | |
WatchTrades(ctx, symbol, cb) |
Real-time trades | |
WatchKlines(ctx, symbol, interval, cb) |
Real-time klines |
FetchOrderByID, FetchOrders, and FetchOpenOrders are intentionally separate: single-order lookup should not be implemented by scanning open orders, and FetchOrders is broader than FetchOpenOrders.
| Method | Description |
|---|---|
FetchPositions(ctx) |
Get all open positions |
SetLeverage(ctx, symbol, leverage) |
Set leverage for a symbol |
FetchFundingRate(ctx, symbol) |
Current funding rate |
FetchAllFundingRates(ctx) |
All funding rates |
ModifyOrder(ctx, orderID, symbol, params) |
Modify an open order (price/qty) |
| Method | Description |
|---|---|
FetchSpotBalances(ctx) |
Per-asset balances (free/locked) |
TransferAsset(ctx, params) |
Transfer between spot/futures accounts |
type OrderParams struct {
Symbol string // Base symbol: "BTC", "ETH"
Side OrderSide // OrderSideBuy or OrderSideSell
Type OrderType // OrderTypeMarket, OrderTypeLimit, OrderTypePostOnly
Quantity decimal.Decimal // Order quantity
Price decimal.Decimal // Required for LIMIT orders
TimeInForce TimeInForce // GTC (default), IOC, FOK
ReduceOnly bool // Reduce-only order
Slippage decimal.Decimal // If > 0, MARKET → LIMIT IOC with slippage
ClientID string // Client-defined order ID
}Backpack requires a numeric clientId in the valid uint32 range. If you want to set OrderParams.ClientID yourself, do not pass UUIDs, timestamps, or any value larger than 4294967295.
Use the package helper instead:
import "github.com/QuantProcessing/exchanges/backpack"
params := &exchanges.OrderParams{
Symbol: "BTC",
Side: exchanges.OrderSideBuy,
Type: exchanges.OrderTypeLimit,
Quantity: qty,
Price: price,
ClientID: backpack.GenerateClientID(),
}If ClientID is left empty, the Backpack adapter generates a safe one automatically.
order, err := adp.PlaceOrder(ctx, params)
if err != nil {
// Structured error matching
if errors.Is(err, exchanges.ErrInsufficientBalance) {
// Handle insufficient balance
}
if errors.Is(err, exchanges.ErrMinQuantity) {
// Handle below minimum quantity
}
if errors.Is(err, exchanges.ErrRateLimited) {
// Handle rate limit according to your own retry/backoff policy
}
// Access exchange-specific details
var exErr *exchanges.ExchangeError
if errors.As(err, &exErr) {
fmt.Printf("[%s] Code: %s, Message: %s\n", exErr.Exchange, exErr.Code, exErr.Message)
}
}Available sentinel errors: ErrInsufficientBalance, ErrRateLimited, ErrInvalidPrecision, ErrOrderNotFound, ErrSymbolNotFound, ErrMinNotional, ErrMinQuantity, ErrAuthFailed, ErrNetworkTimeout, ErrNotSupported.
When an exchange returns a rate-limit error, the SDK wraps it as a structured ExchangeError with ErrRateLimited as the cause. The error flows through the entire call chain:
Your Code (caller)
→ adapter.PlaceOrder() // transparent pass-through (return nil, err)
→ client.Post() // returns exchanges.NewExchangeError(..., ErrRateLimited)
Basic detection — use errors.Is() to check for rate limiting:
order, err := adp.PlaceOrder(ctx, params)
if errors.Is(err, exchanges.ErrRateLimited) {
log.Warn("rate limited, backing off...")
time.Sleep(5 * time.Second)
}Extract exchange-specific details — use errors.As() for the full error context:
var exErr *exchanges.ExchangeError
if errors.As(err, &exErr) && errors.Is(err, exchanges.ErrRateLimited) {
fmt.Printf("Exchange: %s\n", exErr.Exchange) // "BINANCE", "GRVT", "LIGHTER", etc.
fmt.Printf("Code: %s\n", exErr.Code) // "-1003", "1006", "429"
fmt.Printf("Message: %s\n", exErr.Message) // Original error message
}Recommended retry pattern — exponential backoff:
func placeOrderWithRetry(ctx context.Context, adp exchanges.Exchange, params *exchanges.OrderParams) (*exchanges.Order, error) {
maxRetries := 3
for i := 0; i < maxRetries; i++ {
order, err := adp.PlaceOrder(ctx, params)
if err == nil {
return order, nil
}
if !errors.Is(err, exchanges.ErrRateLimited) {
return nil, err // Non-rate-limit error, fail immediately
}
backoff := time.Duration(1<<uint(i)) * time.Second // 1s, 2s, 4s
log.Warnf("rate limited (attempt %d/%d), retrying in %v", i+1, maxRetries, backoff)
select {
case <-time.After(backoff):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("rate limited after %d retries", maxRetries)
}Design note: The library deliberately does not implement automatic retry or backoff. Rate-limit handling strategy (fixed delay, exponential backoff, circuit breaker, etc.) is a business-level decision that callers should own.
Rate-limit detection is implemented at the SDK layer for every supported exchange:
| Exchange | Detection Signal | Error Code | Details |
|---|---|---|---|
| Binance | HTTP 429/418, code -1003/-1015, header X-Mbx-Used-Weight |
-1003, -1015 |
Weight-based + order count tracking |
| Aster | Same as Binance (Binance-family fork) | -1003, -1015 |
Same X-Mbx-* header support |
| OKX | HTTP 429, code 50011/50061 |
50011, 50061 |
Per-endpoint rate limits |
| Hyperliquid | HTTP 429, message-based detection | 429 |
Message content matching |
| EdgeX | HTTP 429, code/message-based detection | 429 |
Custom error code/message |
| GRVT | HTTP 429, error code 1006 |
1006 |
Per-instrument tracking |
| Lighter | HTTP 429 | 429 |
Weight-based (60 req/min standard) |
| Nado | HTTP 429 | 429 |
1200 req/min per IP |
| StandX | HTTP 429 | 429 |
Retry-After header support |
All rate-limit errors are wrapped as ExchangeError with ErrRateLimited as the unwrappable cause. Use errors.Is(err, exchanges.ErrRateLimited) for detection — see Rate Limit Error Handling for detailed usage.
If an exchange returns explicit ban or throttle errors (for example HTTP 418/429), the SDK surfaces them as structured exchange errors so callers can decide whether to retry, back off, or pause.
Before sending orders, adapters automatically:
- Round price to symbol's price precision
- Truncate quantity to symbol's quantity precision
- Validate minimum quantity and notional constraints
All adapters accept an optional Logger for structured logging:
// Compatible with *zap.SugaredLogger
logger := zap.NewProduction().Sugar()
adp, _ := binance.NewAdapter(ctx, binance.Options{
APIKey: "...", SecretKey: "...",
Logger: logger,
})If no logger is provided, NopLogger is used. The interface:
type Logger interface {
Debugw(msg string, keysAndValues ...any)
Infow(msg string, keysAndValues ...any)
Warnw(msg string, keysAndValues ...any)
Errorw(msg string, keysAndValues ...any)
}exchanges/ Root package — interfaces, models, errors, utilities
├── exchange.go Core Exchange / PerpExchange / SpotExchange interfaces
├── models.go Unified data types (Order, Position, Ticker, etc.)
├── errors.go Sentinel errors + ExchangeError type
├── base_adapter.go Shared adapter logic (orderbook, validation, common helpers)
├── local_state.go LocalOrderBook interface + unified LocalState manager
├── event_bus.go Generic EventBus[T] for fan-out pub/sub
├── log.go Logger interface + NopLogger
├── testsuite/ Adapter compliance test suite
├── binance/ Binance adapter + SDK
│ ├── options.go Options{APIKey, SecretKey, QuoteCurrency, Logger}
│ ├── perp_adapter.go Perp adapter (Exchange + PerpExchange)
│ ├── spot_adapter.go Spot adapter (Exchange + SpotExchange)
│ └── sdk/ Low-level REST & WebSocket clients
├── okx/ OKX (same structure)
├── aster/ Aster
├── nado/ Nado
├── lighter/ Lighter
├── hyperliquid/ Hyperliquid
├── standx/ StandX
├── grvt/ GRVT (build tag: grvt)
└── edgex/ EdgeX (build tag: edgex)
The repository uses a layered verification model. Plain go test ./... is not the canonical gate because some packages include live exchange coverage that depends on credentials or longer-running WebSocket sessions.
Copy the example environment file and fill in your credentials when you need live/private verification:
cp .env.example .envRun the default quick gate:
go test -short ./...Run a focused short verification for one exchange:
scripts/verify_exchange.sh backpack
scripts/verify_exchange.sh okx
scripts/verify_exchange.sh hyperliquidRun the full regression suite:
GOCACHE=/tmp/exchanges-gocache bash scripts/verify_full.shverify_full.sh loads .env from the repository root, preserves already-exported shell variables, and manages RUN_FULL=1 internally. It also accepts these legacy aliases:
EDGEX_PRIVATE_KEY -> EDGEX_STARK_PRIVATE_KEYNADO_SUB_ACCOUNT_NAME -> NADO_SUBACCOUNT_NAMEOKX_SECRET_KEY -> OKX_API_SECRETOKX_PASSPHRASE -> OKX_API_PASSPHRASE
Run the soak suite for longer-lived subscription checks:
GOCACHE=/tmp/exchanges-gocache RUN_SOAK=1 bash scripts/verify_soak.shThe current soak suite runs 3-minute stream checks for the designated packages. RUN_FULL and RUN_SOAK are script-managed toggles for the dedicated verification entrypoints above; they are not the default repository gate.
MIT