diff --git a/pkg/exchange/bybit/bybitapi/client_test.go b/pkg/exchange/bybit/bybitapi/client_test.go index cf6a61215..8156dd2c5 100644 --- a/pkg/exchange/bybit/bybitapi/client_test.go +++ b/pkg/exchange/bybit/bybitapi/client_test.go @@ -5,6 +5,7 @@ import ( "os" "strconv" "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -162,4 +163,14 @@ func TestClient(t *testing.T) { assert.NoError(t, err) 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) + }) } diff --git a/pkg/exchange/bybit/bybitapi/get_k_lines_request.go b/pkg/exchange/bybit/bybitapi/get_k_lines_request.go new file mode 100644 index 000000000..de04b72d3 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_k_lines_request.go @@ -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, + } +} diff --git a/pkg/exchange/bybit/bybitapi/get_k_lines_request_requestgen.go b/pkg/exchange/bybit/bybitapi/get_k_lines_request_requestgen.go new file mode 100644 index 000000000..d6b3d06c3 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_k_lines_request_requestgen.go @@ -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 +} diff --git a/pkg/exchange/bybit/bybitapi/get_k_lines_request_test.go b/pkg/exchange/bybit/bybitapi/get_k_lines_request_test.go new file mode 100644 index 000000000..05a739c58 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_k_lines_request_test.go @@ -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) + }) +} diff --git a/pkg/exchange/bybit/bybitapi/types.go b/pkg/exchange/bybit/bybitapi/types.go index d54e54d63..509b51ded 100644 --- a/pkg/exchange/bybit/bybitapi/types.go +++ b/pkg/exchange/bybit/bybitapi/types.go @@ -1,5 +1,41 @@ 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 const ( diff --git a/pkg/exchange/bybit/bybitapi/types_test.go b/pkg/exchange/bybit/bybitapi/types_test.go new file mode 100644 index 000000000..ba39b3d84 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/types_test.go @@ -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) +} diff --git a/pkg/exchange/bybit/convert.go b/pkg/exchange/bybit/convert.go index 9f665ecec..19043ad23 100644 --- a/pkg/exchange/bybit/convert.go +++ b/pkg/exchange/bybit/convert.go @@ -283,3 +283,48 @@ func toGlobalBalanceMap(events []bybitapi.WalletBalances) types.BalanceMap { } 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 +} diff --git a/pkg/exchange/bybit/convert_test.go b/pkg/exchange/bybit/convert_test.go index e6eb5b049..d2dab1632 100644 --- a/pkg/exchange/bybit/convert_test.go +++ b/pkg/exchange/bybit/convert_test.go @@ -459,3 +459,88 @@ func Test_toGlobalTrade(t *testing.T) { assert.NoError(t, err) 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) +} diff --git a/pkg/exchange/bybit/exchange.go b/pkg/exchange/bybit/exchange.go index 6998f5407..962958589 100644 --- a/pkg/exchange/bybit/exchange.go +++ b/pkg/exchange/bybit/exchange.go @@ -19,6 +19,7 @@ import ( const ( maxOrderIdLen = 36 defaultQueryLimit = 50 + defaultKLineLimit = 1000 halfYearDuration = 6 * 30 * 24 * time.Hour ) @@ -38,7 +39,12 @@ var ( "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 { @@ -422,6 +428,68 @@ func (e *Exchange) QueryAccountBalances(ctx context.Context) (types.BalanceMap, 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 { return NewStream(e.key, e.secret) }