init; project structure & bybit provider
This commit is contained in:
commit
ae096d4820
14 changed files with 482 additions and 0 deletions
1
internal/bot/telegram/telegram.go
Normal file
1
internal/bot/telegram/telegram.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package telegram
|
||||
45
internal/config/config.go
Normal file
45
internal/config/config.go
Normal file
|
|
@ -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
|
||||
}
|
||||
6
internal/entities/pair.go
Normal file
6
internal/entities/pair.go
Normal file
|
|
@ -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.
|
||||
}
|
||||
10
internal/entities/price.go
Normal file
10
internal/entities/price.go
Normal file
|
|
@ -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
|
||||
}
|
||||
54
internal/logger/logger.go
Normal file
54
internal/logger/logger.go
Normal file
|
|
@ -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
|
||||
}
|
||||
74
internal/provider/bybit/bybit.go
Normal file
74
internal/provider/bybit/bybit.go
Normal file
|
|
@ -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
|
||||
}
|
||||
32
internal/provider/bybit/models.go
Normal file
32
internal/provider/bybit/models.go
Normal file
|
|
@ -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.
|
||||
}
|
||||
138
internal/provider/bybit/request.go
Normal file
138
internal/provider/bybit/request.go
Normal file
|
|
@ -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
|
||||
}
|
||||
12
internal/provider/provider.go
Normal file
12
internal/provider/provider.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue