mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
492 lines
15 KiB
Go
492 lines
15 KiB
Go
// Package crypto provides a generic crypto exchange adapter template.
|
|
// Concrete exchanges can embed BaseExchange and override only the methods
|
|
// that differ from the common REST/HMAC pattern shared by most CEXes.
|
|
package crypto
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/luxfi/broker/pkg/types"
|
|
)
|
|
|
|
// Config holds the common credentials and endpoints for a crypto exchange.
|
|
type Config struct {
|
|
Name string `json:"name"` // exchange identifier (e.g. "myexchange")
|
|
BaseURL string `json:"base_url"`
|
|
APIKey string `json:"api_key"`
|
|
APISecret string `json:"api_secret"`
|
|
Passphrase string `json:"passphrase,omitempty"` // some exchanges require a passphrase
|
|
Sandbox bool `json:"sandbox,omitempty"`
|
|
}
|
|
|
|
// Endpoints maps logical operations to concrete REST paths.
|
|
// Override these for each exchange.
|
|
type Endpoints struct {
|
|
CreateAccount string // POST
|
|
GetAccount string // GET, {account_id}
|
|
ListAccounts string // GET
|
|
GetPortfolio string // GET, {account_id}
|
|
CreateOrder string // POST
|
|
ListOrders string // GET, {account_id}
|
|
GetOrder string // GET, {account_id}, {order_id}
|
|
CancelOrder string // DELETE, {account_id}, {order_id}
|
|
ListAssets string // GET
|
|
GetAsset string // GET, {symbol}
|
|
GetSnapshot string // GET, {symbol}
|
|
GetSnapshots string // GET, ?symbols=
|
|
GetBars string // GET, {symbol}
|
|
GetLatestTrades string // GET
|
|
GetLatestQuotes string // GET
|
|
GetClock string // GET
|
|
GetCalendar string // GET
|
|
CreateTransfer string // POST
|
|
ListTransfers string // GET
|
|
CreateBankRelation string // POST
|
|
ListBankRelations string // GET
|
|
}
|
|
|
|
// DefaultEndpoints returns sensible defaults that match the most common
|
|
// crypto exchange REST API patterns.
|
|
func DefaultEndpoints() Endpoints {
|
|
return Endpoints{
|
|
ListAccounts: "/api/v1/accounts",
|
|
GetAccount: "/api/v1/accounts/{account_id}",
|
|
GetPortfolio: "/api/v1/accounts/{account_id}/portfolio",
|
|
CreateOrder: "/api/v1/orders",
|
|
ListOrders: "/api/v1/orders",
|
|
GetOrder: "/api/v1/orders/{order_id}",
|
|
CancelOrder: "/api/v1/orders/{order_id}",
|
|
ListAssets: "/api/v1/assets",
|
|
GetAsset: "/api/v1/assets/{symbol}",
|
|
GetSnapshot: "/api/v1/ticker/{symbol}",
|
|
GetSnapshots: "/api/v1/tickers",
|
|
GetBars: "/api/v1/candles/{symbol}",
|
|
GetLatestTrades: "/api/v1/trades",
|
|
GetLatestQuotes: "/api/v1/quotes",
|
|
CreateTransfer: "/api/v1/transfers",
|
|
ListTransfers: "/api/v1/transfers",
|
|
}
|
|
}
|
|
|
|
// BaseExchange implements provider.Provider with common crypto exchange patterns.
|
|
// Embed this in a concrete exchange provider and override methods as needed.
|
|
type BaseExchange struct {
|
|
Cfg Config
|
|
Eps Endpoints
|
|
Client *http.Client
|
|
SignFn func(method, path, body string, timestamp int64) string // custom signer
|
|
}
|
|
|
|
// New creates a BaseExchange with default settings.
|
|
func New(cfg Config) *BaseExchange {
|
|
if cfg.Name == "" {
|
|
cfg.Name = "crypto"
|
|
}
|
|
return &BaseExchange{
|
|
Cfg: cfg,
|
|
Eps: DefaultEndpoints(),
|
|
Client: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (e *BaseExchange) Name() string { return e.Cfg.Name }
|
|
|
|
// Do executes an authenticated HTTP request against the exchange.
|
|
func (e *BaseExchange) Do(ctx context.Context, method, path string, body interface{}) ([]byte, int, error) {
|
|
var reqBody io.Reader
|
|
var bodyBytes []byte
|
|
if body != nil {
|
|
var err error
|
|
bodyBytes, err = json.Marshal(body)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("marshal body: %w", err)
|
|
}
|
|
reqBody = bytes.NewReader(bodyBytes)
|
|
}
|
|
|
|
url := e.Cfg.BaseURL + path
|
|
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
ts := time.Now().Unix()
|
|
sig := e.sign(method, path, string(bodyBytes), ts)
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-API-Key", e.Cfg.APIKey)
|
|
req.Header.Set("X-Timestamp", strconv.FormatInt(ts, 10))
|
|
req.Header.Set("X-Signature", sig)
|
|
if e.Cfg.Passphrase != "" {
|
|
req.Header.Set("X-Passphrase", e.Cfg.Passphrase)
|
|
}
|
|
|
|
resp, err := e.Client.Do(req)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("http request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, resp.StatusCode, fmt.Errorf("read body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return data, resp.StatusCode, fmt.Errorf("api error %d: %s", resp.StatusCode, string(data))
|
|
}
|
|
return data, resp.StatusCode, nil
|
|
}
|
|
|
|
func (e *BaseExchange) sign(method, path, body string, timestamp int64) string {
|
|
if e.SignFn != nil {
|
|
return e.SignFn(method, path, body, timestamp)
|
|
}
|
|
// Default: HMAC-SHA256(timestamp + method + path + body)
|
|
msg := strconv.FormatInt(timestamp, 10) + method + path + body
|
|
mac := hmac.New(sha256.New, []byte(e.Cfg.APISecret))
|
|
mac.Write([]byte(msg))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func (e *BaseExchange) resolve(pattern string, params map[string]string) string {
|
|
result := pattern
|
|
for k, v := range params {
|
|
result = strings.ReplaceAll(result, "{"+k+"}", v)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// --- Provider Interface Implementation ---
|
|
|
|
func (e *BaseExchange) CreateAccount(ctx context.Context, req *types.CreateAccountRequest) (*types.Account, error) {
|
|
data, _, err := e.Do(ctx, "POST", e.Eps.CreateAccount, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var acct types.Account
|
|
if err := json.Unmarshal(data, &acct); err != nil {
|
|
return nil, fmt.Errorf("decode account: %w", err)
|
|
}
|
|
acct.Provider = e.Cfg.Name
|
|
return &acct, nil
|
|
}
|
|
|
|
func (e *BaseExchange) GetAccount(ctx context.Context, accountID string) (*types.Account, error) {
|
|
path := e.resolve(e.Eps.GetAccount, map[string]string{"account_id": accountID})
|
|
data, _, err := e.Do(ctx, "GET", path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var acct types.Account
|
|
if err := json.Unmarshal(data, &acct); err != nil {
|
|
return nil, fmt.Errorf("decode account: %w", err)
|
|
}
|
|
acct.Provider = e.Cfg.Name
|
|
return &acct, nil
|
|
}
|
|
|
|
func (e *BaseExchange) ListAccounts(ctx context.Context) ([]*types.Account, error) {
|
|
data, _, err := e.Do(ctx, "GET", e.Eps.ListAccounts, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var accts []*types.Account
|
|
if err := json.Unmarshal(data, &accts); err != nil {
|
|
return nil, fmt.Errorf("decode accounts: %w", err)
|
|
}
|
|
for _, a := range accts {
|
|
a.Provider = e.Cfg.Name
|
|
}
|
|
return accts, nil
|
|
}
|
|
|
|
func (e *BaseExchange) GetPortfolio(ctx context.Context, accountID string) (*types.Portfolio, error) {
|
|
path := e.resolve(e.Eps.GetPortfolio, map[string]string{"account_id": accountID})
|
|
data, _, err := e.Do(ctx, "GET", path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var port types.Portfolio
|
|
if err := json.Unmarshal(data, &port); err != nil {
|
|
return nil, fmt.Errorf("decode portfolio: %w", err)
|
|
}
|
|
return &port, nil
|
|
}
|
|
|
|
func (e *BaseExchange) CreateOrder(ctx context.Context, accountID string, req *types.CreateOrderRequest) (*types.Order, error) {
|
|
payload := map[string]interface{}{
|
|
"account_id": accountID,
|
|
"symbol": req.Symbol,
|
|
"side": req.Side,
|
|
"type": req.Type,
|
|
"time_in_force": req.TimeInForce,
|
|
}
|
|
if req.Qty != "" {
|
|
payload["qty"] = req.Qty
|
|
}
|
|
if req.Notional != "" {
|
|
payload["notional"] = req.Notional
|
|
}
|
|
if req.LimitPrice != "" {
|
|
payload["limit_price"] = req.LimitPrice
|
|
}
|
|
if req.StopPrice != "" {
|
|
payload["stop_price"] = req.StopPrice
|
|
}
|
|
|
|
data, _, err := e.Do(ctx, "POST", e.Eps.CreateOrder, payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var order types.Order
|
|
if err := json.Unmarshal(data, &order); err != nil {
|
|
return nil, fmt.Errorf("decode order: %w", err)
|
|
}
|
|
order.Provider = e.Cfg.Name
|
|
return &order, nil
|
|
}
|
|
|
|
func (e *BaseExchange) ListOrders(ctx context.Context, accountID string) ([]*types.Order, error) {
|
|
path := e.Eps.ListOrders + "?account_id=" + accountID
|
|
data, _, err := e.Do(ctx, "GET", path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var orders []*types.Order
|
|
if err := json.Unmarshal(data, &orders); err != nil {
|
|
return nil, fmt.Errorf("decode orders: %w", err)
|
|
}
|
|
for _, o := range orders {
|
|
o.Provider = e.Cfg.Name
|
|
}
|
|
return orders, nil
|
|
}
|
|
|
|
func (e *BaseExchange) GetOrder(ctx context.Context, accountID, orderID string) (*types.Order, error) {
|
|
path := e.resolve(e.Eps.GetOrder, map[string]string{"order_id": orderID})
|
|
data, _, err := e.Do(ctx, "GET", path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var order types.Order
|
|
if err := json.Unmarshal(data, &order); err != nil {
|
|
return nil, fmt.Errorf("decode order: %w", err)
|
|
}
|
|
order.Provider = e.Cfg.Name
|
|
return &order, nil
|
|
}
|
|
|
|
func (e *BaseExchange) CancelOrder(ctx context.Context, accountID, orderID string) error {
|
|
path := e.resolve(e.Eps.CancelOrder, map[string]string{"order_id": orderID})
|
|
_, _, err := e.Do(ctx, "DELETE", path, nil)
|
|
return err
|
|
}
|
|
|
|
func (e *BaseExchange) CreateTransfer(ctx context.Context, accountID string, req *types.CreateTransferRequest) (*types.Transfer, error) {
|
|
data, _, err := e.Do(ctx, "POST", e.Eps.CreateTransfer, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var xfer types.Transfer
|
|
if err := json.Unmarshal(data, &xfer); err != nil {
|
|
return nil, fmt.Errorf("decode transfer: %w", err)
|
|
}
|
|
xfer.Provider = e.Cfg.Name
|
|
return &xfer, nil
|
|
}
|
|
|
|
func (e *BaseExchange) ListTransfers(ctx context.Context, accountID string) ([]*types.Transfer, error) {
|
|
path := e.Eps.ListTransfers + "?account_id=" + accountID
|
|
data, _, err := e.Do(ctx, "GET", path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var xfers []*types.Transfer
|
|
if err := json.Unmarshal(data, &xfers); err != nil {
|
|
return nil, fmt.Errorf("decode transfers: %w", err)
|
|
}
|
|
for _, x := range xfers {
|
|
x.Provider = e.Cfg.Name
|
|
}
|
|
return xfers, nil
|
|
}
|
|
|
|
func (e *BaseExchange) CreateBankRelationship(ctx context.Context, accountID, ownerName, accountType, accountNumber, routingNumber string) (*types.BankRelationship, error) {
|
|
payload := map[string]string{
|
|
"account_id": accountID,
|
|
"owner_name": ownerName,
|
|
"account_type": accountType,
|
|
"account_number": accountNumber,
|
|
"routing_number": routingNumber,
|
|
}
|
|
data, _, err := e.Do(ctx, "POST", e.Eps.CreateBankRelation, payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var rel types.BankRelationship
|
|
if err := json.Unmarshal(data, &rel); err != nil {
|
|
return nil, fmt.Errorf("decode bank relationship: %w", err)
|
|
}
|
|
rel.Provider = e.Cfg.Name
|
|
return &rel, nil
|
|
}
|
|
|
|
func (e *BaseExchange) ListBankRelationships(ctx context.Context, accountID string) ([]*types.BankRelationship, error) {
|
|
path := e.Eps.ListBankRelations + "?account_id=" + accountID
|
|
data, _, err := e.Do(ctx, "GET", path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var rels []*types.BankRelationship
|
|
if err := json.Unmarshal(data, &rels); err != nil {
|
|
return nil, fmt.Errorf("decode bank relationships: %w", err)
|
|
}
|
|
for _, r := range rels {
|
|
r.Provider = e.Cfg.Name
|
|
}
|
|
return rels, nil
|
|
}
|
|
|
|
func (e *BaseExchange) ListAssets(ctx context.Context, class string) ([]*types.Asset, error) {
|
|
path := e.Eps.ListAssets
|
|
if class != "" {
|
|
path += "?class=" + class
|
|
}
|
|
data, _, err := e.Do(ctx, "GET", path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var assets []*types.Asset
|
|
if err := json.Unmarshal(data, &assets); err != nil {
|
|
return nil, fmt.Errorf("decode assets: %w", err)
|
|
}
|
|
for _, a := range assets {
|
|
a.Provider = e.Cfg.Name
|
|
}
|
|
return assets, nil
|
|
}
|
|
|
|
func (e *BaseExchange) GetAsset(ctx context.Context, symbolOrID string) (*types.Asset, error) {
|
|
path := e.resolve(e.Eps.GetAsset, map[string]string{"symbol": symbolOrID})
|
|
data, _, err := e.Do(ctx, "GET", path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var asset types.Asset
|
|
if err := json.Unmarshal(data, &asset); err != nil {
|
|
return nil, fmt.Errorf("decode asset: %w", err)
|
|
}
|
|
asset.Provider = e.Cfg.Name
|
|
return &asset, nil
|
|
}
|
|
|
|
func (e *BaseExchange) GetSnapshot(ctx context.Context, symbol string) (*types.MarketSnapshot, error) {
|
|
path := e.resolve(e.Eps.GetSnapshot, map[string]string{"symbol": symbol})
|
|
data, _, err := e.Do(ctx, "GET", path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var snap types.MarketSnapshot
|
|
if err := json.Unmarshal(data, &snap); err != nil {
|
|
return nil, fmt.Errorf("decode snapshot: %w", err)
|
|
}
|
|
snap.Symbol = symbol
|
|
return &snap, nil
|
|
}
|
|
|
|
func (e *BaseExchange) GetSnapshots(ctx context.Context, symbols []string) (map[string]*types.MarketSnapshot, error) {
|
|
path := e.Eps.GetSnapshots + "?symbols=" + strings.Join(symbols, ",")
|
|
data, _, err := e.Do(ctx, "GET", path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var snaps map[string]*types.MarketSnapshot
|
|
if err := json.Unmarshal(data, &snaps); err != nil {
|
|
return nil, fmt.Errorf("decode snapshots: %w", err)
|
|
}
|
|
return snaps, nil
|
|
}
|
|
|
|
func (e *BaseExchange) GetBars(ctx context.Context, symbol, timeframe, start, end string, limit int) ([]*types.Bar, error) {
|
|
path := e.resolve(e.Eps.GetBars, map[string]string{"symbol": symbol})
|
|
path += fmt.Sprintf("?timeframe=%s&start=%s&end=%s&limit=%d", timeframe, start, end, limit)
|
|
data, _, err := e.Do(ctx, "GET", path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var bars []*types.Bar
|
|
if err := json.Unmarshal(data, &bars); err != nil {
|
|
return nil, fmt.Errorf("decode bars: %w", err)
|
|
}
|
|
return bars, nil
|
|
}
|
|
|
|
func (e *BaseExchange) GetLatestTrades(ctx context.Context, symbols []string) (map[string]*types.Trade, error) {
|
|
path := e.Eps.GetLatestTrades + "?symbols=" + strings.Join(symbols, ",")
|
|
data, _, err := e.Do(ctx, "GET", path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var trades map[string]*types.Trade
|
|
if err := json.Unmarshal(data, &trades); err != nil {
|
|
return nil, fmt.Errorf("decode trades: %w", err)
|
|
}
|
|
return trades, nil
|
|
}
|
|
|
|
func (e *BaseExchange) GetLatestQuotes(ctx context.Context, symbols []string) (map[string]*types.Quote, error) {
|
|
path := e.Eps.GetLatestQuotes + "?symbols=" + strings.Join(symbols, ",")
|
|
data, _, err := e.Do(ctx, "GET", path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var quotes map[string]*types.Quote
|
|
if err := json.Unmarshal(data, "es); err != nil {
|
|
return nil, fmt.Errorf("decode quotes: %w", err)
|
|
}
|
|
return quotes, nil
|
|
}
|
|
|
|
func (e *BaseExchange) GetClock(ctx context.Context) (*types.MarketClock, error) {
|
|
if e.Eps.GetClock == "" {
|
|
return &types.MarketClock{IsOpen: true, Timestamp: time.Now().UTC().Format(time.RFC3339)}, nil
|
|
}
|
|
data, _, err := e.Do(ctx, "GET", e.Eps.GetClock, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var clock types.MarketClock
|
|
if err := json.Unmarshal(data, &clock); err != nil {
|
|
return nil, fmt.Errorf("decode clock: %w", err)
|
|
}
|
|
return &clock, nil
|
|
}
|
|
|
|
func (e *BaseExchange) GetCalendar(ctx context.Context, start, end string) ([]*types.MarketCalendarDay, error) {
|
|
if e.Eps.GetCalendar == "" {
|
|
return nil, nil // crypto exchanges trade 24/7
|
|
}
|
|
path := e.Eps.GetCalendar + fmt.Sprintf("?start=%s&end=%s", start, end)
|
|
data, _, err := e.Do(ctx, "GET", path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var days []*types.MarketCalendarDay
|
|
if err := json.Unmarshal(data, &days); err != nil {
|
|
return nil, fmt.Errorf("decode calendar: %w", err)
|
|
}
|
|
return days, nil
|
|
}
|