// package bybit provides rates of cryptocurrencies from bybit exchange orderbook. package bybit import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "strconv" "time" "gitea.computernetthings.ru/yash/crypto_alert_bot/internal/config" "gitea.computernetthings.ru/yash/crypto_alert_bot/internal/entities" "gitea.computernetthings.ru/yash/crypto_alert_bot/internal/provider" "github.com/shopspring/decimal" ) const ( // mainnetURL = "https://api.bybit.com" httpTimeout = time.Second * 10 ) type Bybit struct { log *slog.Logger cfg *config.Bybit client *http.Client } func New(log *slog.Logger, cfg *config.Bybit) provider.Provider { return &Bybit{ log: log, cfg: cfg, client: &http.Client{ Timeout: httpTimeout, }, } } func (b *Bybit) symbol(pair *entities.Instrument) string { return fmt.Sprintf("%s%s", pair.BaseCurrency, pair.QuoteCurrency) } // Price returns the current price of the pair (base currency / quote currency). // e.g. BTC/USDT. func (b *Bybit) Price(ctx context.Context, instrument entities.Instrument) (*entities.Price, error) { // build request req := marketOrderbookReq{ Category: categorySpot, Symbol: b.symbol(&instrument), } var resp marketOrderbookResp // make request err := b.makeRequest(ctx, http.MethodGet, "/v5/market/orderbook", req, &resp) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } // get results if len(resp.A) == 0 || len(resp.B) == 0 { return nil, fmt.Errorf("incorrect response: nil data") } if len(resp.A[0]) != 2 || len(resp.B[0]) != 2 { return nil, fmt.Errorf("incorrect response: nil prices") } askPrice := decimal.RequireFromString(resp.A[0][0]) bidPrice := decimal.RequireFromString(resp.B[0][0]) spread := askPrice.Sub(bidPrice) return &entities.Price{ Ask: askPrice, Bid: bidPrice, Spread: spread, Instrument: instrument, }, nil } const klineLimit = 1000 var intervalToBybitIntervalMapper = map[provider.KlineInterval]string{ provider.Kline1m: "1", provider.Kline3m: "3", provider.Kline5m: "5", provider.Kline15m: "15", provider.Kline30m: "30", provider.Kline1H: "60", provider.Kline4H: "240", provider.Kline6H: "360", provider.Kline12H: "720", provider.Kline1D: "D", provider.Kline1W: "W", } // intervalDuration returns the time.Duration corresponding to a Bybit kline interval string. func intervalBybit(interval provider.KlineInterval) (string, error) { i, ok := intervalToBybitIntervalMapper[interval] if !ok { return "", fmt.Errorf("interval not found") } return i, nil } // InstrumentExists reports whether base/quote is a valid spot pair on Bybit. // A non-zero retCode (e.g. unknown symbol) is treated as "not found" — only // actual transport / parse failures propagate as errors. func (b *Bybit) InstrumentExists(ctx context.Context, base, quote string) (bool, error) { req := marketOrderbookReq{ Category: categorySpot, Symbol: fmt.Sprintf("%s%s", base, quote), } body, err := b.getRequest(ctx, "/v5/market/orderbook", req) if err != nil { return false, fmt.Errorf("failed to check instrument existence: %w", err) } var resp response if err := json.Unmarshal(body, &resp); err != nil { return false, fmt.Errorf("failed to parse response: %w", err) } return resp.RetCode == 0, nil } // Candles returns OHLC candles for the given interval in the [from, to) range. // It paginates automatically when the range exceeds klineLimit candles per request. func (b *Bybit) Candles(ctx context.Context, instrument entities.Instrument, from, to time.Time, interval provider.KlineInterval) ([]entities.Candle, error) { dur := interval.ToDuration() limit := klineLimit var allCandles []entities.Candle batchFrom := from for batchFrom.Before(to) { batchTo := batchFrom.Add(time.Duration(limit) * dur) if batchTo.After(to) { batchTo = to } bybitInterval, err := intervalBybit(interval) if err != nil { return nil, err } req := marketKlineReq{ Category: categorySpot, Symbol: b.symbol(&instrument), Interval: bybitInterval, Start: strconv.FormatInt(batchFrom.UnixMilli(), 10), End: strconv.FormatInt(batchTo.UnixMilli(), 10), Limit: &limit, } var resp marketKlineResp if err := b.makeRequest(ctx, http.MethodGet, "/v5/market/kline", req, &resp); err != nil { return nil, fmt.Errorf("failed to get kline [%s, %s]: %w", batchFrom.Format(time.RFC3339), batchTo.Format(time.RFC3339), err) } for _, item := range resp.List { if len(item) < 5 { b.log.Error("bybit candles: length of elements less than 5", "len", len(item)) continue } startMs, err := strconv.ParseInt(item[0], 10, 64) if err != nil { b.log.Error("bybit candles: failed to parse start time", "err", err) continue } high, err := decimal.NewFromString(item[2]) if err != nil { b.log.Error("bybit candles: failed to parse candle high price", "err", err) continue } low, err := decimal.NewFromString(item[3]) if err != nil { b.log.Error("bybit candles: failed to parse candle low price", "err", err) continue } close, err := decimal.NewFromString(item[4]) if err != nil { b.log.Error("bybit candles: failed to parse candle close price", "err", err) continue } allCandles = append(allCandles, entities.Candle{ OpenTime: time.UnixMilli(startMs), High: high, Low: low, Close: close, }) } batchFrom = batchTo } return allCandles, nil }