Merge pull request #1237 from bailantaotao/edwin/add-new-exchange-account-api

FEATURE: add new exchange Bybit GetAccountInfo/GetInstrumentsInfo api
This commit is contained in:
bailantaotao 2023-07-24 16:58:12 +08:00 committed by GitHub
commit 06a741e615
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 701 additions and 3 deletions

View File

@ -128,7 +128,8 @@ the implementation.
- OKEx Spot Exchange
- Kucoin Spot Exchange
- MAX Spot Exchange (located in Taiwan)
- Bitget (In Progress)
- Bitget Exchange (In Progress)
- Bybit Exchange (In Progress)
## Documentation and General Topics
@ -219,6 +220,10 @@ KUCOIN_API_KEY=
KUCOIN_API_SECRET=
KUCOIN_API_PASSPHRASE=
KUCOIN_API_KEY_VERSION=2
# for Bybit exchange, if you have one
BYBIT_API_KEY=
BYBIT_API_SECRET=
```
Prepare your dotenv file `.env.local` and BBGO yaml config file `bbgo.yaml`.

View File

@ -0,0 +1,165 @@
package bybitapi
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"github.com/c9s/requestgen"
"github.com/pkg/errors"
"github.com/c9s/bbgo/pkg/types"
)
const defaultHTTPTimeout = time.Second * 15
const RestBaseURL = "https://api.bybit.com"
// defaultRequestWindowMilliseconds specify how long an HTTP request is valid. It is also used to prevent replay attacks.
var defaultRequestWindowMilliseconds = fmt.Sprintf("%d", time.Millisecond*5000)
type RestClient struct {
requestgen.BaseAPIClient
key, secret string
}
func NewClient() (*RestClient, error) {
u, err := url.Parse(RestBaseURL)
if err != nil {
return nil, err
}
return &RestClient{
BaseAPIClient: requestgen.BaseAPIClient{
BaseURL: u,
HttpClient: &http.Client{
Timeout: defaultHTTPTimeout,
},
},
}, nil
}
func (c *RestClient) Auth(key, secret string) {
c.key = key
// pragma: allowlist secret
c.secret = secret
}
// newAuthenticatedRequest creates new http request for authenticated routes.
func (c *RestClient) NewAuthenticatedRequest(ctx context.Context, method, refURL string, params url.Values, payload interface{}) (*http.Request, error) {
if len(c.key) == 0 {
return nil, errors.New("empty api key")
}
if len(c.secret) == 0 {
return nil, errors.New("empty api secret")
}
rel, err := url.Parse(refURL)
if err != nil {
return nil, err
}
if params != nil {
rel.RawQuery = params.Encode()
}
pathURL := c.BaseURL.ResolveReference(rel)
path := pathURL.Path
if rel.RawQuery != "" {
path += "?" + rel.RawQuery
}
t := time.Now().In(time.UTC)
timestamp := strconv.FormatInt(t.UnixMilli(), 10)
body, err := castPayload(payload)
if err != nil {
return nil, err
}
var signKey string
switch method {
case http.MethodPost:
signKey = timestamp + c.key + defaultRequestWindowMilliseconds + string(body)
case http.MethodGet:
signKey = timestamp + c.key + defaultRequestWindowMilliseconds + rel.RawQuery
default:
return nil, fmt.Errorf("unexpected method: %s", method)
}
// See https://bybit-exchange.github.io/docs/v5/guide#create-a-request
//
// 1. timestamp + API key + (recv_window) + (queryString | jsonBodyString)
// 2. Use the HMAC_SHA256 or RSA_SHA256 algorithm to sign the string in step 1, and convert it to a hex
// string (HMAC_SHA256) / base64 (RSA_SHA256) to obtain the sign parameter.
// 3. Append the sign parameter to request header, and send the HTTP request.
signature := sign(signKey, c.secret)
req, err := http.NewRequestWithContext(ctx, method, pathURL.String(), bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("X-BAPI-API-KEY", c.key)
req.Header.Add("X-BAPI-TIMESTAMP", timestamp)
req.Header.Add("X-BAPI-SIGN", signature)
req.Header.Add("X-BAPI-RECV-WINDOW", defaultRequestWindowMilliseconds)
return req, nil
}
func sign(payload string, secret string) string {
var sig = hmac.New(sha256.New, []byte(secret))
_, err := sig.Write([]byte(payload))
if err != nil {
return ""
}
return base64.StdEncoding.EncodeToString(sig.Sum(nil))
}
func castPayload(payload interface{}) ([]byte, error) {
if payload == nil {
return nil, nil
}
switch v := payload.(type) {
case string:
return []byte(v), nil
case []byte:
return v, nil
}
return json.Marshal(payload)
}
/*
sample:
{
"retCode": 0,
"retMsg": "OK",
"result": {
},
"retExtInfo": {},
"time": 1671017382656
}
*/
type APIResponse struct {
RetCode uint `json:"retCode"`
RetMsg string `json:"retMsg"`
Result json.RawMessage `json:"result"`
RetExtInfo json.RawMessage `json:"retExtInfo"`
Time types.MillisecondTimestamp `json:"time"`
}

View File

@ -0,0 +1,48 @@
package bybitapi
import (
"context"
"os"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/c9s/bbgo/pkg/testutil"
)
func getTestClientOrSkip(t *testing.T) *RestClient {
if b, _ := strconv.ParseBool(os.Getenv("CI")); b {
t.Skip("skip test for CI")
}
key, secret, ok := testutil.IntegrationTestConfigured(t, "BYBIT")
if !ok {
t.Skip("BYBIT_* env vars are not configured")
return nil
}
client, err := NewClient()
assert.NoError(t, err)
client.Auth(key, secret)
return client
}
func TestClient(t *testing.T) {
client := getTestClientOrSkip(t)
ctx := context.Background()
t.Run("GetAccountInfoRequest", func(t *testing.T) {
req := client.NewGetAccountRequest()
accountInfo, err := req.Do(ctx)
assert.NoError(t, err)
t.Logf("accountInfo: %+v", accountInfo)
})
t.Run("GetInstrumentsInfoRequest", func(t *testing.T) {
req := client.NewGetInstrumentsInfoRequest()
instrumentsInfo, err := req.Do(ctx)
assert.NoError(t, err)
t.Logf("instrumentsInfo: %+v", instrumentsInfo)
})
}

View File

@ -0,0 +1,24 @@
package bybitapi
import "github.com/c9s/requestgen"
//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Result
//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Result
type AccountInfo struct {
MarginMode string `json:"marginMode"`
UpdatedTime string `json:"updatedTime"`
UnifiedMarginStatus int `json:"unifiedMarginStatus"`
DcpStatus string `json:"dcpStatus"`
TimeWindow int `json:"timeWindow"`
SmpGroup int `json:"smpGroup"`
}
//go:generate GetRequest -url "/v5/account/info" -type GetAccountInfoRequest -responseDataType .AccountInfo
type GetAccountInfoRequest struct {
client requestgen.AuthenticatedAPIClient
}
func (c *RestClient) NewGetAccountRequest() *GetAccountInfoRequest {
return &GetAccountInfoRequest{client: c}
}

View File

@ -0,0 +1,139 @@
// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Result -url /v5/account/info -type GetAccountInfoRequest -responseDataType .AccountInfo"; DO NOT EDIT.
package bybitapi
import (
"context"
"encoding/json"
"fmt"
"net/url"
"reflect"
"regexp"
)
// GetQueryParameters builds and checks the query parameters and returns url.Values
func (g *GetAccountInfoRequest) GetQueryParameters() (url.Values, error) {
var params = map[string]interface{}{}
query := url.Values{}
for _k, _v := range params {
query.Add(_k, fmt.Sprintf("%v", _v))
}
return query, nil
}
// GetParameters builds and checks the parameters and return the result in a map object
func (g *GetAccountInfoRequest) GetParameters() (map[string]interface{}, error) {
var params = map[string]interface{}{}
return params, nil
}
// GetParametersQuery converts the parameters from GetParameters into the url.Values format
func (g *GetAccountInfoRequest) GetParametersQuery() (url.Values, error) {
query := url.Values{}
params, err := g.GetParameters()
if err != nil {
return query, err
}
for _k, _v := range params {
if g.isVarSlice(_v) {
g.iterateSlice(_v, func(it interface{}) {
query.Add(_k+"[]", fmt.Sprintf("%v", it))
})
} else {
query.Add(_k, fmt.Sprintf("%v", _v))
}
}
return query, nil
}
// GetParametersJSON converts the parameters from GetParameters into the JSON format
func (g *GetAccountInfoRequest) GetParametersJSON() ([]byte, error) {
params, err := g.GetParameters()
if err != nil {
return nil, err
}
return json.Marshal(params)
}
// GetSlugParameters builds and checks the slug parameters and return the result in a map object
func (g *GetAccountInfoRequest) GetSlugParameters() (map[string]interface{}, error) {
var params = map[string]interface{}{}
return params, nil
}
func (g *GetAccountInfoRequest) applySlugsToUrl(url string, slugs map[string]string) string {
for _k, _v := range slugs {
needleRE := regexp.MustCompile(":" + _k + "\\b")
url = needleRE.ReplaceAllString(url, _v)
}
return url
}
func (g *GetAccountInfoRequest) iterateSlice(slice interface{}, _f func(it interface{})) {
sliceValue := reflect.ValueOf(slice)
for _i := 0; _i < sliceValue.Len(); _i++ {
it := sliceValue.Index(_i).Interface()
_f(it)
}
}
func (g *GetAccountInfoRequest) isVarSlice(_v interface{}) bool {
rt := reflect.TypeOf(_v)
switch rt.Kind() {
case reflect.Slice:
return true
}
return false
}
func (g *GetAccountInfoRequest) GetSlugsMap() (map[string]string, error) {
slugs := map[string]string{}
params, err := g.GetSlugParameters()
if err != nil {
return slugs, nil
}
for _k, _v := range params {
slugs[_k] = fmt.Sprintf("%v", _v)
}
return slugs, nil
}
func (g *GetAccountInfoRequest) Do(ctx context.Context) (*AccountInfo, error) {
// no body params
var params interface{}
query := url.Values{}
apiURL := "/v5/account/info"
req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params)
if err != nil {
return nil, err
}
response, err := g.client.SendRequest(req)
if err != nil {
return nil, err
}
var apiResponse APIResponse
if err := response.DecodeJSON(&apiResponse); err != nil {
return nil, err
}
var data AccountInfo
if err := json.Unmarshal(apiResponse.Result, &data); err != nil {
return nil, err
}
return &data, nil
}

View File

@ -0,0 +1,51 @@
package bybitapi
import (
"github.com/c9s/requestgen"
"github.com/c9s/bbgo/pkg/fixedpoint"
)
//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Result
//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Result
type InstrumentsInfo struct {
Category Category `json:"category"`
List []struct {
Symbol string `json:"symbol"`
BaseCoin string `json:"baseCoin"`
QuoteCoin string `json:"quoteCoin"`
Innovation string `json:"innovation"`
Status Status `json:"status"`
MarginTrading string `json:"marginTrading"`
LotSizeFilter struct {
BasePrecision fixedpoint.Value `json:"basePrecision"`
QuotePrecision fixedpoint.Value `json:"quotePrecision"`
MinOrderQty fixedpoint.Value `json:"minOrderQty"`
MaxOrderQty fixedpoint.Value `json:"maxOrderQty"`
MinOrderAmt fixedpoint.Value `json:"minOrderAmt"`
MaxOrderAmt fixedpoint.Value `json:"maxOrderAmt"`
} `json:"lotSizeFilter"`
PriceFilter struct {
TickSize fixedpoint.Value `json:"tickSize"`
} `json:"priceFilter"`
} `json:"list"`
}
//go:generate GetRequest -url "/v5/market/instruments-info" -type GetInstrumentsInfoRequest -responseDataType .InstrumentsInfo
type GetInstrumentsInfoRequest struct {
client requestgen.APIClient
category Category `param:"category,query" validValues:"spot"`
symbol *string `param:"symbol,query"`
limit *uint64 `param:"limit,query"`
cursor *string `param:"cursor,query"`
}
func (c *RestClient) NewGetInstrumentsInfoRequest() *GetInstrumentsInfoRequest {
return &GetInstrumentsInfoRequest{
client: c,
category: CategorySpot,
}
}

View File

@ -0,0 +1,202 @@
// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Result -url /v5/market/instruments-info -type GetInstrumentsInfoRequest -responseDataType .InstrumentsInfo"; DO NOT EDIT.
package bybitapi
import (
"context"
"encoding/json"
"fmt"
"net/url"
"reflect"
"regexp"
)
func (g *GetInstrumentsInfoRequest) Category(category Category) *GetInstrumentsInfoRequest {
g.category = category
return g
}
func (g *GetInstrumentsInfoRequest) Symbol(symbol string) *GetInstrumentsInfoRequest {
g.symbol = &symbol
return g
}
func (g *GetInstrumentsInfoRequest) Limit(limit uint64) *GetInstrumentsInfoRequest {
g.limit = &limit
return g
}
func (g *GetInstrumentsInfoRequest) Cursor(cursor string) *GetInstrumentsInfoRequest {
g.cursor = &cursor
return g
}
// GetQueryParameters builds and checks the query parameters and returns url.Values
func (g *GetInstrumentsInfoRequest) GetQueryParameters() (url.Values, error) {
var params = map[string]interface{}{}
// check category field -> json key category
category := g.category
// TEMPLATE check-valid-values
switch category {
case "spot":
params["category"] = category
default:
return nil, fmt.Errorf("category value %v is invalid", category)
}
// END TEMPLATE check-valid-values
// assign parameter of category
params["category"] = category
// check symbol field -> json key symbol
if g.symbol != nil {
symbol := *g.symbol
// assign parameter of symbol
params["symbol"] = symbol
} else {
}
// check limit field -> json key limit
if g.limit != nil {
limit := *g.limit
// assign parameter of limit
params["limit"] = limit
} else {
}
// check cursor field -> json key cursor
if g.cursor != nil {
cursor := *g.cursor
// assign parameter of cursor
params["cursor"] = cursor
} else {
}
query := url.Values{}
for _k, _v := range params {
query.Add(_k, fmt.Sprintf("%v", _v))
}
return query, nil
}
// GetParameters builds and checks the parameters and return the result in a map object
func (g *GetInstrumentsInfoRequest) GetParameters() (map[string]interface{}, error) {
var params = map[string]interface{}{}
return params, nil
}
// GetParametersQuery converts the parameters from GetParameters into the url.Values format
func (g *GetInstrumentsInfoRequest) GetParametersQuery() (url.Values, error) {
query := url.Values{}
params, err := g.GetParameters()
if err != nil {
return query, err
}
for _k, _v := range params {
if g.isVarSlice(_v) {
g.iterateSlice(_v, func(it interface{}) {
query.Add(_k+"[]", fmt.Sprintf("%v", it))
})
} else {
query.Add(_k, fmt.Sprintf("%v", _v))
}
}
return query, nil
}
// GetParametersJSON converts the parameters from GetParameters into the JSON format
func (g *GetInstrumentsInfoRequest) GetParametersJSON() ([]byte, error) {
params, err := g.GetParameters()
if err != nil {
return nil, err
}
return json.Marshal(params)
}
// GetSlugParameters builds and checks the slug parameters and return the result in a map object
func (g *GetInstrumentsInfoRequest) GetSlugParameters() (map[string]interface{}, error) {
var params = map[string]interface{}{}
return params, nil
}
func (g *GetInstrumentsInfoRequest) applySlugsToUrl(url string, slugs map[string]string) string {
for _k, _v := range slugs {
needleRE := regexp.MustCompile(":" + _k + "\\b")
url = needleRE.ReplaceAllString(url, _v)
}
return url
}
func (g *GetInstrumentsInfoRequest) iterateSlice(slice interface{}, _f func(it interface{})) {
sliceValue := reflect.ValueOf(slice)
for _i := 0; _i < sliceValue.Len(); _i++ {
it := sliceValue.Index(_i).Interface()
_f(it)
}
}
func (g *GetInstrumentsInfoRequest) isVarSlice(_v interface{}) bool {
rt := reflect.TypeOf(_v)
switch rt.Kind() {
case reflect.Slice:
return true
}
return false
}
func (g *GetInstrumentsInfoRequest) GetSlugsMap() (map[string]string, error) {
slugs := map[string]string{}
params, err := g.GetSlugParameters()
if err != nil {
return slugs, nil
}
for _k, _v := range params {
slugs[_k] = fmt.Sprintf("%v", _v)
}
return slugs, nil
}
func (g *GetInstrumentsInfoRequest) Do(ctx context.Context) (*InstrumentsInfo, error) {
// no body params
var params interface{}
query, err := g.GetQueryParameters()
if err != nil {
return nil, err
}
apiURL := "/v5/market/instruments-info"
req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params)
if err != nil {
return nil, err
}
response, err := g.client.SendRequest(req)
if err != nil {
return nil, err
}
var apiResponse APIResponse
if err := response.DecodeJSON(&apiResponse); err != nil {
return nil, err
}
var data InstrumentsInfo
if err := json.Unmarshal(apiResponse.Result, &data); err != nil {
return nil, err
}
return &data, nil
}

View File

@ -0,0 +1,14 @@
package bybitapi
type Category string
const (
CategorySpot Category = "spot"
)
type Status string
const (
// StatusTrading is only include the "Trading" status for `spot` category.
StatusTrading Status = "Trading"
)

View File

@ -0,0 +1,45 @@
package bybit
import (
"github.com/sirupsen/logrus"
"github.com/c9s/bbgo/pkg/exchange/bybit/bybitapi"
"github.com/c9s/bbgo/pkg/types"
)
var log = logrus.WithFields(logrus.Fields{
"exchange": "bybit",
})
type Exchange struct {
key, secret string
client *bybitapi.RestClient
}
func New(key, secret string) (*Exchange, error) {
client, err := bybitapi.NewClient()
if err != nil {
return nil, err
}
if len(key) > 0 && len(secret) > 0 {
client.Auth(key, secret)
}
return &Exchange{
key: key,
// pragma: allowlist nextline secret
secret: secret,
client: client,
}, nil
}
func (e *Exchange) Name() types.ExchangeName {
return types.ExchangeBybit
}
// PlatformFeeCurrency returns empty string. The platform does not support "PlatformFeeCurrency" but instead charges
// fees using the native token.
func (e *Exchange) PlatformFeeCurrency() string {
return ""
}

View File

@ -7,6 +7,7 @@ import (
"github.com/c9s/bbgo/pkg/exchange/binance"
"github.com/c9s/bbgo/pkg/exchange/bitget"
"github.com/c9s/bbgo/pkg/exchange/bybit"
"github.com/c9s/bbgo/pkg/exchange/kucoin"
"github.com/c9s/bbgo/pkg/exchange/max"
"github.com/c9s/bbgo/pkg/exchange/okex"
@ -44,6 +45,9 @@ func New(n types.ExchangeName, key, secret, passphrase string) (types.ExchangeMi
case types.ExchangeBitget:
return bitget.New(key, secret, passphrase), nil
case types.ExchangeBybit:
return bybit.New(key, secret)
default:
return nil, fmt.Errorf("unsupported exchange: %v", n)

View File

@ -23,7 +23,7 @@ var queryOrderLimiter = rate.NewLimiter(rate.Every(6*time.Second), 1)
var ErrMissingSequence = errors.New("sequence is missing")
// OKB is the platform currency of OKEx, pre-allocate static string here
// KCS is the platform currency of Kucoin, pre-allocate static string here
const KCS = "KCS"
var log = logrus.WithFields(logrus.Fields{
@ -38,7 +38,6 @@ type Exchange struct {
func New(key, secret, passphrase string) *Exchange {
client := kucoinapi.NewClient()
// for public access mode
if len(key) > 0 && len(secret) > 0 && len(passphrase) > 0 {
client.Auth(key, secret, passphrase)
}

View File

@ -46,6 +46,7 @@ const (
ExchangeKucoin ExchangeName = "kucoin"
ExchangeBitget ExchangeName = "bitget"
ExchangeBacktest ExchangeName = "backtest"
ExchangeBybit ExchangeName = "bybit"
)
var SupportedExchanges = []ExchangeName{
@ -54,6 +55,7 @@ var SupportedExchanges = []ExchangeName{
ExchangeOKEx,
ExchangeKucoin,
ExchangeBitget,
ExchangeBybit,
// note: we are not using "backtest"
}