From ae096d482097120bcef7d364ccea3031f3ce479e Mon Sep 17 00:00:00 2001 From: yash Date: Tue, 24 Feb 2026 22:28:29 +0300 Subject: [PATCH] init; project structure & bybit provider --- .gitignore | 2 + Makefile | 5 ++ cmd/app/main.go | 28 ++++++ go.mod | 28 ++++++ go.sum | 47 ++++++++++ internal/bot/telegram/telegram.go | 1 + internal/config/config.go | 45 ++++++++++ internal/entities/pair.go | 6 ++ internal/entities/price.go | 10 +++ internal/logger/logger.go | 54 +++++++++++ internal/provider/bybit/bybit.go | 74 ++++++++++++++++ internal/provider/bybit/models.go | 32 +++++++ internal/provider/bybit/request.go | 138 +++++++++++++++++++++++++++++ internal/provider/provider.go | 12 +++ 14 files changed, 482 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 cmd/app/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/bot/telegram/telegram.go create mode 100644 internal/config/config.go create mode 100644 internal/entities/pair.go create mode 100644 internal/entities/price.go create mode 100644 internal/logger/logger.go create mode 100644 internal/provider/bybit/bybit.go create mode 100644 internal/provider/bybit/models.go create mode 100644 internal/provider/bybit/request.go create mode 100644 internal/provider/provider.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..680bc55 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.yml +*.yaml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d4ff95a --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +run: + CONFIG_PATH=./internal/config/local.yml go run ./cmd/app/main.go + +build: + go build -o crypro_alert_bot ./cmd/app/main.go diff --git a/cmd/app/main.go b/cmd/app/main.go new file mode 100644 index 0000000..dd4f94c --- /dev/null +++ b/cmd/app/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + "crypto_alert_bot/internal/config" + "crypto_alert_bot/internal/entities" + "crypto_alert_bot/internal/logger" + "crypto_alert_bot/internal/provider/bybit" + "fmt" +) + +func main() { + // read config + cfg := config.MustLoad() + // init logger + log := logger.NewAppLogger(&cfg.Logger) + log.Info("app started") + // init telegram bot + b := bybit.New(log, &cfg.Providers.Bybit) + price, err := b.Price(context.Background(), entities.Pair{ + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + }) + if err != nil { + panic(err) + } + fmt.Printf("%+v\n", price) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f302d3b --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module crypto_alert_bot + +go 1.25.5 + +require ( + github.com/BurntSushi/toml v1.2.1 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/log v0.4.2 // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/ilyakaznacheev/cleanenv v1.5.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sys v0.30.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4a7b5d5 --- /dev/null +++ b/go.sum @@ -0,0 +1,47 @@ +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= +github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= +github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= diff --git a/internal/bot/telegram/telegram.go b/internal/bot/telegram/telegram.go new file mode 100644 index 0000000..2f93d21 --- /dev/null +++ b/internal/bot/telegram/telegram.go @@ -0,0 +1 @@ +package telegram diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..db15e9b --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,45 @@ +package config + +import ( + "fmt" + "os" + + "github.com/ilyakaznacheev/cleanenv" +) + +type Config struct { + Logger Logger `yaml:"logger"` + Providers struct { + Bybit Bybit `yaml:"bybit"` + } `yaml:"providers"` +} + +type Logger struct { + ServiceName string `yaml:"service_name" env-required:"true"` // service name for printing in logs + Encoding string `yaml:"encoding" env-default:"json"` // console/json + Level string `yaml:"level" env-default:"info"` // debug/info/warn/error +} + +type Bybit struct { + BaseURL string `yaml:"base_url" env-default:"https://api.bybit.com"` // bybit api url +} + +// MustLoad returns config or panic. +func MustLoad() *Config { + configPath := os.Getenv("CONFIG_PATH") + if configPath == "" { + panic("CONFIG_PATH is not set") + } + // check if file exists + if _, err := os.Stat(configPath); os.IsNotExist(err) { + panic(fmt.Sprintf("config file does not exist: %s", configPath)) + } + + var cfg Config + // read config + if err := cleanenv.ReadConfig(configPath, &cfg); err != nil { + panic(fmt.Errorf("cannot read config: %w", err)) + } + + return &cfg +} diff --git a/internal/entities/pair.go b/internal/entities/pair.go new file mode 100644 index 0000000..1bb4a3a --- /dev/null +++ b/internal/entities/pair.go @@ -0,0 +1,6 @@ +package entities + +type Pair struct { + BaseCurrency string // base currency of the pair. e.g. BTC. + QuoteCurrency string // quote currency of the pair. e.g. USDT. +} diff --git a/internal/entities/price.go b/internal/entities/price.go new file mode 100644 index 0000000..b3a7fb8 --- /dev/null +++ b/internal/entities/price.go @@ -0,0 +1,10 @@ +package entities + +import "github.com/shopspring/decimal" + +type Price struct { + Ask decimal.Decimal // limit seller / market buyer. ask > bid. + Bid decimal.Decimal // limit buyer / market seller. bid < ask. + Spread decimal.Decimal // delta between ask and bid. + Pair Pair // trading pair +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..b1a5b58 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,54 @@ +package logger + +import ( + "crypto_alert_bot/internal/config" + "fmt" + "log/slog" + "os" + + prettyLogger "github.com/charmbracelet/log" +) + +var levelAdapter = map[string]slog.Level{ + "debug": slog.LevelDebug, + "info": slog.LevelInfo, + "warn": slog.LevelWarn, + "error": slog.LevelError, +} + +const ( + encodingConsole = "console" + encodingJSON = "json" +) + +const ( + serviceNameKey = "service_name" +) + +func NewAppLogger(cfg *config.Logger) *slog.Logger { + var log *slog.Logger + // select log level + level, ok := levelAdapter[cfg.Level] + if !ok { + panic(fmt.Errorf("logger level not correct: %s", level)) + } + // make handler + switch cfg.Encoding { + case encodingConsole: + handler := prettyLogger.NewWithOptions(os.Stdout, prettyLogger.Options{ + Level: prettyLogger.Level(level), + ReportTimestamp: true, + ReportCaller: true, + }) + log = slog.New(handler) + case encodingJSON: + handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}) + log = slog.New(handler) + default: + panic(fmt.Errorf("logger encoding is not correct: %s", cfg.Encoding)) + } + // add field with service name + log = log.With(serviceNameKey, cfg.ServiceName) + + return log +} diff --git a/internal/provider/bybit/bybit.go b/internal/provider/bybit/bybit.go new file mode 100644 index 0000000..de58b35 --- /dev/null +++ b/internal/provider/bybit/bybit.go @@ -0,0 +1,74 @@ +// package bybit provides rates of cryptocurrencies from bybit exchange orderbook. +package bybit + +import ( + "context" + "crypto_alert_bot/internal/config" + "crypto_alert_bot/internal/entities" + "crypto_alert_bot/internal/provider" + "fmt" + "log/slog" + "net/http" + "time" + + "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.Pair) 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, pair entities.Pair) (*entities.Price, error) { + // build request + req := marketOrderbookReq{ + Category: categorySpot, + Symbol: b.symbol(&pair), + } + 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, + Pair: pair, + }, nil +} diff --git a/internal/provider/bybit/models.go b/internal/provider/bybit/models.go new file mode 100644 index 0000000..bcf1b49 --- /dev/null +++ b/internal/provider/bybit/models.go @@ -0,0 +1,32 @@ +package bybit + +const ( + categorySpot string = "spot" + categoryLinear string = "linear" + categoryInverse string = "inverse" + categoryOption string = "option" +) + +type marketOrderbookReq struct { + // Product type. spot, linear, inverse, option + Category string `json:"category"` + // Symbol name, like BTCUSDT, uppercase only + Symbol string `json:"symbol"` + /* + Limit size for each bid and ask + spot: [1, 200]. Default: 1. + linear&inverse: [1, 500]. Default: 25. + option: [1, 25]. Default: 1. + */ + Limit *int `json:"limit,omitempty"` +} + +type marketOrderbookResp struct { + S string `json:"s"` // Symbol name. + A [][]string `json:"a"` // Ask, seller. Sorted by price in ascending order. a[0]: Ask price, a[1]: Ask size. + B [][]string `json:"b"` // Bid, buyer. Sorted by price in descending order. b[0]: Bid price, b[1]: Bid size. + Ts int64 `json:"ts"` // The timestamp (ms) that the system generates the data. + U int `json:"u"` // Update ID, is always in sequence. + Seq int `json:"seq"` // Cross sequence. + Cts int64 `json:"cts"` // The timestamp from the matching engine when this orderbook data is produced. It can be correlated with T from public trade channel. +} diff --git a/internal/provider/bybit/request.go b/internal/provider/bybit/request.go new file mode 100644 index 0000000..5be72fa --- /dev/null +++ b/internal/provider/bybit/request.go @@ -0,0 +1,138 @@ +package bybit + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +type response struct { + RetCode int `json:"retCode"` + RetMsg string `json:"retMsg"` + Result json.RawMessage `json:"result"` + Time int64 `json:"time"` + RetExtInfo json.RawMessage `json:"retExtInfo"` +} + +func (b *Bybit) makeRequest(ctx context.Context, method string, endpoint string, params any, v any) error { + // send request + var ( + body []byte + err error + ) + + switch method { + case http.MethodGet: + body, err = b.getRequest(ctx, endpoint, params) + case http.MethodPost: + body, err = b.postRequest(ctx, endpoint, params) + default: + return fmt.Errorf("method not correct") + } + if err != nil { + return fmt.Errorf("failed to make request: %w", err) + } + + b.log.Debug("bybit request", "endpoint", endpoint, "params", params, "body", string(body)) + + var resp response + if err := json.Unmarshal(body, &resp); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + if resp.RetCode != 0 { + return fmt.Errorf("response not ok: code: %d, msg: %s, info: %s, result: %s", resp.RetCode, resp.RetMsg, resp.RetExtInfo, resp.Result) + } + if v == nil { + return nil + } + if err := json.Unmarshal(resp.Result, v); err != nil { + return fmt.Errorf("failed to parse result: %w", err) + } + + return nil +} + +func (b *Bybit) getRequest(ctx context.Context, endPoint string, params any) ([]byte, error) { + // params to query + queryString := "" + if params != nil { + b, err := json.Marshal(params) + if err != nil { + return nil, err + } + var q map[string]any + if err := json.Unmarshal(b, &q); err != nil { + return nil, err + } + query := make(url.Values) + for k, v := range q { + query.Add(k, fmt.Sprint(v)) + } + queryString = query.Encode() + } + + // make request + request, err := http.NewRequest("GET", b.cfg.BaseURL+endPoint+"?"+queryString, nil) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + + request = request.WithContext(ctx) + + request.Header.Set("Content-Type", "application/json") + // b.setRequestAPIHeaders(request, []byte(queryString)) + + // get response + response, err := b.client.Do(request) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + return body, nil +} + +func (b *Bybit) postRequest(ctx context.Context, endPoint string, params any) ([]byte, error) { + // params to json + jsonData, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("failed to marshal params: %w", err) + } + + // make request + request, err := http.NewRequest("POST", b.cfg.BaseURL+endPoint, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + + request = request.WithContext(ctx) + + request.Header.Set("Content-Type", "application/json") + // b.setRequestAPIHeaders(request, jsonData) + + // get response + response, err := b.client.Do(request) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + return body, nil +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..0ae00b3 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,12 @@ +package provider + +import ( + "context" + "crypto_alert_bot/internal/entities" +) + +type Provider interface { + // Price returns the current price of the pair (base currency / quote currency). + // e.g. BTC/USDT. + Price(ctx context.Context, pair entities.Pair) (*entities.Price, error) +}