init code
This commit is contained in:
21
internal/configs/binance/asset.go
Normal file
21
internal/configs/binance/asset.go
Normal file
@@ -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",
|
||||
}
|
||||
5
internal/configs/config.go
Normal file
5
internal/configs/config.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package configs
|
||||
|
||||
var (
|
||||
LogEnv = true
|
||||
)
|
||||
7
internal/configs/key/env.go
Normal file
7
internal/configs/key/env.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package key
|
||||
|
||||
const (
|
||||
LogEnv = "LOG_ENV"
|
||||
|
||||
TelegramToken = "TELEGRAM_TOKEN"
|
||||
)
|
||||
20
internal/configs/strategy/market.go
Normal file
20
internal/configs/strategy/market.go
Normal file
@@ -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
|
||||
)
|
||||
17
internal/configs/tele/sticker.go
Normal file
17
internal/configs/tele/sticker.go
Normal file
@@ -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",
|
||||
}
|
||||
8
internal/data/imarket.go
Normal file
8
internal/data/imarket.go
Normal file
@@ -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)
|
||||
}
|
||||
50
internal/data/market/future_price.go
Normal file
50
internal/data/market/future_price.go
Normal file
@@ -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
|
||||
}
|
||||
25
internal/data/market/main.go
Normal file
25
internal/data/market/main.go
Normal file
@@ -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
|
||||
}
|
||||
36
internal/data/market/spot_price.go
Normal file
36
internal/data/market/spot_price.go
Normal file
@@ -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()
|
||||
}
|
||||
15
internal/data/repo.go
Normal file
15
internal/data/repo.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package data
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
Market IMarket
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func NewRepository(market IMarket) {
|
||||
once.Do(
|
||||
func() {
|
||||
Market = market
|
||||
})
|
||||
}
|
||||
12
internal/deps/data.go
Normal file
12
internal/deps/data.go
Normal file
@@ -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)
|
||||
}
|
||||
95
internal/helper/binancex/symbol.go
Normal file
95
internal/helper/binancex/symbol.go
Normal file
@@ -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
|
||||
}
|
||||
27
internal/load.go
Normal file
27
internal/load.go
Normal file
@@ -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)
|
||||
}
|
||||
52
internal/services/controllers/market.go
Normal file
52
internal/services/controllers/market.go
Normal file
@@ -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
|
||||
}
|
||||
9
internal/services/tele/chat/helper/wrap.go
Normal file
9
internal/services/tele/chat/helper/wrap.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package helper
|
||||
|
||||
func Pre(msg string) string {
|
||||
return "<pre>" + msg + "</pre>"
|
||||
}
|
||||
|
||||
func Code(msg string) string {
|
||||
return "<code>" + msg + "</code>"
|
||||
}
|
||||
39
internal/services/tele/chat/reply.go
Normal file
39
internal/services/tele/chat/reply.go
Normal file
@@ -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))
|
||||
}
|
||||
45
internal/services/tele/command.go
Normal file
45
internal/services/tele/command.go
Normal file
@@ -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
|
||||
}
|
||||
43
internal/services/tele/commands/chat.go
Normal file
43
internal/services/tele/commands/chat.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
12
internal/services/tele/commands/general.go
Normal file
12
internal/services/tele/commands/general.go
Normal file
@@ -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)
|
||||
}
|
||||
5
internal/services/tele/commands/helper/error.go
Normal file
5
internal/services/tele/commands/helper/error.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package helper
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrorParamCountMismatch = errors.New("param count mismatched")
|
||||
41
internal/services/tele/commands/helper/handler.go
Normal file
41
internal/services/tele/commands/helper/handler.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
18
internal/services/tele/commands/market.go
Normal file
18
internal/services/tele/commands/market.go
Normal file
@@ -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))
|
||||
}
|
||||
11
internal/services/tele/commands/start.go
Normal file
11
internal/services/tele/commands/start.go
Normal file
@@ -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)
|
||||
}
|
||||
49
internal/services/tele/commands/token.go
Normal file
49
internal/services/tele/commands/token.go
Normal file
@@ -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)
|
||||
}
|
||||
19
internal/services/tele/middlewares/error.go
Normal file
19
internal/services/tele/middlewares/error.go
Normal file
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
14
internal/services/tele/middlewares/ignore_bot.go
Normal file
14
internal/services/tele/middlewares/ignore_bot.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
51
internal/services/tele/tele_service.go
Normal file
51
internal/services/tele/tele_service.go
Normal file
@@ -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()
|
||||
}
|
||||
34
internal/services/tele/view/fee.go
Normal file
34
internal/services/tele/view/fee.go
Normal file
@@ -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()
|
||||
}
|
||||
27
internal/services/tele/view/helper/table.go
Normal file
27
internal/services/tele/view/helper/table.go
Normal file
@@ -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
|
||||
}
|
||||
60
internal/services/tele/view/price.go
Normal file
60
internal/services/tele/view/price.go
Normal file
@@ -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()
|
||||
}
|
||||
11
internal/services/tele/view/symbol.go
Normal file
11
internal/services/tele/view/symbol.go
Normal file
@@ -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("<a href=\"https://www.binance.com/futures/%s\">%s</a>", sym, token)
|
||||
}
|
||||
42
internal/utils/collectionx/count_map.go
Normal file
42
internal/utils/collectionx/count_map.go
Normal file
@@ -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)
|
||||
}
|
||||
31
internal/utils/convertx/number.go
Normal file
31
internal/utils/convertx/number.go
Normal file
@@ -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
|
||||
}
|
||||
47
internal/utils/envx/envx.go
Normal file
47
internal/utils/envx/envx.go
Normal file
@@ -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
|
||||
}
|
||||
9
internal/utils/errorx/error.go
Normal file
9
internal/utils/errorx/error.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
23
internal/utils/mathx/maxmin.go
Normal file
23
internal/utils/mathx/maxmin.go
Normal file
@@ -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
|
||||
}
|
||||
21
internal/utils/netx/ip.go
Normal file
21
internal/utils/netx/ip.go
Normal file
@@ -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)
|
||||
}
|
||||
10
internal/utils/opx/ternary.go
Normal file
10
internal/utils/opx/ternary.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
9
internal/utils/slicex/slice.go
Normal file
9
internal/utils/slicex/slice.go
Normal file
@@ -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])
|
||||
})
|
||||
}
|
||||
11
internal/utils/stringx/replace.go
Normal file
11
internal/utils/stringx/replace.go
Normal file
@@ -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
|
||||
}
|
||||
9
internal/utils/stringx/validation.go
Normal file
9
internal/utils/stringx/validation.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package stringx
|
||||
|
||||
import "regexp"
|
||||
|
||||
func IsAlphaNumeric(s string) bool {
|
||||
match, _ := regexp.MatchString("^[a-zA-Z0-9]*$", s)
|
||||
return match
|
||||
|
||||
}
|
||||
19
internal/utils/timex/cd.go
Normal file
19
internal/utils/timex/cd.go
Normal file
@@ -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))
|
||||
}
|
||||
6
internal/utils/typex/pair.go
Normal file
6
internal/utils/typex/pair.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package typex
|
||||
|
||||
type Pair[T any, V any] struct {
|
||||
First T
|
||||
Second V
|
||||
}
|
||||
Reference in New Issue
Block a user