mirror of
https://github.com/c9s/bbgo.git
synced 2024-11-10 09:11:55 +00:00
Merge pull request #1277 from bailantaotao/edwin/add-kline-api
FEATURE: [bybit] add k line api
This commit is contained in:
commit
6379cab65e
|
@ -5,6 +5,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
@ -162,4 +163,14 @@ func TestClient(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
t.Logf("apiResp: %+v", apiResp)
|
t.Logf("apiResp: %+v", apiResp)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("GetKLinesRequest", func(t *testing.T) {
|
||||||
|
startTime := time.Date(2023, 8, 8, 9, 28, 0, 0, time.UTC)
|
||||||
|
endTime := time.Date(2023, 8, 8, 9, 45, 0, 0, time.UTC)
|
||||||
|
req := client.NewGetKLinesRequest().
|
||||||
|
Symbol("BTCUSDT").Interval("15").StartTime(startTime).EndTime(endTime)
|
||||||
|
apiResp, err := req.Do(ctx)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
t.Logf("apiResp: %+v", apiResp.List)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
107
pkg/exchange/bybit/bybitapi/get_k_lines_request.go
Normal file
107
pkg/exchange/bybit/bybitapi/get_k_lines_request.go
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
package bybitapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/c9s/requestgen"
|
||||||
|
|
||||||
|
"github.com/c9s/bbgo/pkg/fixedpoint"
|
||||||
|
"github.com/c9s/bbgo/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Result
|
||||||
|
//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Result
|
||||||
|
|
||||||
|
type IntervalSign string
|
||||||
|
|
||||||
|
const (
|
||||||
|
IntervalSignDay IntervalSign = "D"
|
||||||
|
IntervalSignWeek IntervalSign = "W"
|
||||||
|
IntervalSignMonth IntervalSign = "M"
|
||||||
|
)
|
||||||
|
|
||||||
|
type KLinesResponse struct {
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
// An string array of individual candle
|
||||||
|
// Sort in reverse by startTime
|
||||||
|
List []KLine `json:"list"`
|
||||||
|
Category Category `json:"category"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KLine struct {
|
||||||
|
// list[0]: startTime, Start time of the candle (ms)
|
||||||
|
StartTime types.MillisecondTimestamp
|
||||||
|
// list[1]: openPrice
|
||||||
|
Open fixedpoint.Value
|
||||||
|
// list[2]: highPrice
|
||||||
|
High fixedpoint.Value
|
||||||
|
// list[3]: lowPrice
|
||||||
|
Low fixedpoint.Value
|
||||||
|
// list[4]: closePrice
|
||||||
|
Close fixedpoint.Value
|
||||||
|
// list[5]: volume, Trade volume. Unit of contract: pieces of contract. Unit of spot: quantity of coins
|
||||||
|
Volume fixedpoint.Value
|
||||||
|
// list[6]: turnover, Turnover. Unit of figure: quantity of quota coin
|
||||||
|
TurnOver fixedpoint.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
const KLinesArrayLen = 7
|
||||||
|
|
||||||
|
func (k *KLine) UnmarshalJSON(data []byte) error {
|
||||||
|
var jsonArr []json.RawMessage
|
||||||
|
err := json.Unmarshal(data, &jsonArr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal jsonRawMessage: %v, err: %w", string(data), err)
|
||||||
|
}
|
||||||
|
if len(jsonArr) != KLinesArrayLen {
|
||||||
|
return fmt.Errorf("unexpected K Lines array length: %d, exp: %d", len(jsonArr), KLinesArrayLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(jsonArr[0], &k.StartTime)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal resp index 0: %v, err: %w", string(jsonArr[0]), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
values := make([]fixedpoint.Value, len(jsonArr)-1)
|
||||||
|
for i, jsonRaw := range jsonArr[1:] {
|
||||||
|
err = json.Unmarshal(jsonRaw, &values[i])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal resp index %d: %v, err: %w", i+1, string(jsonRaw), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
k.Open = values[0]
|
||||||
|
k.High = values[1]
|
||||||
|
k.Low = values[2]
|
||||||
|
k.Close = values[3]
|
||||||
|
k.Volume = values[4]
|
||||||
|
k.TurnOver = values[5]
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:generate GetRequest -url "/v5/market/kline" -type GetKLinesRequest -responseDataType .KLinesResponse
|
||||||
|
type GetKLinesRequest struct {
|
||||||
|
client requestgen.APIClient
|
||||||
|
|
||||||
|
category Category `param:"category,query" validValues:"spot"`
|
||||||
|
symbol string `param:"symbol,query"`
|
||||||
|
// Kline interval.
|
||||||
|
// - 1,3,5,15,30,60,120,240,360,720: minute
|
||||||
|
// - D: day
|
||||||
|
// - M: month
|
||||||
|
// - W: week
|
||||||
|
interval string `param:"interval,query" validValues:"1,3,5,15,30,60,120,240,360,720,D,W,M"`
|
||||||
|
startTime *time.Time `param:"start,query,milliseconds"`
|
||||||
|
endTime *time.Time `param:"end,query,milliseconds"`
|
||||||
|
// Limit for data size per page. [1, 1000]. Default: 200
|
||||||
|
limit *uint64 `param:"limit,query"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RestClient) NewGetKLinesRequest() *GetKLinesRequest {
|
||||||
|
return &GetKLinesRequest{
|
||||||
|
client: c,
|
||||||
|
category: CategorySpot,
|
||||||
|
}
|
||||||
|
}
|
237
pkg/exchange/bybit/bybitapi/get_k_lines_request_requestgen.go
Normal file
237
pkg/exchange/bybit/bybitapi/get_k_lines_request_requestgen.go
Normal file
|
@ -0,0 +1,237 @@
|
||||||
|
// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Result -url /v5/market/kline -type GetKLinesRequest -responseDataType .KLinesResponse"; DO NOT EDIT.
|
||||||
|
|
||||||
|
package bybitapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (g *GetKLinesRequest) Category(category Category) *GetKLinesRequest {
|
||||||
|
g.category = category
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GetKLinesRequest) Symbol(symbol string) *GetKLinesRequest {
|
||||||
|
g.symbol = symbol
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GetKLinesRequest) Interval(interval string) *GetKLinesRequest {
|
||||||
|
g.interval = interval
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GetKLinesRequest) StartTime(startTime time.Time) *GetKLinesRequest {
|
||||||
|
g.startTime = &startTime
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GetKLinesRequest) EndTime(endTime time.Time) *GetKLinesRequest {
|
||||||
|
g.endTime = &endTime
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GetKLinesRequest) Limit(limit uint64) *GetKLinesRequest {
|
||||||
|
g.limit = &limit
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQueryParameters builds and checks the query parameters and returns url.Values
|
||||||
|
func (g *GetKLinesRequest) 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
|
||||||
|
symbol := g.symbol
|
||||||
|
|
||||||
|
// assign parameter of symbol
|
||||||
|
params["symbol"] = symbol
|
||||||
|
// check interval field -> json key interval
|
||||||
|
interval := g.interval
|
||||||
|
|
||||||
|
// TEMPLATE check-valid-values
|
||||||
|
switch interval {
|
||||||
|
case "1", "3", "5", "15", "30", "60", "120", "240", "360", "720", "D", "W", "M":
|
||||||
|
params["interval"] = interval
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("interval value %v is invalid", interval)
|
||||||
|
|
||||||
|
}
|
||||||
|
// END TEMPLATE check-valid-values
|
||||||
|
|
||||||
|
// assign parameter of interval
|
||||||
|
params["interval"] = interval
|
||||||
|
// check startTime field -> json key start
|
||||||
|
if g.startTime != nil {
|
||||||
|
startTime := *g.startTime
|
||||||
|
|
||||||
|
// assign parameter of startTime
|
||||||
|
// convert time.Time to milliseconds time stamp
|
||||||
|
params["start"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10)
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
// check endTime field -> json key end
|
||||||
|
if g.endTime != nil {
|
||||||
|
endTime := *g.endTime
|
||||||
|
|
||||||
|
// assign parameter of endTime
|
||||||
|
// convert time.Time to milliseconds time stamp
|
||||||
|
params["end"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10)
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
// check limit field -> json key limit
|
||||||
|
if g.limit != nil {
|
||||||
|
limit := *g.limit
|
||||||
|
|
||||||
|
// assign parameter of limit
|
||||||
|
params["limit"] = limit
|
||||||
|
} 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 *GetKLinesRequest) 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 *GetKLinesRequest) 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 *GetKLinesRequest) 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 *GetKLinesRequest) GetSlugParameters() (map[string]interface{}, error) {
|
||||||
|
var params = map[string]interface{}{}
|
||||||
|
|
||||||
|
return params, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GetKLinesRequest) 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 *GetKLinesRequest) 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 *GetKLinesRequest) isVarSlice(_v interface{}) bool {
|
||||||
|
rt := reflect.TypeOf(_v)
|
||||||
|
switch rt.Kind() {
|
||||||
|
case reflect.Slice:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GetKLinesRequest) 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 *GetKLinesRequest) Do(ctx context.Context) (*KLinesResponse, error) {
|
||||||
|
|
||||||
|
// no body params
|
||||||
|
var params interface{}
|
||||||
|
query, err := g.GetQueryParameters()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := "/v5/market/kline"
|
||||||
|
|
||||||
|
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 KLinesResponse
|
||||||
|
if err := json.Unmarshal(apiResponse.Result, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &data, nil
|
||||||
|
}
|
175
pkg/exchange/bybit/bybitapi/get_k_lines_request_test.go
Normal file
175
pkg/exchange/bybit/bybitapi/get_k_lines_request_test.go
Normal file
|
@ -0,0 +1,175 @@
|
||||||
|
package bybitapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/c9s/bbgo/pkg/fixedpoint"
|
||||||
|
"github.com/c9s/bbgo/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKLinesResponse_UnmarshalJSON(t *testing.T) {
|
||||||
|
t.Run("succeeds", func(t *testing.T) {
|
||||||
|
data := `{
|
||||||
|
"symbol": "BTCUSDT",
|
||||||
|
"category": "spot",
|
||||||
|
"list": [
|
||||||
|
[
|
||||||
|
"1670608800000",
|
||||||
|
"17071",
|
||||||
|
"17073",
|
||||||
|
"17027",
|
||||||
|
"17055.5",
|
||||||
|
"268611",
|
||||||
|
"15.74462667"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"1670605200000",
|
||||||
|
"17071.5",
|
||||||
|
"17071.5",
|
||||||
|
"17061",
|
||||||
|
"17071",
|
||||||
|
"4177",
|
||||||
|
"0.24469757"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
expRes := &KLinesResponse{
|
||||||
|
Symbol: "BTCUSDT",
|
||||||
|
List: []KLine{
|
||||||
|
{
|
||||||
|
StartTime: types.NewMillisecondTimestampFromInt(1670608800000),
|
||||||
|
Open: fixedpoint.NewFromFloat(17071),
|
||||||
|
High: fixedpoint.NewFromFloat(17073),
|
||||||
|
Low: fixedpoint.NewFromFloat(17027),
|
||||||
|
Close: fixedpoint.NewFromFloat(17055.5),
|
||||||
|
Volume: fixedpoint.NewFromFloat(268611),
|
||||||
|
TurnOver: fixedpoint.NewFromFloat(15.74462667),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
StartTime: types.NewMillisecondTimestampFromInt(1670605200000),
|
||||||
|
Open: fixedpoint.NewFromFloat(17071.5),
|
||||||
|
High: fixedpoint.NewFromFloat(17071.5),
|
||||||
|
Low: fixedpoint.NewFromFloat(17061),
|
||||||
|
Close: fixedpoint.NewFromFloat(17071),
|
||||||
|
Volume: fixedpoint.NewFromFloat(4177),
|
||||||
|
TurnOver: fixedpoint.NewFromFloat(0.24469757),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Category: CategorySpot,
|
||||||
|
}
|
||||||
|
|
||||||
|
kline := &KLinesResponse{}
|
||||||
|
err := json.Unmarshal([]byte(data), kline)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expRes, kline)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unexpected length", func(t *testing.T) {
|
||||||
|
data := `{
|
||||||
|
"symbol": "BTCUSDT",
|
||||||
|
"category": "spot",
|
||||||
|
"list": [
|
||||||
|
[
|
||||||
|
"1670608800000",
|
||||||
|
"17071",
|
||||||
|
"17073",
|
||||||
|
"17027",
|
||||||
|
"17055.5",
|
||||||
|
"268611"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
kline := &KLinesResponse{}
|
||||||
|
err := json.Unmarshal([]byte(data), kline)
|
||||||
|
assert.Equal(t, fmt.Errorf("unexpected K Lines array length: 6, exp: %d", KLinesArrayLen), err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unexpected json array", func(t *testing.T) {
|
||||||
|
klineJson := `{}`
|
||||||
|
|
||||||
|
data := fmt.Sprintf(`{
|
||||||
|
"symbol": "BTCUSDT",
|
||||||
|
"category": "spot",
|
||||||
|
"list": [%s]
|
||||||
|
}`, klineJson)
|
||||||
|
|
||||||
|
var jsonArr []json.RawMessage
|
||||||
|
expErr := json.Unmarshal([]byte(klineJson), &jsonArr)
|
||||||
|
assert.Error(t, expErr)
|
||||||
|
|
||||||
|
kline := &KLinesResponse{}
|
||||||
|
err := json.Unmarshal([]byte(data), kline)
|
||||||
|
assert.Equal(t, fmt.Errorf("failed to unmarshal jsonRawMessage: %v, err: %w", klineJson, expErr), err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unexpected json 0", func(t *testing.T) {
|
||||||
|
klineJson := `
|
||||||
|
[
|
||||||
|
"a",
|
||||||
|
"17071.5",
|
||||||
|
"17071.5",
|
||||||
|
"17061",
|
||||||
|
"17071",
|
||||||
|
"4177",
|
||||||
|
"0.24469757"
|
||||||
|
]
|
||||||
|
`
|
||||||
|
|
||||||
|
data := fmt.Sprintf(`{
|
||||||
|
"symbol": "BTCUSDT",
|
||||||
|
"category": "spot",
|
||||||
|
"list": [%s]
|
||||||
|
}`, klineJson)
|
||||||
|
|
||||||
|
var jsonArr []json.RawMessage
|
||||||
|
err := json.Unmarshal([]byte(klineJson), &jsonArr)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
timestamp := types.MillisecondTimestamp{}
|
||||||
|
expErr := json.Unmarshal(jsonArr[0], ×tamp)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
kline := &KLinesResponse{}
|
||||||
|
err = json.Unmarshal([]byte(data), kline)
|
||||||
|
assert.Equal(t, fmt.Errorf("failed to unmarshal resp index 0: %v, err: %w", string(jsonArr[0]), expErr), err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unexpected json 1", func(t *testing.T) {
|
||||||
|
// TODO: fix panic
|
||||||
|
t.Skip("test will result in a panic, skip it")
|
||||||
|
klineJson := `
|
||||||
|
[
|
||||||
|
"1670608800000",
|
||||||
|
"a",
|
||||||
|
"17071.5",
|
||||||
|
"17061",
|
||||||
|
"17071",
|
||||||
|
"4177",
|
||||||
|
"0.24469757"
|
||||||
|
]
|
||||||
|
`
|
||||||
|
|
||||||
|
data := fmt.Sprintf(`{
|
||||||
|
"symbol": "BTCUSDT",
|
||||||
|
"category": "spot",
|
||||||
|
"list": [%s]
|
||||||
|
}`, klineJson)
|
||||||
|
|
||||||
|
var jsonArr []json.RawMessage
|
||||||
|
err := json.Unmarshal([]byte(klineJson), &jsonArr)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var value fixedpoint.Value
|
||||||
|
expErr := json.Unmarshal(jsonArr[1], &value)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
kline := &KLinesResponse{}
|
||||||
|
err = json.Unmarshal([]byte(data), kline)
|
||||||
|
assert.Equal(t, fmt.Errorf("failed to unmarshal resp index 1: %v, err: %w", string(jsonArr[1]), expErr), err)
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,5 +1,41 @@
|
||||||
package bybitapi
|
package bybitapi
|
||||||
|
|
||||||
|
import "github.com/c9s/bbgo/pkg/types"
|
||||||
|
|
||||||
|
var (
|
||||||
|
SupportedIntervals = map[types.Interval]int{
|
||||||
|
types.Interval1m: 1 * 60,
|
||||||
|
types.Interval3m: 3 * 60,
|
||||||
|
types.Interval5m: 5 * 60,
|
||||||
|
types.Interval15m: 15 * 60,
|
||||||
|
types.Interval30m: 30 * 60,
|
||||||
|
types.Interval1h: 60 * 60,
|
||||||
|
types.Interval2h: 60 * 60 * 2,
|
||||||
|
types.Interval4h: 60 * 60 * 4,
|
||||||
|
types.Interval6h: 60 * 60 * 6,
|
||||||
|
types.Interval12h: 60 * 60 * 12,
|
||||||
|
types.Interval1d: 60 * 60 * 24,
|
||||||
|
types.Interval1w: 60 * 60 * 24 * 7,
|
||||||
|
types.Interval1mo: 60 * 60 * 24 * 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
ToGlobalInterval = map[string]types.Interval{
|
||||||
|
"1": types.Interval1m,
|
||||||
|
"3": types.Interval3m,
|
||||||
|
"5": types.Interval5m,
|
||||||
|
"15": types.Interval15m,
|
||||||
|
"30": types.Interval30m,
|
||||||
|
"60": types.Interval1h,
|
||||||
|
"120": types.Interval2h,
|
||||||
|
"240": types.Interval4h,
|
||||||
|
"360": types.Interval6h,
|
||||||
|
"720": types.Interval12h,
|
||||||
|
"D": types.Interval1d,
|
||||||
|
"W": types.Interval1w,
|
||||||
|
"M": types.Interval1mo,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
type Category string
|
type Category string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
41
pkg/exchange/bybit/bybitapi/types_test.go
Normal file
41
pkg/exchange/bybit/bybitapi/types_test.go
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
package bybitapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/c9s/bbgo/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_SupportedIntervals(t *testing.T) {
|
||||||
|
assert.Equal(t, SupportedIntervals[types.Interval1m], 60)
|
||||||
|
assert.Equal(t, SupportedIntervals[types.Interval3m], 180)
|
||||||
|
assert.Equal(t, SupportedIntervals[types.Interval5m], 300)
|
||||||
|
assert.Equal(t, SupportedIntervals[types.Interval15m], 15*60)
|
||||||
|
assert.Equal(t, SupportedIntervals[types.Interval30m], 30*60)
|
||||||
|
assert.Equal(t, SupportedIntervals[types.Interval1h], 60*60)
|
||||||
|
assert.Equal(t, SupportedIntervals[types.Interval2h], 60*60*2)
|
||||||
|
assert.Equal(t, SupportedIntervals[types.Interval4h], 60*60*4)
|
||||||
|
assert.Equal(t, SupportedIntervals[types.Interval6h], 60*60*6)
|
||||||
|
assert.Equal(t, SupportedIntervals[types.Interval12h], 60*60*12)
|
||||||
|
assert.Equal(t, SupportedIntervals[types.Interval1d], 60*60*24)
|
||||||
|
assert.Equal(t, SupportedIntervals[types.Interval1w], 60*60*24*7)
|
||||||
|
assert.Equal(t, SupportedIntervals[types.Interval1mo], 60*60*24*30)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_ToGlobalInterval(t *testing.T) {
|
||||||
|
assert.Equal(t, ToGlobalInterval["1"], types.Interval1m)
|
||||||
|
assert.Equal(t, ToGlobalInterval["3"], types.Interval3m)
|
||||||
|
assert.Equal(t, ToGlobalInterval["5"], types.Interval5m)
|
||||||
|
assert.Equal(t, ToGlobalInterval["15"], types.Interval15m)
|
||||||
|
assert.Equal(t, ToGlobalInterval["30"], types.Interval30m)
|
||||||
|
assert.Equal(t, ToGlobalInterval["60"], types.Interval1h)
|
||||||
|
assert.Equal(t, ToGlobalInterval["120"], types.Interval2h)
|
||||||
|
assert.Equal(t, ToGlobalInterval["240"], types.Interval4h)
|
||||||
|
assert.Equal(t, ToGlobalInterval["360"], types.Interval6h)
|
||||||
|
assert.Equal(t, ToGlobalInterval["720"], types.Interval12h)
|
||||||
|
assert.Equal(t, ToGlobalInterval["D"], types.Interval1d)
|
||||||
|
assert.Equal(t, ToGlobalInterval["W"], types.Interval1w)
|
||||||
|
assert.Equal(t, ToGlobalInterval["M"], types.Interval1mo)
|
||||||
|
}
|
|
@ -283,3 +283,48 @@ func toGlobalBalanceMap(events []bybitapi.WalletBalances) types.BalanceMap {
|
||||||
}
|
}
|
||||||
return bm
|
return bm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toLocalInterval(interval types.Interval) (string, error) {
|
||||||
|
if _, found := bybitapi.SupportedIntervals[interval]; !found {
|
||||||
|
return "", fmt.Errorf("interval not supported: %s", interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch interval {
|
||||||
|
|
||||||
|
case types.Interval1d:
|
||||||
|
return string(bybitapi.IntervalSignDay), nil
|
||||||
|
|
||||||
|
case types.Interval1w:
|
||||||
|
return string(bybitapi.IntervalSignWeek), nil
|
||||||
|
|
||||||
|
case types.Interval1mo:
|
||||||
|
return string(bybitapi.IntervalSignMonth), nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d", interval.Minutes()), nil
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toGlobalKLines(symbol string, interval types.Interval, klines []bybitapi.KLine) []types.KLine {
|
||||||
|
gKLines := make([]types.KLine, len(klines))
|
||||||
|
for i, kline := range klines {
|
||||||
|
endTime := types.Time(kline.StartTime.Time().Add(interval.Duration()))
|
||||||
|
gKLines[i] = types.KLine{
|
||||||
|
Exchange: types.ExchangeBybit,
|
||||||
|
Symbol: symbol,
|
||||||
|
StartTime: types.Time(kline.StartTime),
|
||||||
|
EndTime: endTime,
|
||||||
|
Interval: interval,
|
||||||
|
Open: kline.Open,
|
||||||
|
Close: kline.Close,
|
||||||
|
High: kline.High,
|
||||||
|
Low: kline.Low,
|
||||||
|
Volume: kline.Volume,
|
||||||
|
QuoteVolume: kline.TurnOver,
|
||||||
|
// Bybit doesn't support close flag in REST API
|
||||||
|
Closed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gKLines
|
||||||
|
}
|
||||||
|
|
|
@ -459,3 +459,88 @@ func Test_toGlobalTrade(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, res, &exp)
|
assert.Equal(t, res, &exp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_toGlobalKLines(t *testing.T) {
|
||||||
|
symbol := "BTCUSDT"
|
||||||
|
interval := types.Interval15m
|
||||||
|
|
||||||
|
resp := bybitapi.KLinesResponse{
|
||||||
|
Symbol: symbol,
|
||||||
|
List: []bybitapi.KLine{
|
||||||
|
/*
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"StartTime": "2023-08-08 17:30:00 +0800 CST",
|
||||||
|
"OpenPrice": 29045.3,
|
||||||
|
"HighPrice": 29228.56,
|
||||||
|
"LowPrice": 29045.3,
|
||||||
|
"ClosePrice": 29228.56,
|
||||||
|
"Volume": 9.265593,
|
||||||
|
"TurnOver": 270447.43520753
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"StartTime": "2023-08-08 17:15:00 +0800 CST",
|
||||||
|
"OpenPrice": 29167.33,
|
||||||
|
"HighPrice": 29229.08,
|
||||||
|
"LowPrice": 29000,
|
||||||
|
"ClosePrice": 29045.3,
|
||||||
|
"Volume": 9.295508,
|
||||||
|
"TurnOver": 270816.87513775
|
||||||
|
}
|
||||||
|
]
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
StartTime: types.NewMillisecondTimestampFromInt(1691486100000),
|
||||||
|
Open: fixedpoint.NewFromFloat(29045.3),
|
||||||
|
High: fixedpoint.NewFromFloat(29228.56),
|
||||||
|
Low: fixedpoint.NewFromFloat(29045.3),
|
||||||
|
Close: fixedpoint.NewFromFloat(29228.56),
|
||||||
|
Volume: fixedpoint.NewFromFloat(9.265593),
|
||||||
|
TurnOver: fixedpoint.NewFromFloat(270447.43520753),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
StartTime: types.NewMillisecondTimestampFromInt(1691487000000),
|
||||||
|
Open: fixedpoint.NewFromFloat(29167.33),
|
||||||
|
High: fixedpoint.NewFromFloat(29229.08),
|
||||||
|
Low: fixedpoint.NewFromFloat(29000),
|
||||||
|
Close: fixedpoint.NewFromFloat(29045.3),
|
||||||
|
Volume: fixedpoint.NewFromFloat(9.295508),
|
||||||
|
TurnOver: fixedpoint.NewFromFloat(270816.87513775),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Category: bybitapi.CategorySpot,
|
||||||
|
}
|
||||||
|
|
||||||
|
expKlines := []types.KLine{
|
||||||
|
{
|
||||||
|
Exchange: types.ExchangeBybit,
|
||||||
|
Symbol: resp.Symbol,
|
||||||
|
StartTime: types.Time(resp.List[0].StartTime.Time()),
|
||||||
|
EndTime: types.Time(resp.List[0].StartTime.Time().Add(interval.Duration())),
|
||||||
|
Interval: interval,
|
||||||
|
Open: fixedpoint.NewFromFloat(29045.3),
|
||||||
|
Close: fixedpoint.NewFromFloat(29228.56),
|
||||||
|
High: fixedpoint.NewFromFloat(29228.56),
|
||||||
|
Low: fixedpoint.NewFromFloat(29045.3),
|
||||||
|
Volume: fixedpoint.NewFromFloat(9.265593),
|
||||||
|
QuoteVolume: fixedpoint.NewFromFloat(270447.43520753),
|
||||||
|
Closed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Exchange: types.ExchangeBybit,
|
||||||
|
Symbol: resp.Symbol,
|
||||||
|
StartTime: types.Time(resp.List[1].StartTime.Time()),
|
||||||
|
EndTime: types.Time(resp.List[1].StartTime.Time().Add(interval.Duration())),
|
||||||
|
Interval: interval,
|
||||||
|
Open: fixedpoint.NewFromFloat(29167.33),
|
||||||
|
Close: fixedpoint.NewFromFloat(29045.3),
|
||||||
|
High: fixedpoint.NewFromFloat(29229.08),
|
||||||
|
Low: fixedpoint.NewFromFloat(29000),
|
||||||
|
Volume: fixedpoint.NewFromFloat(9.295508),
|
||||||
|
QuoteVolume: fixedpoint.NewFromFloat(270816.87513775),
|
||||||
|
Closed: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, toGlobalKLines(symbol, interval, resp.List), expKlines)
|
||||||
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@ import (
|
||||||
const (
|
const (
|
||||||
maxOrderIdLen = 36
|
maxOrderIdLen = 36
|
||||||
defaultQueryLimit = 50
|
defaultQueryLimit = 50
|
||||||
|
defaultKLineLimit = 1000
|
||||||
|
|
||||||
halfYearDuration = 6 * 30 * 24 * time.Hour
|
halfYearDuration = 6 * 30 * 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
@ -38,7 +39,12 @@ var (
|
||||||
"exchange": "bybit",
|
"exchange": "bybit",
|
||||||
})
|
})
|
||||||
|
|
||||||
_ types.ExchangeAccountService = &Exchange{}
|
_ types.ExchangeAccountService = &Exchange{}
|
||||||
|
_ types.ExchangeMarketDataService = &Exchange{}
|
||||||
|
_ types.CustomIntervalProvider = &Exchange{}
|
||||||
|
_ types.ExchangeMinimal = &Exchange{}
|
||||||
|
_ types.ExchangeTradeService = &Exchange{}
|
||||||
|
_ types.Exchange = &Exchange{}
|
||||||
)
|
)
|
||||||
|
|
||||||
type Exchange struct {
|
type Exchange struct {
|
||||||
|
@ -422,6 +428,68 @@ func (e *Exchange) QueryAccountBalances(ctx context.Context) (types.BalanceMap,
|
||||||
|
|
||||||
return toGlobalBalanceMap(accounts.List), nil
|
return toGlobalBalanceMap(accounts.List), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
QueryKLines queries for historical klines (also known as candles/candlesticks). Charts are returned in groups based
|
||||||
|
on the requested interval.
|
||||||
|
|
||||||
|
A k-line's start time is inclusive, but end time is not(startTime + interval - 1 millisecond).
|
||||||
|
e.q. 15m interval k line can be represented as 00:00:00.000 ~ 00:14:59.999
|
||||||
|
*/
|
||||||
|
func (e *Exchange) QueryKLines(ctx context.Context, symbol string, interval types.Interval, options types.KLineQueryOptions) ([]types.KLine, error) {
|
||||||
|
req := e.client.NewGetKLinesRequest().Symbol(symbol)
|
||||||
|
intervalStr, err := toLocalInterval(interval)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Interval(intervalStr)
|
||||||
|
|
||||||
|
limit := uint64(options.Limit)
|
||||||
|
if limit > defaultKLineLimit || limit <= 0 {
|
||||||
|
log.Debugf("limtit is exceeded or zero, update to %d, got: %d", defaultKLineLimit, options.Limit)
|
||||||
|
limit = defaultKLineLimit
|
||||||
|
}
|
||||||
|
req.Limit(limit)
|
||||||
|
|
||||||
|
if options.StartTime != nil {
|
||||||
|
req.StartTime(*options.StartTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.EndTime != nil {
|
||||||
|
req.EndTime(*options.EndTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sharedRateLimiter.Wait(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("query klines rate limiter wait error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := req.Do(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to call k line, err: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Category != bybitapi.CategorySpot {
|
||||||
|
return nil, fmt.Errorf("unexpected category: %s", resp.Category)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Symbol != symbol {
|
||||||
|
return nil, fmt.Errorf("unexpected symbol: %s, exp: %s", resp.Category, symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
kLines := toGlobalKLines(symbol, interval, resp.List)
|
||||||
|
return types.SortKLinesAscending(kLines), nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Exchange) SupportedInterval() map[types.Interval]int {
|
||||||
|
return bybitapi.SupportedIntervals
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Exchange) IsSupportedInterval(interval types.Interval) bool {
|
||||||
|
_, ok := bybitapi.SupportedIntervals[interval]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Exchange) NewStream() types.Stream {
|
func (e *Exchange) NewStream() types.Stream {
|
||||||
return NewStream(e.key, e.secret)
|
return NewStream(e.key, e.secret)
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,7 @@ type Stream struct {
|
||||||
|
|
||||||
bookEventCallbacks []func(e BookEvent)
|
bookEventCallbacks []func(e BookEvent)
|
||||||
walletEventCallbacks []func(e []bybitapi.WalletBalances)
|
walletEventCallbacks []func(e []bybitapi.WalletBalances)
|
||||||
|
kLineEventCallbacks []func(e KLineEvent)
|
||||||
orderEventCallbacks []func(e []OrderEvent)
|
orderEventCallbacks []func(e []OrderEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,6 +53,7 @@ func NewStream(key, secret string) *Stream {
|
||||||
|
|
||||||
stream.OnConnect(stream.handlerConnect)
|
stream.OnConnect(stream.handlerConnect)
|
||||||
stream.OnBookEvent(stream.handleBookEvent)
|
stream.OnBookEvent(stream.handleBookEvent)
|
||||||
|
stream.OnKLineEvent(stream.handleKLineEvent)
|
||||||
stream.OnWalletEvent(stream.handleWalletEvent)
|
stream.OnWalletEvent(stream.handleWalletEvent)
|
||||||
stream.OnOrderEvent(stream.handleOrderEvent)
|
stream.OnOrderEvent(stream.handleOrderEvent)
|
||||||
return stream
|
return stream
|
||||||
|
@ -80,6 +82,9 @@ func (s *Stream) dispatchEvent(event interface{}) {
|
||||||
case []bybitapi.WalletBalances:
|
case []bybitapi.WalletBalances:
|
||||||
s.EmitWalletEvent(e)
|
s.EmitWalletEvent(e)
|
||||||
|
|
||||||
|
case *KLineEvent:
|
||||||
|
s.EmitKLineEvent(*e)
|
||||||
|
|
||||||
case []OrderEvent:
|
case []OrderEvent:
|
||||||
s.EmitOrderEvent(e)
|
s.EmitOrderEvent(e)
|
||||||
}
|
}
|
||||||
|
@ -99,6 +104,7 @@ func (s *Stream) parseWebSocketEvent(in []byte) (interface{}, error) {
|
||||||
|
|
||||||
case e.IsTopic():
|
case e.IsTopic():
|
||||||
switch getTopicType(e.Topic) {
|
switch getTopicType(e.Topic) {
|
||||||
|
|
||||||
case TopicTypeOrderBook:
|
case TopicTypeOrderBook:
|
||||||
var book BookEvent
|
var book BookEvent
|
||||||
err = json.Unmarshal(e.WebSocketTopicEvent.Data, &book)
|
err = json.Unmarshal(e.WebSocketTopicEvent.Data, &book)
|
||||||
|
@ -109,6 +115,20 @@ func (s *Stream) parseWebSocketEvent(in []byte) (interface{}, error) {
|
||||||
book.Type = e.WebSocketTopicEvent.Type
|
book.Type = e.WebSocketTopicEvent.Type
|
||||||
return &book, nil
|
return &book, nil
|
||||||
|
|
||||||
|
case TopicTypeKLine:
|
||||||
|
var kLines []KLine
|
||||||
|
err = json.Unmarshal(e.WebSocketTopicEvent.Data, &kLines)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal data into KLine: %+v, : %w", string(e.WebSocketTopicEvent.Data), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
symbol, err := getSymbolFromTopic(e.Topic)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &KLineEvent{KLines: kLines, Symbol: symbol, Type: e.WebSocketTopicEvent.Type}, nil
|
||||||
|
|
||||||
case TopicTypeWallet:
|
case TopicTypeWallet:
|
||||||
var wallets []bybitapi.WalletBalances
|
var wallets []bybitapi.WalletBalances
|
||||||
return wallets, json.Unmarshal(e.WebSocketTopicEvent.Data, &wallets)
|
return wallets, json.Unmarshal(e.WebSocketTopicEvent.Data, &wallets)
|
||||||
|
@ -165,7 +185,7 @@ func (s *Stream) handlerConnect() {
|
||||||
var topics []string
|
var topics []string
|
||||||
|
|
||||||
for _, subscription := range s.Subscriptions {
|
for _, subscription := range s.Subscriptions {
|
||||||
topic, err := convertSubscription(subscription)
|
topic, err := s.convertSubscription(subscription)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Errorf("subscription convert error")
|
log.WithError(err).Errorf("subscription convert error")
|
||||||
continue
|
continue
|
||||||
|
@ -213,17 +233,27 @@ func (s *Stream) handlerConnect() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertSubscription(s types.Subscription) (string, error) {
|
func (s *Stream) convertSubscription(sub types.Subscription) (string, error) {
|
||||||
switch s.Channel {
|
switch sub.Channel {
|
||||||
|
|
||||||
case types.BookChannel:
|
case types.BookChannel:
|
||||||
depth := types.DepthLevel1
|
depth := types.DepthLevel1
|
||||||
if len(s.Options.Depth) > 0 && s.Options.Depth == types.DepthLevel50 {
|
if len(sub.Options.Depth) > 0 && sub.Options.Depth == types.DepthLevel50 {
|
||||||
depth = types.DepthLevel50
|
depth = types.DepthLevel50
|
||||||
}
|
}
|
||||||
return genTopic(TopicTypeOrderBook, depth, s.Symbol), nil
|
return genTopic(TopicTypeOrderBook, depth, sub.Symbol), nil
|
||||||
|
|
||||||
|
case types.KLineChannel:
|
||||||
|
interval, err := toLocalInterval(sub.Options.Interval)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return genTopic(TopicTypeKLine, interval, sub.Symbol), nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("unsupported stream channel: %s", s.Channel)
|
return "", fmt.Errorf("unsupported stream channel: %s", sub.Channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Stream) handleBookEvent(e BookEvent) {
|
func (s *Stream) handleBookEvent(e BookEvent) {
|
||||||
|
@ -257,3 +287,23 @@ func (s *Stream) handleOrderEvent(events []OrderEvent) {
|
||||||
s.StandardStream.EmitOrderUpdate(*gOrder)
|
s.StandardStream.EmitOrderUpdate(*gOrder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Stream) handleKLineEvent(klineEvent KLineEvent) {
|
||||||
|
if klineEvent.Type != DataTypeSnapshot {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, event := range klineEvent.KLines {
|
||||||
|
kline, err := event.toGlobalKLine(klineEvent.Symbol)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("failed to convert to global k line")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if kline.Closed {
|
||||||
|
s.EmitKLineClosed(kline)
|
||||||
|
} else {
|
||||||
|
s.EmitKLine(kline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -26,6 +26,16 @@ func (s *Stream) EmitWalletEvent(e []bybitapi.WalletBalances) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Stream) OnKLineEvent(cb func(e KLineEvent)) {
|
||||||
|
s.kLineEventCallbacks = append(s.kLineEventCallbacks, cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) EmitKLineEvent(e KLineEvent) {
|
||||||
|
for _, cb := range s.kLineEventCallbacks {
|
||||||
|
cb(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Stream) OnOrderEvent(cb func(e []OrderEvent)) {
|
func (s *Stream) OnOrderEvent(cb func(e []OrderEvent)) {
|
||||||
s.orderEventCallbacks = append(s.orderEventCallbacks, cb)
|
s.orderEventCallbacks = append(s.orderEventCallbacks, cb)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package bybit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
@ -76,6 +77,23 @@ func TestStream(t *testing.T) {
|
||||||
c := make(chan struct{})
|
c := make(chan struct{})
|
||||||
<-c
|
<-c
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("kline test", func(t *testing.T) {
|
||||||
|
s.Subscribe(types.KLineChannel, "BTCUSDT", types.SubscribeOptions{
|
||||||
|
Interval: types.Interval30m,
|
||||||
|
Depth: "",
|
||||||
|
Speed: "",
|
||||||
|
})
|
||||||
|
s.SetPublicOnly()
|
||||||
|
err := s.Connect(context.Background())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
s.OnKLine(func(kline types.KLine) {
|
||||||
|
t.Log(kline)
|
||||||
|
})
|
||||||
|
c := make(chan struct{})
|
||||||
|
<-c
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStream_parseWebSocketEvent(t *testing.T) {
|
func TestStream_parseWebSocketEvent(t *testing.T) {
|
||||||
|
@ -151,6 +169,80 @@ func TestStream_parseWebSocketEvent(t *testing.T) {
|
||||||
}, *book)
|
}, *book)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("TopicTypeKLine with snapshot", func(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"topic": "kline.5.BTCUSDT",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"start": 1672324800000,
|
||||||
|
"end": 1672325099999,
|
||||||
|
"interval": "5",
|
||||||
|
"open": "16649.5",
|
||||||
|
"close": "16677",
|
||||||
|
"high": "16677",
|
||||||
|
"low": "16608",
|
||||||
|
"volume": "2.081",
|
||||||
|
"turnover": "34666.4005",
|
||||||
|
"confirm": false,
|
||||||
|
"timestamp": 1672324988882
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ts": 1672324988882,
|
||||||
|
"type": "snapshot"
|
||||||
|
}`
|
||||||
|
|
||||||
|
res, err := s.parseWebSocketEvent([]byte(input))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
book, ok := res.(*KLineEvent)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, KLineEvent{
|
||||||
|
Symbol: "BTCUSDT",
|
||||||
|
Type: DataTypeSnapshot,
|
||||||
|
KLines: []KLine{
|
||||||
|
{
|
||||||
|
StartTime: types.NewMillisecondTimestampFromInt(1672324800000),
|
||||||
|
EndTime: types.NewMillisecondTimestampFromInt(1672325099999),
|
||||||
|
Interval: "5",
|
||||||
|
OpenPrice: fixedpoint.NewFromFloat(16649.5),
|
||||||
|
ClosePrice: fixedpoint.NewFromFloat(16677),
|
||||||
|
HighPrice: fixedpoint.NewFromFloat(16677),
|
||||||
|
LowPrice: fixedpoint.NewFromFloat(16608),
|
||||||
|
Volume: fixedpoint.NewFromFloat(2.081),
|
||||||
|
Turnover: fixedpoint.NewFromFloat(34666.4005),
|
||||||
|
Confirm: false,
|
||||||
|
Timestamp: types.NewMillisecondTimestampFromInt(1672324988882),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, *book)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TopicTypeKLine with invalid topic", func(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"topic": "kline.5",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"start": 1672324800000,
|
||||||
|
"end": 1672325099999,
|
||||||
|
"interval": "5",
|
||||||
|
"open": "16649.5",
|
||||||
|
"close": "16677",
|
||||||
|
"high": "16677",
|
||||||
|
"low": "16608",
|
||||||
|
"volume": "2.081",
|
||||||
|
"turnover": "34666.4005",
|
||||||
|
"confirm": false,
|
||||||
|
"timestamp": 1672324988882
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ts": 1672324988882,
|
||||||
|
"type": "snapshot"
|
||||||
|
}`
|
||||||
|
|
||||||
|
res, err := s.parseWebSocketEvent([]byte(input))
|
||||||
|
assert.Equal(t, errors.New("unexpected topic: kline.5"), err)
|
||||||
|
assert.Nil(t, res)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("Parse fails", func(t *testing.T) {
|
t.Run("Parse fails", func(t *testing.T) {
|
||||||
input := `{
|
input := `{
|
||||||
"topic":"orderbook.50.BTCUSDT",
|
"topic":"orderbook.50.BTCUSDT",
|
||||||
|
@ -168,8 +260,9 @@ func TestStream_parseWebSocketEvent(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_convertSubscription(t *testing.T) {
|
func Test_convertSubscription(t *testing.T) {
|
||||||
|
s := Stream{}
|
||||||
t.Run("BookChannel.DepthLevel1", func(t *testing.T) {
|
t.Run("BookChannel.DepthLevel1", func(t *testing.T) {
|
||||||
res, err := convertSubscription(types.Subscription{
|
res, err := s.convertSubscription(types.Subscription{
|
||||||
Symbol: "BTCUSDT",
|
Symbol: "BTCUSDT",
|
||||||
Channel: types.BookChannel,
|
Channel: types.BookChannel,
|
||||||
Options: types.SubscribeOptions{
|
Options: types.SubscribeOptions{
|
||||||
|
@ -180,7 +273,7 @@ func Test_convertSubscription(t *testing.T) {
|
||||||
assert.Equal(t, genTopic(TopicTypeOrderBook, types.DepthLevel1, "BTCUSDT"), res)
|
assert.Equal(t, genTopic(TopicTypeOrderBook, types.DepthLevel1, "BTCUSDT"), res)
|
||||||
})
|
})
|
||||||
t.Run("BookChannel. with default depth", func(t *testing.T) {
|
t.Run("BookChannel. with default depth", func(t *testing.T) {
|
||||||
res, err := convertSubscription(types.Subscription{
|
res, err := s.convertSubscription(types.Subscription{
|
||||||
Symbol: "BTCUSDT",
|
Symbol: "BTCUSDT",
|
||||||
Channel: types.BookChannel,
|
Channel: types.BookChannel,
|
||||||
})
|
})
|
||||||
|
@ -188,7 +281,7 @@ func Test_convertSubscription(t *testing.T) {
|
||||||
assert.Equal(t, genTopic(TopicTypeOrderBook, types.DepthLevel1, "BTCUSDT"), res)
|
assert.Equal(t, genTopic(TopicTypeOrderBook, types.DepthLevel1, "BTCUSDT"), res)
|
||||||
})
|
})
|
||||||
t.Run("BookChannel.DepthLevel50", func(t *testing.T) {
|
t.Run("BookChannel.DepthLevel50", func(t *testing.T) {
|
||||||
res, err := convertSubscription(types.Subscription{
|
res, err := s.convertSubscription(types.Subscription{
|
||||||
Symbol: "BTCUSDT",
|
Symbol: "BTCUSDT",
|
||||||
Channel: types.BookChannel,
|
Channel: types.BookChannel,
|
||||||
Options: types.SubscribeOptions{
|
Options: types.SubscribeOptions{
|
||||||
|
@ -199,7 +292,7 @@ func Test_convertSubscription(t *testing.T) {
|
||||||
assert.Equal(t, genTopic(TopicTypeOrderBook, types.DepthLevel50, "BTCUSDT"), res)
|
assert.Equal(t, genTopic(TopicTypeOrderBook, types.DepthLevel50, "BTCUSDT"), res)
|
||||||
})
|
})
|
||||||
t.Run("BookChannel. not support depth, use default level 1", func(t *testing.T) {
|
t.Run("BookChannel. not support depth, use default level 1", func(t *testing.T) {
|
||||||
res, err := convertSubscription(types.Subscription{
|
res, err := s.convertSubscription(types.Subscription{
|
||||||
Symbol: "BTCUSDT",
|
Symbol: "BTCUSDT",
|
||||||
Channel: types.BookChannel,
|
Channel: types.BookChannel,
|
||||||
Options: types.SubscribeOptions{
|
Options: types.SubscribeOptions{
|
||||||
|
@ -211,7 +304,7 @@ func Test_convertSubscription(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("unsupported channel", func(t *testing.T) {
|
t.Run("unsupported channel", func(t *testing.T) {
|
||||||
res, err := convertSubscription(types.Subscription{
|
res, err := s.convertSubscription(types.Subscription{
|
||||||
Symbol: "BTCUSDT",
|
Symbol: "BTCUSDT",
|
||||||
Channel: "unsupported",
|
Channel: "unsupported",
|
||||||
})
|
})
|
||||||
|
|
|
@ -82,6 +82,7 @@ const (
|
||||||
TopicTypeOrderBook TopicType = "orderbook"
|
TopicTypeOrderBook TopicType = "orderbook"
|
||||||
TopicTypeWallet TopicType = "wallet"
|
TopicTypeWallet TopicType = "wallet"
|
||||||
TopicTypeOrder TopicType = "order"
|
TopicTypeOrder TopicType = "order"
|
||||||
|
TopicTypeKLine TopicType = "kline"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DataType string
|
type DataType string
|
||||||
|
@ -143,8 +144,69 @@ func getTopicType(topic string) TopicType {
|
||||||
return TopicType(slice[0])
|
return TopicType(slice[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getSymbolFromTopic(topic string) (string, error) {
|
||||||
|
slice := strings.Split(topic, topicSeparator)
|
||||||
|
if len(slice) != 3 {
|
||||||
|
return "", fmt.Errorf("unexpected topic: %s", topic)
|
||||||
|
}
|
||||||
|
return slice[2], nil
|
||||||
|
}
|
||||||
|
|
||||||
type OrderEvent struct {
|
type OrderEvent struct {
|
||||||
bybitapi.Order
|
bybitapi.Order
|
||||||
|
|
||||||
Category bybitapi.Category `json:"category"`
|
Category bybitapi.Category `json:"category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type KLineEvent struct {
|
||||||
|
KLines []KLine
|
||||||
|
|
||||||
|
// internal use
|
||||||
|
// Type can be one of snapshot or delta. Copied from WebSocketTopicEvent.Type
|
||||||
|
Type DataType
|
||||||
|
// Symbol. Copied from WebSocketTopicEvent.Topic
|
||||||
|
Symbol string
|
||||||
|
}
|
||||||
|
|
||||||
|
type KLine struct {
|
||||||
|
// The start timestamp (ms)
|
||||||
|
StartTime types.MillisecondTimestamp `json:"start"`
|
||||||
|
// The end timestamp (ms)
|
||||||
|
EndTime types.MillisecondTimestamp `json:"end"`
|
||||||
|
// Kline interval
|
||||||
|
Interval string `json:"interval"`
|
||||||
|
OpenPrice fixedpoint.Value `json:"open"`
|
||||||
|
ClosePrice fixedpoint.Value `json:"close"`
|
||||||
|
HighPrice fixedpoint.Value `json:"high"`
|
||||||
|
LowPrice fixedpoint.Value `json:"low"`
|
||||||
|
// Trade volume
|
||||||
|
Volume fixedpoint.Value `json:"volume"`
|
||||||
|
// Turnover. Unit of figure: quantity of quota coin
|
||||||
|
Turnover fixedpoint.Value `json:"turnover"`
|
||||||
|
// Weather the tick is ended or not
|
||||||
|
Confirm bool `json:"confirm"`
|
||||||
|
// The timestamp (ms) of the last matched order in the candle
|
||||||
|
Timestamp types.MillisecondTimestamp `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *KLine) toGlobalKLine(symbol string) (types.KLine, error) {
|
||||||
|
interval, found := bybitapi.ToGlobalInterval[k.Interval]
|
||||||
|
if !found {
|
||||||
|
return types.KLine{}, fmt.Errorf("unexpected k line interval: %+v", k)
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.KLine{
|
||||||
|
Exchange: types.ExchangeBybit,
|
||||||
|
Symbol: symbol,
|
||||||
|
StartTime: types.Time(k.StartTime.Time()),
|
||||||
|
EndTime: types.Time(k.EndTime.Time()),
|
||||||
|
Interval: interval,
|
||||||
|
Open: k.OpenPrice,
|
||||||
|
Close: k.ClosePrice,
|
||||||
|
High: k.HighPrice,
|
||||||
|
Low: k.LowPrice,
|
||||||
|
Volume: k.Volume,
|
||||||
|
QuoteVolume: k.Turnover,
|
||||||
|
Closed: k.Confirm,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
|
@ -386,3 +386,74 @@ func Test_getTopicName(t *testing.T) {
|
||||||
exp := TopicTypeOrderBook
|
exp := TopicTypeOrderBook
|
||||||
assert.Equal(t, exp, getTopicType("orderbook.50.BTCUSDT"))
|
assert.Equal(t, exp, getTopicType("orderbook.50.BTCUSDT"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_getSymbolFromTopic(t *testing.T) {
|
||||||
|
t.Run("succeeds", func(t *testing.T) {
|
||||||
|
exp := "BTCUSDT"
|
||||||
|
res, err := getSymbolFromTopic("kline.1.BTCUSDT")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, exp, res)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unexpected topic", func(t *testing.T) {
|
||||||
|
res, err := getSymbolFromTopic("kline.1")
|
||||||
|
assert.Empty(t, res)
|
||||||
|
assert.Equal(t, err, fmt.Errorf("unexpected topic: kline.1"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKLine_toGlobalKLine(t *testing.T) {
|
||||||
|
t.Run("succeeds", func(t *testing.T) {
|
||||||
|
k := KLine{
|
||||||
|
StartTime: types.NewMillisecondTimestampFromInt(1691486100000),
|
||||||
|
EndTime: types.NewMillisecondTimestampFromInt(1691487000000),
|
||||||
|
Interval: "1",
|
||||||
|
OpenPrice: fixedpoint.NewFromFloat(29045.3),
|
||||||
|
ClosePrice: fixedpoint.NewFromFloat(29228.56),
|
||||||
|
HighPrice: fixedpoint.NewFromFloat(29228.56),
|
||||||
|
LowPrice: fixedpoint.NewFromFloat(29045.3),
|
||||||
|
Volume: fixedpoint.NewFromFloat(9.265593),
|
||||||
|
Turnover: fixedpoint.NewFromFloat(270447.43520753),
|
||||||
|
Confirm: false,
|
||||||
|
Timestamp: types.NewMillisecondTimestampFromInt(1691486100000),
|
||||||
|
}
|
||||||
|
|
||||||
|
gKline, err := k.toGlobalKLine("BTCUSDT")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, types.KLine{
|
||||||
|
Exchange: types.ExchangeBybit,
|
||||||
|
Symbol: "BTCUSDT",
|
||||||
|
StartTime: types.Time(k.StartTime.Time()),
|
||||||
|
EndTime: types.Time(k.EndTime.Time()),
|
||||||
|
Interval: types.Interval1m,
|
||||||
|
Open: fixedpoint.NewFromFloat(29045.3),
|
||||||
|
Close: fixedpoint.NewFromFloat(29228.56),
|
||||||
|
High: fixedpoint.NewFromFloat(29228.56),
|
||||||
|
Low: fixedpoint.NewFromFloat(29045.3),
|
||||||
|
Volume: fixedpoint.NewFromFloat(9.265593),
|
||||||
|
QuoteVolume: fixedpoint.NewFromFloat(270447.43520753),
|
||||||
|
Closed: false,
|
||||||
|
}, gKline)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("interval not supported", func(t *testing.T) {
|
||||||
|
k := KLine{
|
||||||
|
StartTime: types.NewMillisecondTimestampFromInt(1691486100000),
|
||||||
|
EndTime: types.NewMillisecondTimestampFromInt(1691487000000),
|
||||||
|
Interval: "112",
|
||||||
|
OpenPrice: fixedpoint.NewFromFloat(29045.3),
|
||||||
|
ClosePrice: fixedpoint.NewFromFloat(29228.56),
|
||||||
|
HighPrice: fixedpoint.NewFromFloat(29228.56),
|
||||||
|
LowPrice: fixedpoint.NewFromFloat(29045.3),
|
||||||
|
Volume: fixedpoint.NewFromFloat(9.265593),
|
||||||
|
Turnover: fixedpoint.NewFromFloat(270447.43520753),
|
||||||
|
Confirm: false,
|
||||||
|
Timestamp: types.NewMillisecondTimestampFromInt(1691486100000),
|
||||||
|
}
|
||||||
|
|
||||||
|
gKline, err := k.toGlobalKLine("BTCUSDT")
|
||||||
|
assert.Equal(t, fmt.Errorf("unexpected k line interval: %+v", &k), err)
|
||||||
|
assert.Equal(t, gKline, types.KLine{})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user