mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
434 lines
12 KiB
Go
434 lines
12 KiB
Go
package binance
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/luxfi/broker/pkg/types"
|
|
)
|
|
|
|
const (
|
|
ProdURL = "https://api.binance.us/api/v3"
|
|
GlobalURL = "https://api.binance.com/api/v3"
|
|
SandboxURL = "https://testnet.binance.vision/api/v3"
|
|
)
|
|
|
|
// Config for Binance (US or global).
|
|
type Config struct {
|
|
BaseURL string `json:"base_url"`
|
|
APIKey string `json:"api_key"`
|
|
APISecret string `json:"api_secret"`
|
|
}
|
|
|
|
type Provider struct {
|
|
cfg Config
|
|
client *http.Client
|
|
}
|
|
|
|
func New(cfg Config) *Provider {
|
|
if cfg.BaseURL == "" {
|
|
cfg.BaseURL = ProdURL
|
|
}
|
|
return &Provider{cfg: cfg, client: &http.Client{Timeout: 30 * time.Second}}
|
|
}
|
|
|
|
func (p *Provider) Name() string { return "binance" }
|
|
|
|
func (p *Provider) sign(params string) string {
|
|
mac := hmac.New(sha256.New, []byte(p.cfg.APISecret))
|
|
mac.Write([]byte(params))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func (p *Provider) doPublic(ctx context.Context, path string) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.cfg.BaseURL+path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := p.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
data, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("binance %d: %s", resp.StatusCode, string(data))
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func (p *Provider) doSigned(ctx context.Context, method, path string, params string) ([]byte, error) {
|
|
ts := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
|
qstr := params
|
|
if qstr != "" {
|
|
qstr += "&"
|
|
}
|
|
qstr += "timestamp=" + ts
|
|
qstr += "&signature=" + p.sign(qstr)
|
|
|
|
var reqBody io.Reader
|
|
url := p.cfg.BaseURL + path
|
|
if method == http.MethodGet || method == http.MethodDelete {
|
|
url += "?" + qstr
|
|
} else {
|
|
reqBody = bytes.NewReader([]byte(qstr))
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("X-MBX-APIKEY", p.cfg.APIKey)
|
|
if method == http.MethodPost {
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
}
|
|
|
|
resp, err := p.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
data, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("binance %d: %s", resp.StatusCode, string(data))
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// --- Accounts ---
|
|
|
|
func (p *Provider) CreateAccount(_ context.Context, _ *types.CreateAccountRequest) (*types.Account, error) {
|
|
return nil, fmt.Errorf("binance: account creation via binance.us")
|
|
}
|
|
|
|
func (p *Provider) GetAccount(ctx context.Context, _ string) (*types.Account, error) {
|
|
data, err := p.doSigned(ctx, http.MethodGet, "/account", "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var raw struct {
|
|
AccountType string `json:"accountType"`
|
|
CanTrade bool `json:"canTrade"`
|
|
}
|
|
json.Unmarshal(data, &raw)
|
|
status := "inactive"
|
|
if raw.CanTrade {
|
|
status = "active"
|
|
}
|
|
return &types.Account{
|
|
Provider: "binance", ProviderID: "default",
|
|
Status: status, Currency: "USD", AccountType: raw.AccountType,
|
|
}, nil
|
|
}
|
|
|
|
func (p *Provider) ListAccounts(ctx context.Context) ([]*types.Account, error) {
|
|
acct, err := p.GetAccount(ctx, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []*types.Account{acct}, nil
|
|
}
|
|
|
|
func (p *Provider) GetPortfolio(ctx context.Context, _ string) (*types.Portfolio, error) {
|
|
data, err := p.doSigned(ctx, http.MethodGet, "/account", "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var raw struct {
|
|
Balances []struct {
|
|
Asset string `json:"asset"`
|
|
Free string `json:"free"`
|
|
Locked string `json:"locked"`
|
|
} `json:"balances"`
|
|
}
|
|
json.Unmarshal(data, &raw)
|
|
|
|
var positions []types.Position
|
|
var cash string
|
|
for _, b := range raw.Balances {
|
|
free, _ := strconv.ParseFloat(b.Free, 64)
|
|
locked, _ := strconv.ParseFloat(b.Locked, 64)
|
|
total := free + locked
|
|
if total <= 0 {
|
|
continue
|
|
}
|
|
if strings.EqualFold(b.Asset, "USD") || strings.EqualFold(b.Asset, "USDT") || strings.EqualFold(b.Asset, "USDC") {
|
|
cash = fmt.Sprintf("%.2f", total)
|
|
continue
|
|
}
|
|
positions = append(positions, types.Position{
|
|
Symbol: b.Asset, Qty: fmt.Sprintf("%.8f", total), AssetClass: "crypto",
|
|
})
|
|
}
|
|
return &types.Portfolio{Cash: cash, Positions: positions}, nil
|
|
}
|
|
|
|
// --- Orders ---
|
|
|
|
func (p *Provider) CreateOrder(ctx context.Context, _ string, req *types.CreateOrderRequest) (*types.Order, error) {
|
|
sym := normalizeSymbol(req.Symbol)
|
|
params := fmt.Sprintf("symbol=%s&side=%s&type=%s&quantity=%s",
|
|
sym, strings.ToUpper(req.Side), mapOrderType(req.Type), req.Qty)
|
|
if req.LimitPrice != "" {
|
|
params += "&price=" + req.LimitPrice + "&timeInForce=" + mapTIF(req.TimeInForce)
|
|
}
|
|
|
|
data, err := p.doSigned(ctx, http.MethodPost, "/order", params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var raw struct {
|
|
OrderID int `json:"orderId"`
|
|
Symbol string `json:"symbol"`
|
|
Status string `json:"status"`
|
|
ExecutedQty string `json:"executedQty"`
|
|
CumQuoteQty string `json:"cummulativeQuoteQty"`
|
|
}
|
|
json.Unmarshal(data, &raw)
|
|
return &types.Order{
|
|
Provider: "binance", ProviderID: strconv.Itoa(raw.OrderID),
|
|
Symbol: req.Symbol, Qty: req.Qty, Side: req.Side, Type: req.Type,
|
|
Status: mapBinanceStatus(raw.Status), FilledQty: raw.ExecutedQty,
|
|
CreatedAt: time.Now(),
|
|
}, nil
|
|
}
|
|
|
|
func (p *Provider) ListOrders(ctx context.Context, _ string) ([]*types.Order, error) {
|
|
data, err := p.doSigned(ctx, http.MethodGet, "/openOrders", "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var raw []struct {
|
|
OrderID int `json:"orderId"`
|
|
Symbol string `json:"symbol"`
|
|
Side string `json:"side"`
|
|
Type string `json:"type"`
|
|
Status string `json:"status"`
|
|
Qty string `json:"origQty"`
|
|
Filled string `json:"executedQty"`
|
|
Price string `json:"price"`
|
|
}
|
|
json.Unmarshal(data, &raw)
|
|
orders := make([]*types.Order, len(raw))
|
|
for i, o := range raw {
|
|
orders[i] = &types.Order{
|
|
Provider: "binance", ProviderID: strconv.Itoa(o.OrderID),
|
|
Symbol: o.Symbol, Side: strings.ToLower(o.Side), Type: strings.ToLower(o.Type),
|
|
Status: mapBinanceStatus(o.Status), Qty: o.Qty, FilledQty: o.Filled,
|
|
}
|
|
}
|
|
return orders, nil
|
|
}
|
|
|
|
func (p *Provider) GetOrder(ctx context.Context, _ string, orderID string) (*types.Order, error) {
|
|
// Binance requires symbol — use allOrders with orderId
|
|
data, err := p.doSigned(ctx, http.MethodGet, "/order", "orderId="+orderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var raw struct {
|
|
OrderID int `json:"orderId"`
|
|
Symbol string `json:"symbol"`
|
|
Side string `json:"side"`
|
|
Status string `json:"status"`
|
|
Qty string `json:"origQty"`
|
|
Filled string `json:"executedQty"`
|
|
}
|
|
json.Unmarshal(data, &raw)
|
|
return &types.Order{
|
|
Provider: "binance", ProviderID: strconv.Itoa(raw.OrderID),
|
|
Symbol: raw.Symbol, Side: strings.ToLower(raw.Side),
|
|
Status: mapBinanceStatus(raw.Status), Qty: raw.Qty, FilledQty: raw.Filled,
|
|
}, nil
|
|
}
|
|
|
|
func (p *Provider) CancelOrder(ctx context.Context, _ string, orderID string) error {
|
|
_, err := p.doSigned(ctx, http.MethodDelete, "/order", "orderId="+orderID)
|
|
return err
|
|
}
|
|
|
|
// --- Stubs ---
|
|
|
|
func (p *Provider) CreateTransfer(_ context.Context, _ string, _ *types.CreateTransferRequest) (*types.Transfer, error) {
|
|
return nil, fmt.Errorf("binance: transfers via binance.us")
|
|
}
|
|
func (p *Provider) ListTransfers(_ context.Context, _ string) ([]*types.Transfer, error) {
|
|
return []*types.Transfer{}, nil
|
|
}
|
|
func (p *Provider) CreateBankRelationship(_ context.Context, _, _, _, _, _ string) (*types.BankRelationship, error) {
|
|
return nil, fmt.Errorf("binance: bank linking via binance.us")
|
|
}
|
|
func (p *Provider) ListBankRelationships(_ context.Context, _ string) ([]*types.BankRelationship, error) {
|
|
return []*types.BankRelationship{}, nil
|
|
}
|
|
|
|
// --- Assets ---
|
|
|
|
func (p *Provider) ListAssets(ctx context.Context, _ string) ([]*types.Asset, error) {
|
|
data, err := p.doPublic(ctx, "/exchangeInfo")
|
|
if err != nil {
|
|
return defaultAssets(), nil
|
|
}
|
|
var info struct {
|
|
Symbols []struct {
|
|
Symbol string `json:"symbol"`
|
|
BaseAsset string `json:"baseAsset"`
|
|
QuoteAsset string `json:"quoteAsset"`
|
|
Status string `json:"status"`
|
|
} `json:"symbols"`
|
|
}
|
|
json.Unmarshal(data, &info)
|
|
assets := make([]*types.Asset, 0)
|
|
for _, s := range info.Symbols {
|
|
if s.QuoteAsset != "USD" && s.QuoteAsset != "USDT" {
|
|
continue
|
|
}
|
|
tradable := s.Status == "TRADING"
|
|
assets = append(assets, &types.Asset{
|
|
ID: s.Symbol, Provider: "binance",
|
|
Symbol: s.BaseAsset + "/" + s.QuoteAsset, Name: s.Symbol,
|
|
Class: "crypto", Status: "active", Tradable: tradable,
|
|
})
|
|
}
|
|
return assets, nil
|
|
}
|
|
|
|
func (p *Provider) GetAsset(_ context.Context, sym string) (*types.Asset, error) {
|
|
return &types.Asset{ID: sym, Provider: "binance", Symbol: sym, Class: "crypto", Status: "active", Tradable: true}, nil
|
|
}
|
|
|
|
func (p *Provider) GetSnapshot(ctx context.Context, symbol string) (*types.MarketSnapshot, error) {
|
|
sym := normalizeSymbol(symbol)
|
|
data, err := p.doPublic(ctx, "/ticker/24hr?symbol="+sym)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var raw struct {
|
|
BidPrice string `json:"bidPrice"`
|
|
AskPrice string `json:"askPrice"`
|
|
LastPrice string `json:"lastPrice"`
|
|
Volume string `json:"volume"`
|
|
High string `json:"highPrice"`
|
|
Low string `json:"lowPrice"`
|
|
Open string `json:"openPrice"`
|
|
}
|
|
json.Unmarshal(data, &raw)
|
|
bid, _ := strconv.ParseFloat(raw.BidPrice, 64)
|
|
ask, _ := strconv.ParseFloat(raw.AskPrice, 64)
|
|
last, _ := strconv.ParseFloat(raw.LastPrice, 64)
|
|
vol, _ := strconv.ParseFloat(raw.Volume, 64)
|
|
hi, _ := strconv.ParseFloat(raw.High, 64)
|
|
lo, _ := strconv.ParseFloat(raw.Low, 64)
|
|
op, _ := strconv.ParseFloat(raw.Open, 64)
|
|
|
|
return &types.MarketSnapshot{
|
|
Symbol: symbol,
|
|
LatestQuote: &types.Quote{
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
BidPrice: bid, AskPrice: ask,
|
|
},
|
|
LatestTrade: &types.Trade{
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
Price: last,
|
|
},
|
|
DailyBar: &types.Bar{Open: op, High: hi, Low: lo, Close: last, Volume: vol},
|
|
}, nil
|
|
}
|
|
|
|
func (p *Provider) GetSnapshots(ctx context.Context, symbols []string) (map[string]*types.MarketSnapshot, error) {
|
|
result := make(map[string]*types.MarketSnapshot)
|
|
for _, s := range symbols {
|
|
snap, err := p.GetSnapshot(ctx, s)
|
|
if err == nil {
|
|
result[s] = snap
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (p *Provider) GetBars(_ context.Context, _, _, _, _ string, _ int) ([]*types.Bar, error) {
|
|
return nil, fmt.Errorf("binance: bars not yet implemented")
|
|
}
|
|
func (p *Provider) GetLatestTrades(_ context.Context, _ []string) (map[string]*types.Trade, error) {
|
|
return nil, fmt.Errorf("binance: not yet implemented")
|
|
}
|
|
func (p *Provider) GetLatestQuotes(_ context.Context, _ []string) (map[string]*types.Quote, error) {
|
|
return nil, fmt.Errorf("binance: not yet implemented")
|
|
}
|
|
func (p *Provider) GetClock(_ context.Context) (*types.MarketClock, error) {
|
|
return &types.MarketClock{Timestamp: time.Now().UTC().Format(time.RFC3339), IsOpen: true}, nil
|
|
}
|
|
func (p *Provider) GetCalendar(_ context.Context, _, _ string) ([]*types.MarketCalendarDay, error) {
|
|
return nil, fmt.Errorf("binance: crypto 24/7")
|
|
}
|
|
|
|
func normalizeSymbol(s string) string {
|
|
s = strings.ToUpper(s)
|
|
s = strings.ReplaceAll(s, "/", "")
|
|
s = strings.ReplaceAll(s, "-", "")
|
|
return s
|
|
}
|
|
func mapOrderType(t string) string {
|
|
switch t {
|
|
case "market":
|
|
return "MARKET"
|
|
case "limit":
|
|
return "LIMIT"
|
|
case "stop":
|
|
return "STOP_LOSS"
|
|
case "stop_limit":
|
|
return "STOP_LOSS_LIMIT"
|
|
default:
|
|
return "MARKET"
|
|
}
|
|
}
|
|
func mapTIF(t string) string {
|
|
switch t {
|
|
case "gtc":
|
|
return "GTC"
|
|
case "ioc":
|
|
return "IOC"
|
|
case "fok":
|
|
return "FOK"
|
|
default:
|
|
return "GTC"
|
|
}
|
|
}
|
|
func mapBinanceStatus(s string) string {
|
|
switch s {
|
|
case "NEW":
|
|
return "new"
|
|
case "PARTIALLY_FILLED":
|
|
return "partially_filled"
|
|
case "FILLED":
|
|
return "filled"
|
|
case "CANCELED":
|
|
return "cancelled"
|
|
case "REJECTED":
|
|
return "rejected"
|
|
case "EXPIRED":
|
|
return "expired"
|
|
default:
|
|
return strings.ToLower(s)
|
|
}
|
|
}
|
|
|
|
func defaultAssets() []*types.Asset {
|
|
syms := []string{"BTC", "ETH", "SOL", "AVAX", "DOT", "LINK", "UNI", "LTC", "XRP", "ADA", "DOGE", "MATIC", "ATOM"}
|
|
out := make([]*types.Asset, len(syms))
|
|
for i, s := range syms {
|
|
out[i] = &types.Asset{ID: s + "USD", Provider: "binance", Symbol: s + "/USD", Class: "crypto", Status: "active", Tradable: true}
|
|
}
|
|
return out
|
|
}
|