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

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*.yml
*.yaml

5
Makefile Normal file
View file

@ -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

28
cmd/app/main.go Normal file
View file

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

28
go.mod Normal file
View file

@ -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
)

47
go.sum Normal file
View file

@ -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=

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