diff --git a/bybit/rest/api.go b/bybit/rest/api.go new file mode 100644 index 0000000..3f97029 --- /dev/null +++ b/bybit/rest/api.go @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2023, LinstoHu + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rest + +import ( + "context" + "net/http" + + "github.com/linstohu/nexapi/bybit/rest/types" + "github.com/linstohu/nexapi/utils" +) + +// GetUnifiedAccountBalance(Unified Account API): UNIFIED (trade spot/linear/options) +// Note: the Unified account supports inverse trading. However, the margin used is from the inverse derivatives wallet instead of the unified wallet. +// doc: https://bybit-exchange.github.io/docs/v5/intro#current-api-coverage +func (bb *BybitClient) GetUnifiedAccountBalance() (*types.GetWalletBalanceResp, error) { + req := utils.HTTPRequest{ + BaseURL: bb.cli.GetBaseURL(), + Path: "/v5/account/wallet-balance", + Method: http.MethodGet, + Query: types.GetWalletBalanceParam{ + AccountType: types.UNIFIED, + }, + } + + headers, err := bb.cli.GenAuthHeaders(req) + if err != nil { + return nil, err + } + req.Headers = headers + + resp, err := bb.cli.SendHTTPRequest(context.TODO(), req) + if err != nil { + return nil, err + } + + var body types.GetWalletBalanceAPIResp + + if err := resp.ReadJsonBody(&body); err != nil { + return nil, err + } + + data := &types.GetWalletBalanceResp{ + Http: resp, + Body: &body, + } + + return data, nil +} + +// GetContractAccountBalance(Unified Account API): CONTRACT(trade inverse) +func (bb *BybitClient) GetUnifiedAccountContractBalance() (*types.GetWalletBalanceResp, error) { + req := utils.HTTPRequest{ + BaseURL: bb.cli.GetBaseURL(), + Path: "/v5/account/wallet-balance", + Method: http.MethodGet, + Query: types.GetWalletBalanceParam{ + AccountType: types.CONTRACT, + }, + } + + headers, err := bb.cli.GenAuthHeaders(req) + if err != nil { + return nil, err + } + req.Headers = headers + + resp, err := bb.cli.SendHTTPRequest(context.TODO(), req) + if err != nil { + return nil, err + } + + var body types.GetWalletBalanceAPIResp + + if err := resp.ReadJsonBody(&body); err != nil { + return nil, err + } + + data := &types.GetWalletBalanceResp{ + Http: resp, + Body: &body, + } + + return data, nil +} + +// GetFundAccountBalance get Funding wallet balance +func (bb *BybitClient) GetFundAccountBalance() (*types.GetAccountBalanceResp, error) { + req := utils.HTTPRequest{ + BaseURL: bb.cli.GetBaseURL(), + Path: "/v5/asset/transfer/query-account-coins-balance", + Method: http.MethodGet, + Query: types.GetAccountBalanceParam{ + AccountType: types.FUND, + }, + } + + headers, err := bb.cli.GenAuthHeaders(req) + if err != nil { + return nil, err + } + req.Headers = headers + + resp, err := bb.cli.SendHTTPRequest(context.TODO(), req) + if err != nil { + return nil, err + } + + var body types.GetAccountBalanceAPIResp + + if err := resp.ReadJsonBody(&body); err != nil { + return nil, err + } + + data := &types.GetAccountBalanceResp{ + Http: resp, + Body: &body, + } + + return data, nil +} diff --git a/bybit/rest/client.go b/bybit/rest/client.go new file mode 100644 index 0000000..5ecc5e6 --- /dev/null +++ b/bybit/rest/client.go @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023, LinstoHu + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rest + +import ( + "github.com/go-playground/validator" + bybitutils "github.com/linstohu/nexapi/bybit/utils" +) + +type BybitClient struct { + cli *bybitutils.BybitClient + + // validate struct fields + validate *validator.Validate +} + +func NewBybitClient(cfg *bybitutils.BybitClientCfg) (*BybitClient, error) { + validator := validator.New() + + err := validator.Struct(cfg) + if err != nil { + return nil, err + } + + cli, err := bybitutils.NewBybitClient(cfg) + if err != nil { + return nil, err + } + + return &BybitClient{ + cli: cli, + validate: validator, + }, nil +} diff --git a/bybit/rest/client_test.go b/bybit/rest/client_test.go new file mode 100644 index 0000000..fa2f541 --- /dev/null +++ b/bybit/rest/client_test.go @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2023, LinstoHu + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rest + +import ( + "fmt" + "testing" + + "github.com/linstohu/nexapi/bybit/utils" + "github.com/stretchr/testify/assert" +) + +func testNewClient(t *testing.T) *BybitClient { + cli, err := NewBybitClient(&utils.BybitClientCfg{ + Debug: true, + BaseURL: utils.TestBaseURL, + // Key: os.Getenv("BYBIT_KEY"), + // Secret: os.Getenv("BYBIT_SECRET"), + + Key: "Np7OFL7psdFayQgHwT", + Secret: "sdM6e2r1qWpfVm6s77Thkkk1oUzxH49gnKAf", + }) + + if err != nil { + t.Fatalf("Could not create bybit client, %s", err) + } + + return cli +} + +func TestGetUnifiedAccountBalance(t *testing.T) { + cli := testNewClient(t) + + resp, err := cli.GetUnifiedAccountBalance() + assert.Nil(t, err) + + for _, v := range resp.Body.Result.List { + fmt.Printf("%+v\n", v) + } +} + +func TestGetUnifiedAccountContractBalance(t *testing.T) { + cli := testNewClient(t) + + resp, err := cli.GetUnifiedAccountContractBalance() + assert.Nil(t, err) + + for _, v := range resp.Body.Result.List { + fmt.Printf("%+v\n", v) + } +} + +func TestGetFundAccountBalance(t *testing.T) { + cli := testNewClient(t) + + resp, err := cli.GetFundAccountBalance() + assert.Nil(t, err) + + for _, v := range resp.Body.Result.Balance { + fmt.Printf("%+v\n", v) + } +} diff --git a/bybit/rest/types/account.go b/bybit/rest/types/account.go new file mode 100644 index 0000000..420f303 --- /dev/null +++ b/bybit/rest/types/account.go @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2023, LinstoHu + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package types + +import "github.com/linstohu/nexapi/utils" + +type GetWalletBalanceParam struct { + AccountType AccountType `url:"accountType" validate:"required,oneof=UNIFIED SPOT OPTION CONTRACT FUND"` // +} + +// AccountType +// doc: https://bybit-exchange.github.io/docs/v5/account/wallet-balance +// - Unified account: UNIFIED (trade spot/linear/options), CONTRACT(trade inverse) +// - Classic account: CONTRACT, SPOT +// +// AccountType Enum doc: https://bybit-exchange.github.io/docs/v5/enum#accounttype +type AccountType = string + +const ( + UNIFIED = "UNIFIED" + SPOT = "SPOT" + OPTION = "OPTION" + CONTRACT = "CONTRACT" + FUND = "FUND" +) + +type GetWalletBalanceResp struct { + Http *utils.ApiResponse + Body *GetWalletBalanceAPIResp +} + +type GetWalletBalanceAPIResp struct { + Response `json:",inline"` + Result WalletBalanceResult `json:"result"` +} + +type WalletBalanceResult struct { + List []WalletBalanceList `json:"list"` +} + +type WalletBalanceList struct { + TotalEquity string `json:"totalEquity"` + AccountIMRate string `json:"accountIMRate"` + TotalMarginBalance string `json:"totalMarginBalance"` + TotalInitialMargin string `json:"totalInitialMargin"` + AccountType string `json:"accountType"` + TotalAvailableBalance string `json:"totalAvailableBalance"` + AccountMMRate string `json:"accountMMRate"` + TotalPerpUPL string `json:"totalPerpUPL"` + TotalWalletBalance string `json:"totalWalletBalance"` + TotalMaintenanceMargin string `json:"totalMaintenanceMargin"` + Coin []WalletBalanceCoin `json:"coin"` +} + +type WalletBalanceCoin struct { + AvailableToBorrow string `json:"availableToBorrow"` + AccruedInterest string `json:"accruedInterest"` + AvailableToWithdraw string `json:"availableToWithdraw"` + TotalOrderIM string `json:"totalOrderIM"` + Equity string `json:"equity"` + TotalPositionMM string `json:"totalPositionMM"` + UsdValue string `json:"usdValue"` + UnrealisedPnl string `json:"unrealisedPnl"` + BorrowAmount string `json:"borrowAmount"` + TotalPositionIM string `json:"totalPositionIM"` + WalletBalance string `json:"walletBalance"` + CumRealisedPnl string `json:"cumRealisedPnl"` + Coin string `json:"coin"` +} + +type GetAccountBalanceParam struct { + AccountType AccountType `url:"accountType" validate:"required,oneof=UNIFIED SPOT OPTION CONTRACT FUND"` + WithBonus string `url:"accountType,omitempty"` +} + +type GetAccountBalanceResp struct { + Http *utils.ApiResponse + Body *GetAccountBalanceAPIResp +} + +type GetAccountBalanceAPIResp struct { + Response `json:",inline"` + Result GetAccountBalanceResult `json:"result"` +} + +type GetAccountBalanceResult struct { + MemberID string `json:"memberId"` + AccountType string `json:"accountType"` + Balance []AccountBalance `json:"balance"` +} + +type AccountBalance struct { + Coin string `json:"coin"` + TransferBalance string `json:"transferBalance"` + WalletBalance string `json:"walletBalance"` + Bonus string `json:"bonus"` +} diff --git a/bybit/rest/types/api.go b/bybit/rest/types/api.go new file mode 100644 index 0000000..7b783eb --- /dev/null +++ b/bybit/rest/types/api.go @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023, LinstoHu + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package types + +type Response struct { + RetCode int `json:"retCode"` + RetMsg string `json:"retMsg"` + RetExtInfo interface{} `json:"retExtInfo"` + Time int64 `json:"time"` +} diff --git a/bybit/utils/client.go b/bybit/utils/client.go new file mode 100644 index 0000000..35b7ec9 --- /dev/null +++ b/bybit/utils/client.go @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2023, LinstoHu + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package utils + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httputil" + "net/url" + "strconv" + "time" + + "github.com/go-playground/validator" + goquery "github.com/google/go-querystring/query" + "github.com/linstohu/nexapi/utils" +) + +type BybitClient struct { + // debug mode + debug bool + // logger + logger *slog.Logger + + baseURL string + key, secret string + recvWindow int +} + +type BybitClientCfg struct { + Debug bool + // Logger + Logger *slog.Logger + + BaseURL string `validate:"required"` + Key string + Secret string + RecvWindow int +} + +func NewBybitClient(cfg *BybitClientCfg) (*BybitClient, error) { + err := validator.New().Struct(cfg) + if err != nil { + return nil, err + } + + cli := BybitClient{ + debug: cfg.Debug, + logger: cfg.Logger, + baseURL: cfg.BaseURL, + key: cfg.Key, + secret: cfg.Secret, + } + + if cfg.RecvWindow == 0 { + cli.recvWindow = 5000 + } + + if cli.logger == nil { + cli.logger = slog.Default() + } + + return &cli, nil +} + +func (bb *BybitClient) GetDebug() bool { + return bb.debug +} + +func (bb *BybitClient) GetBaseURL() string { + return bb.baseURL +} + +func (bb *BybitClient) GetKey() string { + return bb.key +} + +func (bb *BybitClient) GetSecret() string { + return bb.secret +} + +func (bb *BybitClient) GetRecvWindow() int { + return bb.recvWindow +} + +func (bb *BybitClient) GenAuthHeaders(req utils.HTTPRequest) (map[string]string, error) { + if bb.GetKey() == "" || bb.GetSecret() == "" { + return nil, fmt.Errorf("key and secret needed when auth headers") + } + + headers := map[string]string{ + "Content-Type": "application/json", + } + + strBody := "" + if req.Body != nil { + jsonBody, err := json.Marshal(req.Body) + if err != nil { + return nil, err + } + strBody = string(jsonBody) + } + + strQuery := "" + if req.Query != nil { + // attention: do not forget url tag after struct's fields + q, err := goquery.Values(req.Query) + if err != nil { + return nil, err + } + strQuery = q.Encode() + } + + timestamp := time.Now().UnixMilli() + signString := fmt.Sprintf("%d%s%s%s", timestamp, bb.GetKey(), strBody, strQuery) + + h := hmac.New(sha256.New, []byte(bb.GetSecret())) + h.Write([]byte(signString)) + signature := hex.EncodeToString(h.Sum(nil)) + + headers["X-BAPI-API-KEY"] = bb.GetKey() + headers["X-BAPI-SIGN"] = signature + headers["X-BAPI-TIMESTAMP"] = strconv.FormatInt(timestamp, 10) + + return headers, nil +} + +func (bb *BybitClient) SendHTTPRequest(ctx context.Context, req utils.HTTPRequest) (*utils.ApiResponse, error) { + client := http.Client{} + + var body io.Reader + if req.Body != nil { + jsonBody, err := json.Marshal(req.Body) + if err != nil { + return nil, err + } + body = bytes.NewReader(jsonBody) + } + + url, err := url.Parse(req.BaseURL + req.Path) + if err != nil { + return nil, err + } + + if req.Query != nil { + q, err := goquery.Values(req.Query) + if err != nil { + return nil, err + } + url.RawQuery = q.Encode() + } + + request, err := http.NewRequestWithContext(ctx, req.Method, url.String(), body) + if err != nil { + return nil, err + } + + for k, v := range req.Headers { + request.Header.Set(k, v) + } + + if bb.GetDebug() { + dump, err := httputil.DumpRequestOut(request, true) + if err != nil { + return nil, err + } + + bb.logger.Info(fmt.Sprintf("\n%s\n", string(dump))) + } + + resp, err := client.Do(request) + if err != nil { + return nil, err + } + + if bb.GetDebug() { + dump, err := httputil.DumpResponse(resp, true) + if err != nil { + return nil, err + } + bb.logger.Info(fmt.Sprintf("\n%s\n", string(dump))) + } + + return utils.NewApiResponse(&req, resp), nil +} diff --git a/bybit/utils/vars.go b/bybit/utils/vars.go new file mode 100644 index 0000000..baf36a7 --- /dev/null +++ b/bybit/utils/vars.go @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023, LinstoHu + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package utils + +var ( + BaseURL = "https://api.bybit.com" + TestBaseURL = "https://api-testnet.bybit.com" +) diff --git a/bybit/websocket/client.go b/bybit/websocket/client.go new file mode 100644 index 0000000..d783d40 --- /dev/null +++ b/bybit/websocket/client.go @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2023, LinstoHu + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package websocket