8 Commits

Author SHA1 Message Date
renovatebot 0579a1163c Update module github.com/jedib0t/go-pretty/v6 to v6.7.10 2026-04-26 16:49:40 +07:00
thuanle ee6c181cd6 Merge pull request 'Remove ETH special portfolio tracking' (#16) from chore/remove-eth-special-case into main
Build Docker Image / build (amd64) (push) Successful in 1m0s
Reviewed-on: #16
2026-04-26 16:45:36 +07:00
thuanle 4cab108549 Merge branch 'main' into chore/remove-eth-special-case 2026-04-26 16:45:27 +07:00
thuanle 733cf48e41 Merge pull request 'Rewrite price lookup from WebSocket to REST API' (#15) from feat/rest-price-lookup into main
Build Docker Image / build (amd64) (push) Successful in 1m37s
Reviewed-on: #15
2026-04-26 16:19:28 +07:00
thuanle 914beea5ce Address PR #15 round 2: fail-closed admin guard, sync init, error reporting
1. /refresh now fail-closed: rejects all if ADMIN_CHAT_ID unset or invalid
2. Initial pair cache fill is synchronous — bot waits before accepting queries
3. /refresh reports failure when API fetch fails instead of always saying success

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 16:03:22 +07:00
thuanle c7128ff516 Address PR #15 review: batch API calls and admin-guard /refresh
1. Add GetAllPremiumIndex() to fetch all futures data in one call,
   used by GetTopPrices instead of per-symbol sequential calls.
2. Add ADMIN_CHAT_ID env check to /refresh command to restrict
   access to authorized users only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 15:40:00 +07:00
thuanle 59da49c17f Remove ETH special portfolio tracking
Treat ETH like any other token — display only spot/future price
and funding rate, removing personal portfolio calculations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 15:14:39 +07:00
thuanle 9c39423315 Rewrite price lookup from WebSocket to REST API
Replace unreliable WebSocket connections with on-demand REST API calls
for spot and futures prices. Add cached trading pair list (refreshed
hourly) for symbol validation, and /refresh command for manual updates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 15:14:09 +07:00
11 changed files with 211 additions and 143 deletions
+1
View File
@@ -4,4 +4,5 @@ const (
LogEnv = "LOG_ENV" LogEnv = "LOG_ENV"
TelegramToken = "TELEGRAM_TOKEN" TelegramToken = "TELEGRAM_TOKEN"
AdminChatID = "ADMIN_CHAT_ID"
) )
+6
View File
@@ -4,6 +4,7 @@ import "me.thuanle/bbot/internal/data/market"
type IMarket interface { type IMarket interface {
GetFuturePrice(symbol string) (float64, float64, int64, bool) GetFuturePrice(symbol string) (float64, float64, int64, bool)
GetAllPremiumIndex() (map[string]market.PremiumIndex, error)
GetAllFundRate() (map[string]float64, map[string]int64) GetAllFundRate() (map[string]float64, map[string]int64)
GetSpotPrice(symbol string) (float64, bool) GetSpotPrice(symbol string) (float64, bool)
@@ -12,4 +13,9 @@ type IMarket interface {
// Alpha token methods // Alpha token methods
IsAlphaToken(symbol string) bool IsAlphaToken(symbol string) bool
GetAlphaToken(symbol string) (market.AlphaTokenInfo, bool) GetAlphaToken(symbol string) (market.AlphaTokenInfo, bool)
// Trading pair methods
IsSpotPair(symbol string) bool
IsFuturesPair(symbol string) bool
RefreshTradingPairCache() error
} }
+58 -37
View File
@@ -1,64 +1,85 @@
package market package market
import ( import (
"github.com/adshao/go-binance/v2/futures" "context"
"github.com/rs/zerolog/log"
"strconv" "strconv"
"time"
"github.com/rs/zerolog/log"
) )
func (ms *MarketData) GetFuturePrice(symbol string) (float64, float64, int64, bool) { func (ms *MarketData) GetFuturePrice(symbol string) (float64, float64, int64, bool) {
ms.mu.RLock() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer ms.mu.RUnlock() defer cancel()
p, ok := ms.futureMarkPrice[symbol]
if !ok { premiums, err := ms.futuresClient.NewPremiumIndexService().Symbol(symbol).Do(ctx)
if err != nil {
log.Error().Err(err).Str("symbol", symbol).Msg("Failed to fetch futures premium index")
return 0, 0, 0, false return 0, 0, 0, false
} }
return p, ms.futureFundingRate[symbol], ms.futureNextFundingTime[symbol], true
if len(premiums) == 0 {
return 0, 0, 0, false
} }
func (ms *MarketData) StartFutureWsMarkPrice() error { p := premiums[0]
_, _, err := futures.WsAllMarkPriceServe(ms.futureWsMarkPriceHandler, ms.futureWsErrHandler) markPrice, err := strconv.ParseFloat(p.MarkPrice, 64)
if err != nil { if err != nil {
return err return 0, 0, 0, false
}
return nil
} }
func (ms *MarketData) futureWsMarkPriceHandler(event futures.WsAllMarkPriceEvent) { fundingRate, err := strconv.ParseFloat(p.LastFundingRate, 64)
ms.mu.Lock() if err != nil {
defer ms.mu.Unlock() fundingRate = 0
for _, priceEvent := range event { }
price, err := strconv.ParseFloat(priceEvent.MarkPrice, 64)
return markPrice, fundingRate, p.NextFundingTime, true
}
type PremiumIndex struct {
MarkPrice float64
FundingRate float64
NextFundingTime int64
}
func (ms *MarketData) GetAllPremiumIndex() (map[string]PremiumIndex, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
premiums, err := ms.futuresClient.NewPremiumIndexService().Do(ctx)
if err != nil {
log.Error().Err(err).Msg("Failed to fetch all futures premium index")
return nil, err
}
result := make(map[string]PremiumIndex, len(premiums))
for _, p := range premiums {
markPrice, err := strconv.ParseFloat(p.MarkPrice, 64)
if err != nil { if err != nil {
continue continue
} }
rate, _ := strconv.ParseFloat(p.LastFundingRate, 64)
fundingRate, err := strconv.ParseFloat(priceEvent.FundingRate, 64) result[p.Symbol] = PremiumIndex{
if err != nil { MarkPrice: markPrice,
continue FundingRate: rate,
} NextFundingTime: p.NextFundingTime,
ms.futureMarkPrice[priceEvent.Symbol] = price
ms.futureFundingRate[priceEvent.Symbol] = fundingRate
ms.futureNextFundingTime[priceEvent.Symbol] = priceEvent.NextFundingTime
} }
} }
func (ms *MarketData) futureWsErrHandler(err error) { return result, nil
log.Debug().Err(err).Msg("Ws Error. Restart socket")
_ = ms.StartFutureWsMarkPrice()
} }
func (ms *MarketData) GetAllFundRate() (map[string]float64, map[string]int64) { func (ms *MarketData) GetAllFundRate() (map[string]float64, map[string]int64) {
ms.mu.RLock() all, err := ms.GetAllPremiumIndex()
defer ms.mu.RUnlock() if err != nil {
rates := make(map[string]float64, len(ms.futureFundingRate)) return make(map[string]float64), make(map[string]int64)
for k, v := range ms.futureFundingRate {
rates[k] = v
} }
times := make(map[string]int64, len(ms.futureNextFundingTime))
for k, v := range ms.futureNextFundingTime { rates := make(map[string]float64, len(all))
times[k] = v times := make(map[string]int64, len(all))
for sym, p := range all {
rates[sym] = p.FundingRate
times[sym] = p.NextFundingTime
} }
return rates, times return rates, times
} }
+19 -14
View File
@@ -4,37 +4,42 @@ import (
"sync" "sync"
"time" "time"
"github.com/adshao/go-binance/v2"
"github.com/adshao/go-binance/v2/futures"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
type MarketData struct { type MarketData struct {
mu sync.RWMutex // Trading pair caches
futureMarkPrice map[string]float64 spotPairs map[string]bool
futureFundingRate map[string]float64 futuresPairs map[string]bool
futureNextFundingTime map[string]int64 pairCacheMutex sync.RWMutex
lastPairCacheUpdate time.Time
spotPrice map[string]float64
// Alpha token cache // Alpha token cache
alphaTokens map[string]AlphaTokenInfo alphaTokens map[string]AlphaTokenInfo
alphaCacheMutex sync.RWMutex alphaCacheMutex sync.RWMutex
lastAlphaCacheUpdate time.Time lastAlphaCacheUpdate time.Time
// Binance REST clients
spotClient *binance.Client
futuresClient *futures.Client
} }
func NewMarketData() *MarketData { func NewMarketData() *MarketData {
log.Info().Msg("Start market service") log.Info().Msg("Start market service")
ms := &MarketData{ ms := &MarketData{
futureMarkPrice: make(map[string]float64), spotPairs: make(map[string]bool),
futureFundingRate: make(map[string]float64), futuresPairs: make(map[string]bool),
futureNextFundingTime: make(map[string]int64),
spotPrice: make(map[string]float64),
alphaTokens: make(map[string]AlphaTokenInfo), alphaTokens: make(map[string]AlphaTokenInfo),
spotClient: binance.NewClient("", ""),
futuresClient: futures.NewClient("", ""),
} }
_ = ms.StartFutureWsMarkPrice()
_ = ms.StartSpotWsMarkPrice()
// Initialize Alpha token cache and refresh every hour if err := ms.refreshTradingPairCache(); err != nil {
log.Error().Err(err).Msg("Failed initial trading pair cache load")
}
go ms.pairCacheRefreshLoop()
go ms.alphaCacheRefreshLoop() go ms.alphaCacheRefreshLoop()
return ms return ms
+22 -34
View File
@@ -1,21 +1,36 @@
package market package market
import ( import (
"context"
"strconv" "strconv"
"time"
"github.com/adshao/go-binance/v2"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
func (ms *MarketData) GetSpotPrice(symbol string) (float64, bool) { func (ms *MarketData) GetSpotPrice(symbol string) (float64, bool) {
ms.mu.RLock() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
p, ok := ms.spotPrice[symbol] defer cancel()
ms.mu.RUnlock()
if ok { prices, err := ms.spotClient.NewListPricesService().Symbol(symbol).Do(ctx)
return p, true if err != nil {
log.Error().Err(err).Str("symbol", symbol).Msg("Failed to fetch spot price")
return ms.getAlphaPrice(symbol)
} }
// If not found, check if it's an Alpha token if len(prices) == 0 {
return ms.getAlphaPrice(symbol)
}
price, err := strconv.ParseFloat(prices[0].Price, 64)
if err != nil || price == 0 {
return ms.getAlphaPrice(symbol)
}
return price, true
}
func (ms *MarketData) getAlphaPrice(symbol string) (float64, bool) {
if ms.IsAlphaToken(symbol) { if ms.IsAlphaToken(symbol) {
if alphaToken, exists := ms.GetAlphaToken(symbol); exists { if alphaToken, exists := ms.GetAlphaToken(symbol); exists {
if price := alphaToken.GetPrice(); price > 0 { if price := alphaToken.GetPrice(); price > 0 {
@@ -23,32 +38,5 @@ func (ms *MarketData) GetSpotPrice(symbol string) (float64, bool) {
} }
} }
} }
return 0, false return 0, false
} }
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) {
ms.mu.Lock()
defer ms.mu.Unlock()
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()
}
+74
View File
@@ -0,0 +1,74 @@
package market
import (
"context"
"time"
"github.com/rs/zerolog/log"
)
func (ms *MarketData) refreshTradingPairCache() error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
spotInfo, err := ms.spotClient.NewExchangeInfoService().Do(ctx)
if err != nil {
log.Error().Err(err).Msg("Failed to fetch spot exchange info")
return err
}
futuresInfo, err := ms.futuresClient.NewExchangeInfoService().Do(ctx)
if err != nil {
log.Error().Err(err).Msg("Failed to fetch futures exchange info")
return err
}
ms.pairCacheMutex.Lock()
defer ms.pairCacheMutex.Unlock()
ms.spotPairs = make(map[string]bool, len(spotInfo.Symbols))
for _, s := range spotInfo.Symbols {
if s.Status == "TRADING" {
ms.spotPairs[s.Symbol] = true
}
}
ms.futuresPairs = make(map[string]bool, len(futuresInfo.Symbols))
for _, s := range futuresInfo.Symbols {
if s.Status == "TRADING" {
ms.futuresPairs[s.Symbol] = true
}
}
ms.lastPairCacheUpdate = time.Now()
log.Info().
Int("spot", len(ms.spotPairs)).
Int("futures", len(ms.futuresPairs)).
Msg("Trading pair cache refreshed")
return nil
}
func (ms *MarketData) pairCacheRefreshLoop() {
ms.refreshTradingPairCache()
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
ms.refreshTradingPairCache()
}
}
func (ms *MarketData) IsSpotPair(symbol string) bool {
ms.pairCacheMutex.RLock()
defer ms.pairCacheMutex.RUnlock()
return ms.spotPairs[symbol]
}
func (ms *MarketData) IsFuturesPair(symbol string) bool {
ms.pairCacheMutex.RLock()
defer ms.pairCacheMutex.RUnlock()
return ms.futuresPairs[symbol]
}
func (ms *MarketData) RefreshTradingPairCache() error {
return ms.refreshTradingPairCache()
}
+1 -2
View File
@@ -29,8 +29,7 @@ var (
) )
func testSym(sym string) bool { func testSym(sym string) bool {
_, _, _, test := data.Market.GetFuturePrice(sym) return data.Market.IsFuturesPair(sym)
return test
} }
func Token2Symbols(token string) []string { func Token2Symbols(token string) []string {
+8 -4
View File
@@ -13,15 +13,19 @@ func GetTopPrices() ([]string, []float64, []float64) {
topPrice := make([]float64, n) topPrice := make([]float64, n)
topRate := make([]float64, n) topRate := make([]float64, n)
all, err := data.Market.GetAllPremiumIndex()
if err != nil {
return topSym, topPrice, topRate
}
for i, sym := range strategy.TopPriceSymbols { for i, sym := range strategy.TopPriceSymbols {
price, rate, _, ok := data.Market.GetFuturePrice(sym) p, ok := all[sym]
if !ok { if !ok {
continue continue
} }
topSym[i] = sym topSym[i] = sym
topPrice[i] = price topPrice[i] = p.MarkPrice
topRate[i] = rate topRate[i] = p.FundingRate
} }
return topSym, topPrice, topRate return topSym, topPrice, topRate
} }
+5
View File
@@ -16,6 +16,10 @@ var commandList = []telebot.Command{
Text: "fee", Text: "fee",
Description: "(f) - show top funding fee", Description: "(f) - show top funding fee",
}, },
{
Text: "refresh",
Description: "Refresh trading pair cache",
},
} }
func setupCommands(b *telebot.Bot) error { func setupCommands(b *telebot.Bot) error {
@@ -36,6 +40,7 @@ func setupCommands(b *telebot.Bot) error {
//info //info
b.Handle("/p", commands.OnGetTopPrices) b.Handle("/p", commands.OnGetTopPrices)
b.Handle("/fee", commands.OnGetTopFundingFee) b.Handle("/fee", commands.OnGetTopFundingFee)
b.Handle("/refresh", commands.OnRefreshPairCache)
//any text //any text
b.Handle(telebot.OnText, commands.OnChatHandler) b.Handle(telebot.OnText, commands.OnChatHandler)
+16
View File
@@ -1,7 +1,12 @@
package commands package commands
import ( import (
"os"
"strconv"
"gopkg.in/telebot.v3" "gopkg.in/telebot.v3"
"me.thuanle/bbot/internal/configs/key"
"me.thuanle/bbot/internal/data"
"me.thuanle/bbot/internal/services/controllers" "me.thuanle/bbot/internal/services/controllers"
"me.thuanle/bbot/internal/services/tele/chat" "me.thuanle/bbot/internal/services/tele/chat"
"me.thuanle/bbot/internal/services/tele/view" "me.thuanle/bbot/internal/services/tele/view"
@@ -16,3 +21,14 @@ func OnGetTopFundingFee(context telebot.Context) error {
fee, float64s, cds := controllers.GetTopFundingFee() fee, float64s, cds := controllers.GetTopFundingFee()
return chat.ReplyMessagePre(context, view.RenderOnGetTopFundingFeeMessage(fee, float64s, cds)) return chat.ReplyMessagePre(context, view.RenderOnGetTopFundingFeeMessage(fee, float64s, cds))
} }
func OnRefreshPairCache(context telebot.Context) error {
adminID, err := strconv.ParseInt(os.Getenv(key.AdminChatID), 10, 64)
if err != nil || adminID == 0 || context.Sender().ID != adminID {
return nil
}
if err := data.Market.RefreshTradingPairCache(); err != nil {
return chat.ReplyMessage(context, "Failed to refresh trading pair cache")
}
return chat.ReplyMessage(context, "Trading pair cache refreshed")
}
-51
View File
@@ -3,8 +3,6 @@ package commands
import ( import (
"strings" "strings"
"golang.org/x/text/language"
"golang.org/x/text/message"
"gopkg.in/telebot.v3" "gopkg.in/telebot.v3"
"me.thuanle/bbot/internal/configs/tele" "me.thuanle/bbot/internal/configs/tele"
"me.thuanle/bbot/internal/data" "me.thuanle/bbot/internal/data"
@@ -13,8 +11,6 @@ import (
"me.thuanle/bbot/internal/services/tele/view" "me.thuanle/bbot/internal/services/tele/view"
) )
var lastEthPrice float64
func showStickerMode(context telebot.Context, token string) { func showStickerMode(context telebot.Context, token string) {
token = strings.ToUpper(token) token = strings.ToUpper(token)
stickerIdx, ok := tele.Token2StickerIdxMap[token] stickerIdx, ok := tele.Token2StickerIdxMap[token]
@@ -60,53 +56,6 @@ func OnTokenInfoByToken(context telebot.Context, token string) error {
sp, _ := data.Market.GetSpotPrice(sSymbol) sp, _ := data.Market.GetSpotPrice(sSymbol)
_ = chat.ReplyMessage(context, view.RenderOnPriceMessage(symbols[0], sp, fp, fundRate, fundTime, tokenInterestRate)) _ = chat.ReplyMessage(context, view.RenderOnPriceMessage(symbols[0], sp, fp, fundRate, fundTime, tokenInterestRate))
if strings.ToUpper(token) == "ETH" {
mFmt := message.NewPrinter(language.AmericanEnglish)
realAmount := 35.
trangBucAmount := 14.
basePrice := 2500.0
baseTotal := realAmount * basePrice
trangBucTotal := trangBucAmount * basePrice
realCurTotal := realAmount * sp
trangBucCurTotal := trangBucAmount * sp
lastDelta := ""
if lastEthPrice == 0 {
lastEthPrice = sp
} else {
lastDelta = mFmt.Sprintf(
"Δ price: $%+.0f\n"+
"Δ Usdt: $%+.0f\n",
sp-lastEthPrice,
(sp-lastEthPrice)*realAmount,
)
lastEthPrice = sp
}
msg := mFmt.Sprintf(
"🎉🎊🎊🦈🦈🦈 @th13vn Real 🦈🦈🦈🎊🎊🎉\n"+
"∑ USDT: $%.0f\n"+
"Lợi nhuận: $%.0f\n"+
"%s\n"+
"\n"+
"🚀🚀🚀🚀🚀 Road to 5k 🚀🚀🚀🚀🚀: \n"+
"- Δ Price: $%0.0f\n"+
"- Δ Vol: $%0.0f\n"+
"\n"+
"💸💸💸💸💸 Trang Bức balance 💸💸💸💸💸\n"+
"∑ USDT: $%.0f\n"+
"Lợi nhuận: $%.0f\n",
realCurTotal,
realCurTotal-baseTotal,
lastDelta,
5000-sp,
5000*realAmount-realCurTotal,
trangBucCurTotal,
trangBucCurTotal-trangBucTotal,
)
_ = chat.ReplyMessage(context, msg)
}
return nil return nil
} }