init; project structure & bybit provider

This commit is contained in:
yash 2026-02-24 22:28:29 +03:00
commit ae096d4820
14 changed files with 482 additions and 0 deletions

View file

@ -0,0 +1 @@
package telegram

45
internal/config/config.go Normal file
View 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
}

View 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.
}

View 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
View 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
}

View 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
}

View 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.
}

View 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
}

View 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)
}