diff --git a/internal/configs/binance/asset.go b/internal/configs/binance/asset.go new file mode 100644 index 0000000..60208b6 --- /dev/null +++ b/internal/configs/binance/asset.go @@ -0,0 +1,21 @@ +package binance + +const ( + AssetUsdt = "USDT" + AssetUsdc = "USDC" + AssetFdusd = "FDUSD" + AssetBtc = "BTC" + AssetBnb = "BNB" + AssetEth = "ETH" + AssetXrp = "XRP" +) + +var SymbolPrefixList = []string{"1000"} +var SymbolSuffixMap = map[string]string{ + "USDT": "", + "USDC": "c", +} + +var Future2SpotSymbolMap = map[string]string{ + "LUNA2USDT": "LUNAUSDT", +} diff --git a/internal/configs/config.go b/internal/configs/config.go new file mode 100644 index 0000000..71a3db4 --- /dev/null +++ b/internal/configs/config.go @@ -0,0 +1,5 @@ +package configs + +var ( + LogEnv = true +) diff --git a/internal/configs/key/env.go b/internal/configs/key/env.go new file mode 100644 index 0000000..d9bd0be --- /dev/null +++ b/internal/configs/key/env.go @@ -0,0 +1,7 @@ +package key + +const ( + LogEnv = "LOG_ENV" + + TelegramToken = "TELEGRAM_TOKEN" +) diff --git a/internal/configs/strategy/market.go b/internal/configs/strategy/market.go new file mode 100644 index 0000000..f5af1c5 --- /dev/null +++ b/internal/configs/strategy/market.go @@ -0,0 +1,20 @@ +package strategy + +const ( + symbolBTC = "BTCUSDT" + symbolETH = "ETHUSDT" + symbolBNB = "BNBUSDT" + symbolTON = "TONUSDT" + symbolXRP = "XRPUSDT" +) + +var ( + TopPriceSymbols = []string{symbolBTC, symbolETH, symbolBNB, symbolTON, symbolXRP} +) + +const ( + FundRateBuyThreshold = -0.001 + FundRateSellThreshold = 0.00077 + + LiquidInfPrice = 1000_000_000 +) diff --git a/internal/configs/tele/sticker.go b/internal/configs/tele/sticker.go new file mode 100644 index 0000000..d6fce43 --- /dev/null +++ b/internal/configs/tele/sticker.go @@ -0,0 +1,17 @@ +package tele + +const ( + StickerSet = "verichains" + + StickerTop = "AgADfQwAAinYuVQ" +) + +var Token2StickerIdxMap = map[string]int{ + "BNB": 3, + "TON": 7, +} + +var Sticker2TokenMap = map[string]string{ + "AgAD-BQAAtQesFQ": "TON", + "AgADxhEAAoNAIFQ": "BNB", +} diff --git a/internal/data/imarket.go b/internal/data/imarket.go new file mode 100644 index 0000000..52519c3 --- /dev/null +++ b/internal/data/imarket.go @@ -0,0 +1,8 @@ +package data + +type IMarket interface { + GetFuturePrice(symbol string) (float64, float64, int64, bool) + GetAllFundRate() (map[string]float64, map[string]int64) + + GetSpotPrice(symbol string) (float64, bool) +} diff --git a/internal/data/market/future_price.go b/internal/data/market/future_price.go new file mode 100644 index 0000000..46851a0 --- /dev/null +++ b/internal/data/market/future_price.go @@ -0,0 +1,50 @@ +package market + +import ( + "github.com/adshao/go-binance/v2/futures" + "github.com/rs/zerolog/log" + "strconv" +) + +func (ms MarketData) GetFuturePrice(symbol string) (float64, float64, int64, bool) { + p, ok := ms.futureMarkPrice[symbol] + if !ok { + return 0, 0, 0, false + } + return p, ms.futureFundingRate[symbol], ms.futureNextFundingTime[symbol], true +} + +func (ms MarketData) StartFutureWsMarkPrice() error { + _, _, err := futures.WsAllMarkPriceServe(ms.futureWsMarkPriceHandler, ms.futureWsErrHandler) + if err != nil { + return err + } + return nil +} + +func (ms MarketData) futureWsMarkPriceHandler(event futures.WsAllMarkPriceEvent) { + for _, priceEvent := range event { + price, err := strconv.ParseFloat(priceEvent.MarkPrice, 64) + if err != nil { + continue + } + + fundingRate, err := strconv.ParseFloat(priceEvent.FundingRate, 64) + if err != nil { + continue + } + + ms.futureMarkPrice[priceEvent.Symbol] = price + ms.futureFundingRate[priceEvent.Symbol] = fundingRate + ms.futureNextFundingTime[priceEvent.Symbol] = priceEvent.NextFundingTime + } +} + +func (ms MarketData) futureWsErrHandler(err error) { + log.Debug().Err(err).Msg("Ws Error. Restart socket") + _ = ms.StartFutureWsMarkPrice() +} + +func (ms MarketData) GetAllFundRate() (map[string]float64, map[string]int64) { + return ms.futureFundingRate, ms.futureNextFundingTime +} diff --git a/internal/data/market/main.go b/internal/data/market/main.go new file mode 100644 index 0000000..19bec1a --- /dev/null +++ b/internal/data/market/main.go @@ -0,0 +1,25 @@ +package market + +import "github.com/rs/zerolog/log" + +type MarketData struct { + futureMarkPrice map[string]float64 + futureFundingRate map[string]float64 + futureNextFundingTime map[string]int64 + + spotPrice map[string]float64 +} + +func NewMarketData() *MarketData { + log.Info().Msg("Start market service") + ms := &MarketData{ + futureMarkPrice: make(map[string]float64), + futureFundingRate: make(map[string]float64), + futureNextFundingTime: make(map[string]int64), + + spotPrice: make(map[string]float64), + } + _ = ms.StartFutureWsMarkPrice() + _ = ms.StartSpotWsMarkPrice() + return ms +} diff --git a/internal/data/market/spot_price.go b/internal/data/market/spot_price.go new file mode 100644 index 0000000..9cacca6 --- /dev/null +++ b/internal/data/market/spot_price.go @@ -0,0 +1,36 @@ +package market + +import ( + "github.com/adshao/go-binance/v2" + "github.com/rs/zerolog/log" + "strconv" +) + +func (ms MarketData) GetSpotPrice(symbol string) (float64, bool) { + p, ok := ms.spotPrice[symbol] + return p, ok +} + +func (ms MarketData) StartSpotWsMarkPrice() error { + _, _, err := binance.WsAllMarketsStatServe(ms.spotWsAllMarketsStatHandler, ms.spotWsErrHandler) //.WsAllMarkPriceServe(ms.futureWsMarkPriceHandler, ms.futureWsErrHandler) + + if err != nil { + return err + } + return nil +} + +func (ms MarketData) spotWsAllMarketsStatHandler(event binance.WsAllMarketsStatEvent) { + for _, priceEvent := range event { + price, err := strconv.ParseFloat(priceEvent.LastPrice, 64) + if err != nil { + continue + } + ms.spotPrice[priceEvent.Symbol] = price + } +} + +func (ms MarketData) spotWsErrHandler(err error) { + log.Debug().Err(err).Msg("Spot Ws Error. Restart socket") + _ = ms.StartSpotWsMarkPrice() +} diff --git a/internal/data/repo.go b/internal/data/repo.go new file mode 100644 index 0000000..4d492f8 --- /dev/null +++ b/internal/data/repo.go @@ -0,0 +1,15 @@ +package data + +import "sync" + +var ( + Market IMarket + once sync.Once +) + +func NewRepository(market IMarket) { + once.Do( + func() { + Market = market + }) +} diff --git a/internal/deps/data.go b/internal/deps/data.go new file mode 100644 index 0000000..df89a8b --- /dev/null +++ b/internal/deps/data.go @@ -0,0 +1,12 @@ +package deps + +import ( + "me.thuanle/bbot/internal/data" + market2 "me.thuanle/bbot/internal/data/market" +) + +func InitData() { + marketData := market2.NewMarketData() + + data.NewRepository(marketData) +} diff --git a/internal/helper/binancex/symbol.go b/internal/helper/binancex/symbol.go new file mode 100644 index 0000000..62d5f0b --- /dev/null +++ b/internal/helper/binancex/symbol.go @@ -0,0 +1,95 @@ +package binancex + +import ( + "me.thuanle/bbot/internal/configs/binance" + "me.thuanle/bbot/internal/data" + "me.thuanle/bbot/internal/utils/stringx" + "strings" +) + +func Symbol2Token(sym string) string { + token := strings.ToUpper(sym) + + for _, prefix := range binance.SymbolPrefixList { + token, _ = strings.CutPrefix(token, prefix) + } + for suffix, abbr := range binance.SymbolSuffixMap { + var f bool + token, f = stringx.ReplaceSuffix(token, suffix, abbr) + if f { + break + } + } + return token +} + +var ( + checkingPrefixList = append(binance.SymbolPrefixList, "") +) + +func testSym(sym string) bool { + _, _, _, test := data.Market.GetFuturePrice(sym) + return test +} + +func Token2Symbols(token string) []string { + var syms []string + if !stringx.IsAlphaNumeric(token) { + return syms + } + + token = strings.ToUpper(token) + + for _, prefix := range checkingPrefixList { + prefix = strings.ToUpper(prefix) + + s := prefix + token + + //no suffix + if testSym(s) { + syms = append(syms, s) + continue + } + + for suffix, abbr := range binance.SymbolSuffixMap { + suffix = strings.ToUpper(suffix) + abbr = strings.ToUpper(abbr) + + //suffix + sym := s + suffix + if testSym(sym) { + syms = append(syms, sym) + continue + } + + //suffix abbr + symAbr, found := stringx.ReplaceSuffix(s, abbr, suffix) + if found && testSym(symAbr) { + syms = append(syms, symAbr) + continue + } + } + } + return syms +} + +func IsToken(s string) bool { + return len(Token2Symbols(s)) > 0 +} + +// Future2SpotSymbol convert future symbol to spot symbol +func Future2SpotSymbol(sym string) string { + for _, prefix := range binance.SymbolPrefixList { + var f bool + sym, f = strings.CutPrefix(sym, prefix) + if f { + break + } + } + + spotSym, ok := binance.Future2SpotSymbolMap[sym] + if !ok { + return sym + } + return spotSym +} diff --git a/internal/load.go b/internal/load.go new file mode 100644 index 0000000..eb174fb --- /dev/null +++ b/internal/load.go @@ -0,0 +1,27 @@ +package internal + +import ( + "fmt" + "github.com/joho/godotenv" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "me.thuanle/bbot/internal/configs" + "me.thuanle/bbot/internal/configs/key" + "me.thuanle/bbot/internal/utils/envx" +) + +func Startup() { + prepareEnv() + + zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + + log.Info().Msg("Starting up...") +} + +func prepareEnv() { + if err := godotenv.Load(); err != nil { + fmt.Println("Error loading .env file", err) + } + + configs.LogEnv, _ = envx.GetEnvBool(key.LogEnv) +} diff --git a/internal/services/controllers/market.go b/internal/services/controllers/market.go new file mode 100644 index 0000000..bf30653 --- /dev/null +++ b/internal/services/controllers/market.go @@ -0,0 +1,52 @@ +package controllers + +import ( + "me.thuanle/bbot/internal/configs/strategy" + "me.thuanle/bbot/internal/data" + "sort" +) + +// GetTopPrices returns the top symbol, price and rate +func GetTopPrices() ([]string, []float64, []float64) { + n := len(strategy.TopPriceSymbols) + topSym := make([]string, n) + topPrice := make([]float64, n) + topRate := make([]float64, n) + + for i, sym := range strategy.TopPriceSymbols { + price, rate, _, ok := data.Market.GetFuturePrice(sym) + if !ok { + continue + } + topSym[i] = sym + topPrice[i] = price + topRate[i] = rate + + } + return topSym, topPrice, topRate +} + +// GetTopFundingFee returns the top funding fee: symbol, rate and cd time +func GetTopFundingFee() ([]string, []float64, []int64) { + allRates, allCd := data.Market.GetAllFundRate() + var resSym []string + for sym, rate := range allRates { + if strategy.FundRateBuyThreshold < rate && rate < strategy.FundRateSellThreshold { + continue + } + + resSym = append(resSym, sym) + } + + sort.Slice(resSym, func(i, j int) bool { + return allRates[resSym[i]] < allRates[resSym[j]] + }) + + resRate := make([]float64, len(resSym)) + resCd := make([]int64, len(resSym)) + for i, sym := range resSym { + resRate[i] = allRates[sym] + resCd[i] = allCd[sym] + } + return resSym, resRate, resCd +} diff --git a/internal/services/tele/chat/helper/wrap.go b/internal/services/tele/chat/helper/wrap.go new file mode 100644 index 0000000..db17d24 --- /dev/null +++ b/internal/services/tele/chat/helper/wrap.go @@ -0,0 +1,9 @@ +package helper + +func Pre(msg string) string { + return "
" + msg + "
" +} + +func Code(msg string) string { + return "" + msg + "" +} diff --git a/internal/services/tele/chat/reply.go b/internal/services/tele/chat/reply.go new file mode 100644 index 0000000..1ef6960 --- /dev/null +++ b/internal/services/tele/chat/reply.go @@ -0,0 +1,39 @@ +package chat + +import ( + "encoding/json" + "fmt" + "gopkg.in/telebot.v3" + "me.thuanle/bbot/internal/services/tele/chat/helper" +) + +func ReplyMessage(context telebot.Context, formattedText string, opts ...interface{}) error { + if opts == nil { + return context.Reply(formattedText, telebot.ModeHTML, telebot.NoPreview) + } + return context.Reply(formattedText, append(opts, telebot.ModeHTML, telebot.NoPreview)...) +} + +func ReplyMessagef(context telebot.Context, menu *telebot.ReplyMarkup, format string, v ...interface{}) error { + if menu != nil { + return ReplyMessage(context, fmt.Sprintf(format, v...), menu) + } else { + return ReplyMessage(context, fmt.Sprintf(format, v...)) + } +} + +func ReplyMessageCode(context telebot.Context, message string) error { + return ReplyMessage(context, helper.Code(message)) +} + +func ReplyMessagePre(context telebot.Context, message string) error { + return ReplyMessage(context, helper.Pre(message)) +} + +func ReplyMessageJson(context telebot.Context, obj any) error { + jsonStr, err := json.MarshalIndent(obj, "", " ") + if err != nil { + return err + } + return ReplyMessagePre(context, string(jsonStr)) +} diff --git a/internal/services/tele/command.go b/internal/services/tele/command.go new file mode 100644 index 0000000..ff50cf2 --- /dev/null +++ b/internal/services/tele/command.go @@ -0,0 +1,45 @@ +package tele + +import ( + "github.com/rs/zerolog/log" + "gopkg.in/telebot.v3" + "me.thuanle/bbot/internal/services/tele/commands" + "me.thuanle/bbot/internal/services/tele/middlewares" +) + +var commandList = []telebot.Command{ + { + Text: "p", + Description: "(p) - Get mark price", + }, + { + Text: "fee", + Description: "(f) - show top funding fee", + }, +} + +func setupCommands(b *telebot.Bot) error { + if err := b.SetCommands(commandList); err != nil { + log.Fatal().Err(err).Msg("setup telebot commands") + return err + } + + b.Use(middlewares.IgnoreBot) + b.Use(middlewares.SendErrorMiddleware) + + //welcome + b.Handle("/start", commands.OnStart) + + //general + b.Handle("/ip", commands.OnGetIp) + + //info + b.Handle("/p", commands.OnGetTopPrices) + b.Handle("/fee", commands.OnGetTopFundingFee) + + //any text + b.Handle(telebot.OnText, commands.OnChatHandler) + b.Handle(telebot.OnSticker, commands.OnStickerHandler) + + return nil +} diff --git a/internal/services/tele/commands/chat.go b/internal/services/tele/commands/chat.go new file mode 100644 index 0000000..fa128d8 --- /dev/null +++ b/internal/services/tele/commands/chat.go @@ -0,0 +1,43 @@ +package commands + +import ( + "github.com/rs/zerolog/log" + "gopkg.in/telebot.v3" + "me.thuanle/bbot/internal/configs/tele" + "me.thuanle/bbot/internal/helper/binancex" + "strings" +) + +func OnChatHandler(context telebot.Context) error { + text := strings.ToLower(context.Text()) + + switch { + case text == "p": + return OnGetTopPrices(context) + case text == "f" || text == "fee": + return OnGetTopFundingFee(context) + case binancex.IsToken(text): + return OnTokenInfo(context) + default: + //ignore it + } + return nil +} + +func OnStickerHandler(context telebot.Context) error { + stickerId := context.Message().Sticker.UniqueID + switch stickerId { + case tele.StickerTop: + return OnGetTopPrices(context) + default: + token, ok := tele.Sticker2TokenMap[stickerId] + if !ok { + log.Debug(). + Any("sticker", context.Message().Sticker). + Msg("Sticker received") + return nil + } + + return OnTokenInfoByToken(context, token) + } +} diff --git a/internal/services/tele/commands/general.go b/internal/services/tele/commands/general.go new file mode 100644 index 0000000..371c4a4 --- /dev/null +++ b/internal/services/tele/commands/general.go @@ -0,0 +1,12 @@ +package commands + +import ( + "gopkg.in/telebot.v3" + "me.thuanle/bbot/internal/services/tele/chat" + "me.thuanle/bbot/internal/utils/netx" +) + +func OnGetIp(context telebot.Context) error { + ip := netx.GetPublicIp() + return chat.ReplyMessageCode(context, ip) +} diff --git a/internal/services/tele/commands/helper/error.go b/internal/services/tele/commands/helper/error.go new file mode 100644 index 0000000..cd6d74d --- /dev/null +++ b/internal/services/tele/commands/helper/error.go @@ -0,0 +1,5 @@ +package helper + +import "errors" + +var ErrorParamCountMismatch = errors.New("param count mismatched") diff --git a/internal/services/tele/commands/helper/handler.go b/internal/services/tele/commands/helper/handler.go new file mode 100644 index 0000000..2e46d62 --- /dev/null +++ b/internal/services/tele/commands/helper/handler.go @@ -0,0 +1,41 @@ +package helper + +import ( + "gopkg.in/telebot.v3" +) + +func throwErrorParamCountMismatch(_ telebot.Context) error { + return ErrorParamCountMismatch +} + +// Base on the number of Args in context, it will execute the (n+1)-th function in the list +// - If the context have 0 argument, it will call funcs[0] +// - If the context have 1 argument, it will call funcs[1] +// Otherwise, it will call `other` function +func HandleZ0nArgs(other telebot.HandlerFunc, funcs ...telebot.HandlerFunc) telebot.HandlerFunc { + return func(context telebot.Context) error { + args := context.Args() + + if len(args) > len(funcs)-1 { + return other(context) + } + return funcs[len(args)](context) + } +} + +// Base on the number of Args in context, it will execute the (n+1)-th function in the list +// - If the context have 0 argument, it will call funcs[0] +// - If the context have 1 argument, it will call funcs[1] +// Otherwise, it will throw error +func Handle0nArgs(funcs ...telebot.HandlerFunc) telebot.HandlerFunc { + return HandleZ0nArgs(throwErrorParamCountMismatch, funcs...) +} + +func HandleGetSet(getFunc telebot.HandlerFunc, setFunc telebot.HandlerFunc) telebot.HandlerFunc { + return func(context telebot.Context) error { + if len(context.Args()) == 0 { + return getFunc(context) + } + return setFunc(context) + } +} diff --git a/internal/services/tele/commands/market.go b/internal/services/tele/commands/market.go new file mode 100644 index 0000000..49e0001 --- /dev/null +++ b/internal/services/tele/commands/market.go @@ -0,0 +1,18 @@ +package commands + +import ( + "gopkg.in/telebot.v3" + "me.thuanle/bbot/internal/services/controllers" + "me.thuanle/bbot/internal/services/tele/chat" + "me.thuanle/bbot/internal/services/tele/view" +) + +func OnGetTopPrices(context telebot.Context) error { + sym, price, rate := controllers.GetTopPrices() + return chat.ReplyMessagePre(context, view.RenderOnGetTopPricesMessage(sym, price, rate)) +} + +func OnGetTopFundingFee(context telebot.Context) error { + fee, float64s, cds := controllers.GetTopFundingFee() + return chat.ReplyMessagePre(context, view.RenderOnGetTopFundingFeeMessage(fee, float64s, cds)) +} diff --git a/internal/services/tele/commands/start.go b/internal/services/tele/commands/start.go new file mode 100644 index 0000000..d4edb56 --- /dev/null +++ b/internal/services/tele/commands/start.go @@ -0,0 +1,11 @@ +package commands + +import "gopkg.in/telebot.v3" + +const what = `Welcome on board! Here is some tips: +- /p to get pricing +- /l to list current positions` + +func OnStart(context telebot.Context) error { + return context.Send(what) +} diff --git a/internal/services/tele/commands/token.go b/internal/services/tele/commands/token.go new file mode 100644 index 0000000..2ac265f --- /dev/null +++ b/internal/services/tele/commands/token.go @@ -0,0 +1,49 @@ +package commands + +import ( + "gopkg.in/telebot.v3" + "me.thuanle/bbot/internal/configs/tele" + "me.thuanle/bbot/internal/data" + "me.thuanle/bbot/internal/helper/binancex" + "me.thuanle/bbot/internal/services/tele/chat" + "me.thuanle/bbot/internal/services/tele/view" + "strings" +) + +func showStickerMode(context telebot.Context, token string) { + token = strings.ToUpper(token) + stickerIdx, ok := tele.Token2StickerIdxMap[token] + if !ok { + return + } + stickers, err := context.Bot().StickerSet(tele.StickerSet) + if err == nil && stickers != nil { + _ = context.Reply(&stickers.Stickers[stickerIdx]) + } +} + +func OnTokenInfoByToken(context telebot.Context, token string) error { + symbols := binancex.Token2Symbols(token) + if len(symbols) == 0 { + return nil + } + + showStickerMode(context, token) + + fp, fundRate, fundTime, ok := data.Market.GetFuturePrice(symbols[0]) + if !ok { + return nil + } + sSymbol := binancex.Future2SpotSymbol(symbols[0]) + sp, _ := data.Market.GetSpotPrice(sSymbol) + + _ = chat.ReplyMessage(context, view.RenderOnPriceMessage(symbols[0], sp, fp, fundRate, fundTime)) + + return nil +} + +func OnTokenInfo(context telebot.Context) error { + text := strings.ToLower(context.Text()) + + return OnTokenInfoByToken(context, text) +} diff --git a/internal/services/tele/middlewares/error.go b/internal/services/tele/middlewares/error.go new file mode 100644 index 0000000..cc2a435 --- /dev/null +++ b/internal/services/tele/middlewares/error.go @@ -0,0 +1,19 @@ +package middlewares + +import ( + "fmt" + "github.com/rs/zerolog/log" + "gopkg.in/telebot.v3" +) + +func SendErrorMiddleware(next telebot.HandlerFunc) telebot.HandlerFunc { + return func(c telebot.Context) error { + err := next(c) // continue execution chain + if err != nil { + log.Debug().Err(err).Msg("Chat Middleware error reply") + return c.Reply(fmt.Sprintf("Error: %v", err), telebot.RemoveKeyboard) + } + return nil + } + +} diff --git a/internal/services/tele/middlewares/ignore_bot.go b/internal/services/tele/middlewares/ignore_bot.go new file mode 100644 index 0000000..da5a0df --- /dev/null +++ b/internal/services/tele/middlewares/ignore_bot.go @@ -0,0 +1,14 @@ +package middlewares + +import ( + "gopkg.in/telebot.v3" +) + +func IgnoreBot(next telebot.HandlerFunc) telebot.HandlerFunc { + return func(c telebot.Context) error { + if c.Sender().IsBot { + return nil + } + return next(c) + } +} diff --git a/internal/services/tele/tele_service.go b/internal/services/tele/tele_service.go new file mode 100644 index 0000000..061e7d2 --- /dev/null +++ b/internal/services/tele/tele_service.go @@ -0,0 +1,51 @@ +package tele + +import ( + "github.com/rs/zerolog/log" + "gopkg.in/telebot.v3" + "me.thuanle/bbot/internal/configs/key" + "os" + "sync" + "time" +) + +type Service struct { + b *telebot.Bot +} + +var ( + ts *Service + once sync.Once +) + +func Ins() *Service { + if ts != nil { + return ts + } + once.Do(func() { + pref := telebot.Settings{ + Token: os.Getenv(key.TelegramToken), + Poller: &telebot.LongPoller{Timeout: 10 * time.Second}, + } + + b, err := telebot.NewBot(pref) + if err != nil { + log.Fatal().Err(err).Msg("Failed to create bot") + } + + err = setupCommands(b) + if err != nil { + log.Fatal().Err(err).Msg("Failed to setup commands") + } + + ts = &Service{ + b: b, + } + }) + return ts +} + +func (t *Service) Start() { + log.Info().Msg("Starting telegram service") + t.b.Start() +} diff --git a/internal/services/tele/view/fee.go b/internal/services/tele/view/fee.go new file mode 100644 index 0000000..9be3ed5 --- /dev/null +++ b/internal/services/tele/view/fee.go @@ -0,0 +1,34 @@ +package view + +import ( + "fmt" + "github.com/jedib0t/go-pretty/v6/table" + "me.thuanle/bbot/internal/helper/binancex" + "me.thuanle/bbot/internal/services/tele/view/helper" + "me.thuanle/bbot/internal/utils/opx" + "me.thuanle/bbot/internal/utils/timex" + "time" +) + +func IconOfFundingFeeDirection(fund float64) string { + return opx.Ite(fund > 0, "📉", "📈") +} + +func RenderOnGetTopFundingFeeMessage(sym []string, rate []float64, cd []int64) string { + t := helper.NewNoBorderTableWriter("Symbol", "Rate", "Cd") + + flag := true + for i, s := range sym { + if rate[i] > 0 && flag { + t.AppendRow(table.Row{"...", "...", "..."}) + flag = false + } + t.AppendRow(table.Row{ + binancex.Symbol2Token(s), + fmt.Sprintf("%.3f%%", rate[i]*100), + fmt.Sprintf("%s", timex.CdMinuteStringTime(time.UnixMilli(cd[i]))), + }) + } + + return t.Render() +} diff --git a/internal/services/tele/view/helper/table.go b/internal/services/tele/view/helper/table.go new file mode 100644 index 0000000..114b89c --- /dev/null +++ b/internal/services/tele/view/helper/table.go @@ -0,0 +1,27 @@ +package helper + +import ( + "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" +) + +func NewNoBorderTableWriter(headers ...string) table.Writer { + t := table.NewWriter() + t.Style().Options.DrawBorder = false + t.Style().Options.SeparateColumns = false + + var configs []table.ColumnConfig + configs = append(configs, table.ColumnConfig{Number: 1, AlignHeader: text.AlignCenter, Align: text.AlignLeft, AlignFooter: text.AlignLeft}) + for i := 2; i <= len(headers); i++ { + configs = append(configs, table.ColumnConfig{Number: i, AlignHeader: text.AlignCenter, Align: text.AlignRight, AlignFooter: text.AlignRight}) + } + t.SetColumnConfigs(configs) + + var h table.Row + for _, header := range headers { + h = append(h, header) + } + t.AppendHeader(h) + + return t +} diff --git a/internal/services/tele/view/price.go b/internal/services/tele/view/price.go new file mode 100644 index 0000000..279b3b6 --- /dev/null +++ b/internal/services/tele/view/price.go @@ -0,0 +1,60 @@ +package view + +import ( + "fmt" + "github.com/jedib0t/go-pretty/v6/table" + "golang.org/x/text/language" + "golang.org/x/text/message" + "me.thuanle/bbot/internal/helper/binancex" + "me.thuanle/bbot/internal/services/tele/view/helper" + "me.thuanle/bbot/internal/utils/timex" + "time" +) + +func RenderPrice(price float64) string { + mFmt := message.NewPrinter(language.AmericanEnglish) + switch { + case price > 1000: + return mFmt.Sprintf("%0.0f", price) + case price > 100: + return mFmt.Sprintf("%0.1f", price) + case price > 10: + return mFmt.Sprintf("%0.2f", price) + case price > 1: + return mFmt.Sprintf("%0.3f", price) + case price > 0.1: + return mFmt.Sprintf("%0.4f", price) + case price > 0.01: + return mFmt.Sprintf("%0.5f", price) + case price > 0.001: + return mFmt.Sprintf("%0.6f", price) + case price > 0.0001: + return mFmt.Sprintf("%0.7f", price) + default: + return mFmt.Sprintf("%0.8f", price) + } +} + +func RenderOnPriceMessage(symbol string, spotPrice float64, futurePrice float64, fundrate float64, fundtime int64) string { + return fmt.Sprintf( + "%s: %s\n"+ + "Fund rate: %.4f%% %s in %s\n"+ + "Spot: %s", + RenderSymbolInfo(symbol), RenderPrice(futurePrice), + fundrate*100, IconOfFundingFeeDirection(fundrate), timex.CdMinuteStringTime(time.UnixMilli(fundtime)), + RenderPrice(spotPrice), + ) +} + +func RenderOnGetTopPricesMessage(symbols []string, price []float64, fundRate []float64) string { + t := helper.NewNoBorderTableWriter("", "Price", "Rate") + mFmt := message.NewPrinter(language.AmericanEnglish) + for i, symbol := range symbols { + t.AppendRow(table.Row{ + binancex.Symbol2Token(symbol), + mFmt.Sprintf("%.2f", price[i]), + mFmt.Sprintf("%.3f%%", fundRate[i]*100), + }) + } + return t.Render() +} diff --git a/internal/services/tele/view/symbol.go b/internal/services/tele/view/symbol.go new file mode 100644 index 0000000..8aa3753 --- /dev/null +++ b/internal/services/tele/view/symbol.go @@ -0,0 +1,11 @@ +package view + +import ( + "fmt" + "me.thuanle/bbot/internal/helper/binancex" +) + +func RenderSymbolInfo(sym string) string { + token := binancex.Symbol2Token(sym) + return fmt.Sprintf("%s", sym, token) +} diff --git a/internal/utils/collectionx/count_map.go b/internal/utils/collectionx/count_map.go new file mode 100644 index 0000000..237b165 --- /dev/null +++ b/internal/utils/collectionx/count_map.go @@ -0,0 +1,42 @@ +package collectionx + +import "github.com/samber/lo" + +type CountMapN[T comparable] struct { + N int + Map map[T][]int +} + +func NewCountMapN[T comparable](n int) *CountMapN[T] { + return &CountMapN[T]{ + N: n, + Map: make(map[T][]int), + } +} + +func (cm *CountMapN[T]) Inc(key T, ith int, value int) { + m, ok := cm.Map[key] + if !ok { + m = make([]int, cm.N) + cm.Map[key] = m + } + + m[ith] += value +} + +func (cm *CountMapN[T]) Get(key T, ith int) int { + m, ok := cm.Map[key] + if !ok { + return 0 + } + + return m[ith] +} + +func (cm *CountMapN[T]) Sum(key T) int { + m, ok := cm.Map[key] + if !ok { + return 0 + } + return lo.Sum(m) +} diff --git a/internal/utils/convertx/number.go b/internal/utils/convertx/number.go new file mode 100644 index 0000000..9f96c84 --- /dev/null +++ b/internal/utils/convertx/number.go @@ -0,0 +1,31 @@ +package convertx + +import "strconv" + +func Int642String(i int64) string { + return strconv.FormatInt(i, 10) +} + +func String2Float64(s string, defValue ...float64) float64 { + f64, err := strconv.ParseFloat(s, 64) + if err != nil { + return defValue[0] + } + return f64 +} + +func String2Int(s string, defValue ...int) int { + i64, err := strconv.Atoi(s) + if err != nil { + return defValue[0] + } + return i64 +} + +func String2Int64(s string, defValue ...int64) int64 { + i64, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return defValue[0] + } + return i64 +} diff --git a/internal/utils/envx/envx.go b/internal/utils/envx/envx.go new file mode 100644 index 0000000..47a12c5 --- /dev/null +++ b/internal/utils/envx/envx.go @@ -0,0 +1,47 @@ +package envx + +import ( + "encoding/json" + "errors" + "github.com/rs/zerolog/log" + "me.thuanle/bbot/internal/configs" + "os" +) + +func GetEnvJsonArray[T any](key string) ([]T, error) { + values, found := os.LookupEnv(key) + if !found { + return nil, errors.New("env not found. Key: " + key) + } + var des []T + err := json.Unmarshal([]byte(values), &des) + if configs.LogEnv { + if err != nil { + log.Error().Err(err). + Str("key", key). + Str("value", values). + Msg("Failed to load env") + } else { + log.Info(). + Str("key", key). + Interface("value", des). + Msg("Loaded env") + } + } + return des, err +} + +func GetEnvBool(key string) (bool, error) { + value, found := os.LookupEnv(key) + if !found { + return false, errors.New("env not found. Key: " + key) + } + val := value == "true" || value == "1" + if configs.LogEnv { + log.Info(). + Str("key", key). + Bool("value", val). + Msg("Loaded env") + } + return val, nil +} diff --git a/internal/utils/errorx/error.go b/internal/utils/errorx/error.go new file mode 100644 index 0000000..a434fb1 --- /dev/null +++ b/internal/utils/errorx/error.go @@ -0,0 +1,9 @@ +package errorx + +import "github.com/rs/zerolog/log" + +func PanicOnError(err error, msg string) { + if err != nil { + log.Panic().Err(err).Msg(msg) + } +} diff --git a/internal/utils/mathx/maxmin.go b/internal/utils/mathx/maxmin.go new file mode 100644 index 0000000..3ef4337 --- /dev/null +++ b/internal/utils/mathx/maxmin.go @@ -0,0 +1,23 @@ +package mathx + +import "cmp" + +func MaxN[T cmp.Ordered](a T, b ...T) T { + m := a + for _, v := range b { + if v > m { + m = v + } + } + return m +} + +func MinN[T cmp.Ordered](a T, b ...T) T { + m := a + for _, v := range b { + if v < m { + m = v + } + } + return m +} diff --git a/internal/utils/netx/ip.go b/internal/utils/netx/ip.go new file mode 100644 index 0000000..7db66b1 --- /dev/null +++ b/internal/utils/netx/ip.go @@ -0,0 +1,21 @@ +package netx + +import ( + "io" + "net/http" +) + +func GetPublicIp() string { + req, err := http.Get("http://ip.thuanle.me") + if err != nil { + return err.Error() + } + defer req.Body.Close() + + body, err := io.ReadAll(req.Body) + if err != nil { + return err.Error() + } + + return string(body) +} diff --git a/internal/utils/opx/ternary.go b/internal/utils/opx/ternary.go new file mode 100644 index 0000000..8ca5c0c --- /dev/null +++ b/internal/utils/opx/ternary.go @@ -0,0 +1,10 @@ +package opx + +// Ite If-then-else +func Ite[T any](condition bool, trueValue T, falseValue T) T { + if condition { + return trueValue + } else { + return falseValue + } +} diff --git a/internal/utils/slicex/slice.go b/internal/utils/slicex/slice.go new file mode 100644 index 0000000..598a7fe --- /dev/null +++ b/internal/utils/slicex/slice.go @@ -0,0 +1,9 @@ +package slicex + +import "sort" + +func Sort[T any](slice []T, compareTo func(o1, o2 T) bool) { + sort.Slice(slice, func(i, j int) bool { + return compareTo(slice[i], slice[j]) + }) +} diff --git a/internal/utils/stringx/replace.go b/internal/utils/stringx/replace.go new file mode 100644 index 0000000..fe37f15 --- /dev/null +++ b/internal/utils/stringx/replace.go @@ -0,0 +1,11 @@ +package stringx + +import "strings" + +func ReplaceSuffix(s string, suffix string, newSuffix string) (string, bool) { + if strings.HasSuffix(s, suffix) { + replaced := s[:len(s)-len(suffix)] + newSuffix + return replaced, true + } + return s, false +} diff --git a/internal/utils/stringx/validation.go b/internal/utils/stringx/validation.go new file mode 100644 index 0000000..9a65046 --- /dev/null +++ b/internal/utils/stringx/validation.go @@ -0,0 +1,9 @@ +package stringx + +import "regexp" + +func IsAlphaNumeric(s string) bool { + match, _ := regexp.MatchString("^[a-zA-Z0-9]*$", s) + return match + +} diff --git a/internal/utils/timex/cd.go b/internal/utils/timex/cd.go new file mode 100644 index 0000000..d686d9f --- /dev/null +++ b/internal/utils/timex/cd.go @@ -0,0 +1,19 @@ +package timex + +import ( + "strings" + "time" +) + +func CdMinuteStringDuration(dur time.Duration) string { + dur -= dur % time.Minute + rString := dur.String() + if strings.HasSuffix(rString, "0s") { + rString = rString[:len(rString)-2] + } + return rString +} + +func CdMinuteStringTime(t time.Time) string { + return CdMinuteStringDuration(-time.Since(t)) +} diff --git a/internal/utils/typex/pair.go b/internal/utils/typex/pair.go new file mode 100644 index 0000000..d0f43e8 --- /dev/null +++ b/internal/utils/typex/pair.go @@ -0,0 +1,6 @@ +package typex + +type Pair[T any, V any] struct { + First T + Second V +}