From 711721c1eea6b055ad7f3615b60b3d5f45642cda Mon Sep 17 00:00:00 2001 From: thuanle Date: Mon, 27 Apr 2026 04:44:30 +0700 Subject: [PATCH] refactor: use canonical token-to-symbol maps for market lookup Build canonical spot/future token maps with quote priority, unify cache refresh scheduling, and switch resolver/token tests to map-based token lookups. Co-Authored-By: Claude Opus 4.7 --- internal/configs/binance/asset.go | 13 +- internal/data/imarket.go | 2 + internal/data/market/main.go | 21 ++- internal/data/market/trading_pairs.go | 175 +++++++++++++++--- internal/data/market/trading_pairs_test.go | 41 ++++ internal/helper/binancex/resolver.go | 67 +++---- internal/helper/binancex/resolver_test.go | 55 +++--- internal/helper/binancex/symbol.go | 8 +- internal/services/tele/commands/token_test.go | 22 ++- 9 files changed, 296 insertions(+), 108 deletions(-) create mode 100644 internal/data/market/trading_pairs_test.go diff --git a/internal/configs/binance/asset.go b/internal/configs/binance/asset.go index 0d06e06..f7fa800 100644 --- a/internal/configs/binance/asset.go +++ b/internal/configs/binance/asset.go @@ -2,13 +2,16 @@ package binance var SymbolPrefixList = []string{"1000000", "1000", "1M"} -var SymbolSuffixList = []string{"USDT", "USDC"} +var SymbolSuffixList = []string{"USDT", "USDC", "FDUSD"} var SymbolSuffixMap = map[string]string{ - "USDT": "", - "USDC": "c", + "USDT": "", + "USDC": "c", + "FDUSD": "fd", } -var Future2SpotSymbolMap = map[string]string{ - "LUNA2USDT": "LUNAUSDT", +var QuotePriority = []string{"USDT", "USDC", "FDUSD"} + +var FutureToken2SpotTokenMap = map[string]string{ + "LUNA2": "LUNA", } diff --git a/internal/data/imarket.go b/internal/data/imarket.go index 1582529..6b42f24 100644 --- a/internal/data/imarket.go +++ b/internal/data/imarket.go @@ -18,5 +18,7 @@ type IMarket interface { // Trading pair methods IsSpotPair(symbol string) bool IsFuturesPair(symbol string) bool + GetSpotSymbolByToken(token string) (string, bool) + GetFutureSymbolByToken(token string) (string, bool) RefreshTradingPairCache() error } diff --git a/internal/data/market/main.go b/internal/data/market/main.go index df1418d..8acf819 100644 --- a/internal/data/market/main.go +++ b/internal/data/market/main.go @@ -13,6 +13,8 @@ type MarketData struct { // Trading pair caches spotPairs map[string]bool futuresPairs map[string]bool + spotToken2Symbol map[string]string + futureToken2Symbol map[string]string pairCacheMutex sync.RWMutex lastPairCacheUpdate time.Time @@ -29,18 +31,17 @@ type MarketData struct { func NewMarketData() *MarketData { log.Info().Msg("Start market service") ms := &MarketData{ - spotPairs: make(map[string]bool), - futuresPairs: make(map[string]bool), - alphaTokens: make(map[string]AlphaTokenInfo), - spotClient: binance.NewClient("", ""), - futuresClient: futures.NewClient("", ""), + spotPairs: make(map[string]bool), + futuresPairs: make(map[string]bool), + spotToken2Symbol: make(map[string]string), + futureToken2Symbol: make(map[string]string), + alphaTokens: make(map[string]AlphaTokenInfo), + spotClient: binance.NewClient("", ""), + futuresClient: futures.NewClient("", ""), } - if err := ms.refreshTradingPairCache(); err != nil { - log.Error().Err(err).Msg("Failed initial trading pair cache load") - } - go ms.pairCacheRefreshLoop() - go ms.alphaCacheRefreshLoop() + ms.refreshAllCaches() + go ms.cacheRefreshLoop() return ms } diff --git a/internal/data/market/trading_pairs.go b/internal/data/market/trading_pairs.go index a064a9b..aa6aa3d 100644 --- a/internal/data/market/trading_pairs.go +++ b/internal/data/market/trading_pairs.go @@ -2,12 +2,15 @@ package market import ( "context" + "sort" + "strings" "time" "github.com/rs/zerolog/log" + "me.thuanle/bbot/internal/configs/binance" ) -func (ms *MarketData) refreshTradingPairCache() error { +func (ms *MarketData) refreshSpotPairCache() error { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -17,46 +20,162 @@ func (ms *MarketData) refreshTradingPairCache() error { return err } + spotPairs := make(map[string]bool, len(spotInfo.Symbols)) + spotTokenCandidates := make(map[string][]string) + for _, s := range spotInfo.Symbols { + if s.Status != "TRADING" { + continue + } + spotPairs[s.Symbol] = true + token := parseTokenFromSymbolByQuotePriority(s.Symbol) + if token == "" { + continue + } + spotTokenCandidates[token] = append(spotTokenCandidates[token], s.Symbol) + } + + spotToken2Symbol := make(map[string]string, len(spotTokenCandidates)) + for token, candidates := range spotTokenCandidates { + spotToken2Symbol[token] = selectCanonicalSymbolByQuotePriority(token, candidates) + } + + ms.pairCacheMutex.Lock() + ms.spotPairs = spotPairs + ms.spotToken2Symbol = spotToken2Symbol + ms.lastPairCacheUpdate = time.Now() + ms.pairCacheMutex.Unlock() + + return nil +} + +func (ms *MarketData) refreshFuturePairCache() error { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + 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)) + futurePairs := make(map[string]bool, len(futuresInfo.Symbols)) + futureTokenCandidates := make(map[string][]string) for _, s := range futuresInfo.Symbols { - if s.Status == "TRADING" { - ms.futuresPairs[s.Symbol] = true + if s.Status != "TRADING" { + continue } + futurePairs[s.Symbol] = true + token := parseTokenFromSymbolByQuotePriority(s.Symbol) + if token == "" { + continue + } + token = normalizeFutureToken(token) + futureTokenCandidates[token] = append(futureTokenCandidates[token], s.Symbol) } + futureToken2Symbol := make(map[string]string, len(futureTokenCandidates)) + for token, candidates := range futureTokenCandidates { + futureToken2Symbol[token] = selectCanonicalSymbolByQuotePriority(token, candidates) + } + + ms.pairCacheMutex.Lock() + ms.futuresPairs = futurePairs + ms.futureToken2Symbol = futureToken2Symbol ms.lastPairCacheUpdate = time.Now() - log.Info(). - Int("spot", len(ms.spotPairs)). - Int("futures", len(ms.futuresPairs)). - Msg("Trading pair cache refreshed") + ms.pairCacheMutex.Unlock() + return nil } -func (ms *MarketData) pairCacheRefreshLoop() { - ms.refreshTradingPairCache() +func (ms *MarketData) refreshTradingPairCache() error { + if err := ms.refreshSpotPairCache(); err != nil { + return err + } + if err := ms.refreshFuturePairCache(); err != nil { + return err + } + + ms.pairCacheMutex.RLock() + spotCount := len(ms.spotPairs) + futureCount := len(ms.futuresPairs) + ms.pairCacheMutex.RUnlock() + + log.Info(). + Int("spot", spotCount). + Int("futures", futureCount). + Msg("Trading pair cache refreshed") + + return nil +} + +func (ms *MarketData) cacheRefreshLoop() { + ms.refreshAllCaches() ticker := time.NewTicker(time.Hour) defer ticker.Stop() for range ticker.C { - ms.refreshTradingPairCache() + ms.refreshAllCaches() } } +func (ms *MarketData) refreshAllCaches() { + if err := ms.refreshSpotPairCache(); err != nil { + log.Error().Err(err).Msg("Failed spot pair refresh") + } + if err := ms.refreshFuturePairCache(); err != nil { + log.Error().Err(err).Msg("Failed futures pair refresh") + } + ms.refreshAlphaTokenCache() +} + +func parseTokenFromSymbolByQuotePriority(symbol string) string { + symbol = strings.ToUpper(symbol) + for _, quote := range binance.QuotePriority { + quote = strings.ToUpper(quote) + if strings.HasSuffix(symbol, quote) { + token := strings.TrimSuffix(symbol, quote) + if token != "" { + return token + } + } + } + return "" +} + +func selectCanonicalSymbolByQuotePriority(token string, candidates []string) string { + if len(candidates) == 0 { + return "" + } + if len(candidates) == 1 { + return strings.ToUpper(candidates[0]) + } + + token = strings.ToUpper(token) + normalized := make([]string, 0, len(candidates)) + for _, c := range candidates { + normalized = append(normalized, strings.ToUpper(c)) + } + + for _, quote := range binance.QuotePriority { + target := token + strings.ToUpper(quote) + for _, c := range normalized { + if c == target { + return c + } + } + } + + sort.Strings(normalized) + return normalized[0] +} + +func normalizeFutureToken(token string) string { + token = strings.ToUpper(token) + if mapped, ok := binance.FutureToken2SpotTokenMap[token]; ok { + return strings.ToUpper(mapped) + } + return token +} + func (ms *MarketData) IsSpotPair(symbol string) bool { ms.pairCacheMutex.RLock() defer ms.pairCacheMutex.RUnlock() @@ -69,6 +188,20 @@ func (ms *MarketData) IsFuturesPair(symbol string) bool { return ms.futuresPairs[symbol] } +func (ms *MarketData) GetSpotSymbolByToken(token string) (string, bool) { + ms.pairCacheMutex.RLock() + defer ms.pairCacheMutex.RUnlock() + sym, ok := ms.spotToken2Symbol[strings.ToUpper(token)] + return sym, ok +} + +func (ms *MarketData) GetFutureSymbolByToken(token string) (string, bool) { + ms.pairCacheMutex.RLock() + defer ms.pairCacheMutex.RUnlock() + sym, ok := ms.futureToken2Symbol[strings.ToUpper(token)] + return sym, ok +} + func (ms *MarketData) RefreshTradingPairCache() error { return ms.refreshTradingPairCache() } diff --git a/internal/data/market/trading_pairs_test.go b/internal/data/market/trading_pairs_test.go new file mode 100644 index 0000000..d70354f --- /dev/null +++ b/internal/data/market/trading_pairs_test.go @@ -0,0 +1,41 @@ +package market + +import "testing" + +func TestSelectCanonicalSymbolByQuotePriority(t *testing.T) { + pairs := []string{"DOGEFDUSD", "DOGEUSDC", "DOGEUSDT"} + got := selectCanonicalSymbolByQuotePriority("DOGE", pairs) + if got != "DOGEUSDT" { + t.Fatalf("expected DOGEUSDT, got %q", got) + } +} + +func TestSelectCanonicalSymbolByQuotePriority_FallbackOrder(t *testing.T) { + pairs := []string{"DOGEFDUSD", "DOGEUSDC"} + got := selectCanonicalSymbolByQuotePriority("DOGE", pairs) + if got != "DOGEUSDC" { + t.Fatalf("expected DOGEUSDC, got %q", got) + } +} + +func TestSelectCanonicalSymbolByQuotePriority_NoPreferredQuote(t *testing.T) { + pairs := []string{"DOGEBUSD"} + got := selectCanonicalSymbolByQuotePriority("DOGE", pairs) + if got != "DOGEBUSD" { + t.Fatalf("expected DOGEBUSD, got %q", got) + } +} + +func TestNormalizeFutureToken_AppliesOverride(t *testing.T) { + got := normalizeFutureToken("LUNA2") + if got != "LUNA" { + t.Fatalf("expected LUNA, got %q", got) + } +} + +func TestNormalizeFutureToken_NoOverride(t *testing.T) { + got := normalizeFutureToken("PEPE") + if got != "PEPE" { + t.Fatalf("expected PEPE, got %q", got) + } +} diff --git a/internal/helper/binancex/resolver.go b/internal/helper/binancex/resolver.go index 788034d..ee67ac6 100644 --- a/internal/helper/binancex/resolver.go +++ b/internal/helper/binancex/resolver.go @@ -9,41 +9,25 @@ import ( ) func Token2FutureSymbols(token string) []string { - var syms []string if !stringx.IsAlphaNumeric(token) { - return syms + return nil } token = strings.ToUpper(token) + if mapped, ok := data.Market.GetFutureSymbolByToken(token); ok { + return []string{mapped} + } - for _, prefix := range checkingPrefixList { - prefix = strings.ToUpper(prefix) - - s := prefix + token - - if data.Market.IsFuturesPair(s) { - syms = append(syms, s) + for futureToken, spotToken := range binance.FutureToken2SpotTokenMap { + if strings.ToUpper(spotToken) != token { continue } - - for _, suffix := range binance.SymbolSuffixList { - suffix = strings.ToUpper(suffix) - abbr := strings.ToUpper(binance.SymbolSuffixMap[suffix]) - - sym := s + suffix - if data.Market.IsFuturesPair(sym) { - syms = append(syms, sym) - continue - } - - symAbr, found := stringx.ReplaceSuffix(s, abbr, suffix) - if found && data.Market.IsFuturesPair(symAbr) { - syms = append(syms, symAbr) - continue - } + if mapped, ok := data.Market.GetFutureSymbolByToken(strings.ToUpper(futureToken)); ok { + return []string{mapped} } } - return syms + + return nil } func Token2SpotSymbols(token string) []string { @@ -51,21 +35,31 @@ func Token2SpotSymbols(token string) []string { return nil } - spotOnly := strings.ToUpper(token) + "USDT" - if data.Market.IsSpotPair(spotOnly) { - return []string{spotOnly} + token = strings.ToUpper(token) + if mapped, ok := data.Market.GetSpotSymbolByToken(token); ok { + return []string{mapped} } return nil } func Token2RelatedSpotSymbols(token string) []string { - futureSymbols := Token2FutureSymbols(token) - spots := make([]string, 0, len(futureSymbols)+1) - seen := make(map[string]struct{}, len(futureSymbols)+1) + token = strings.ToUpper(token) + seen := make(map[string]struct{}, 2) + spots := make([]string, 0, 2) - for _, futureSymbol := range futureSymbols { + for _, futureSymbol := range Token2FutureSymbols(token) { spotSymbol := Future2SpotSymbol(futureSymbol) + if _, ok := seen[spotSymbol]; ok { + continue + } + if data.Market.IsSpotPair(spotSymbol) { + seen[spotSymbol] = struct{}{} + spots = append(spots, spotSymbol) + } + } + + for _, spotSymbol := range Token2SpotSymbols(token) { if _, ok := seen[spotSymbol]; ok { continue } @@ -73,12 +67,5 @@ func Token2RelatedSpotSymbols(token string) []string { spots = append(spots, spotSymbol) } - spotOnly := strings.ToUpper(token) + "USDT" - if data.Market.IsSpotPair(spotOnly) { - if _, ok := seen[spotOnly]; !ok { - spots = append(spots, spotOnly) - } - } - return spots } diff --git a/internal/helper/binancex/resolver_test.go b/internal/helper/binancex/resolver_test.go index 165144f..25e8fdf 100644 --- a/internal/helper/binancex/resolver_test.go +++ b/internal/helper/binancex/resolver_test.go @@ -1,7 +1,7 @@ package binancex import ( - "reflect" + "strings" "testing" "me.thuanle/bbot/internal/configs/binance" @@ -31,7 +31,25 @@ func (m *resolverMarketStub) GetAlphaToken(symbol string) (market.AlphaTokenInfo func (m *resolverMarketStub) GetAlphaPrice(symbol string) (float64, bool) { return 0, false } func (m *resolverMarketStub) IsSpotPair(symbol string) bool { return m.spotPairs[symbol] } func (m *resolverMarketStub) IsFuturesPair(symbol string) bool { return m.futuresPairs[symbol] } -func (m *resolverMarketStub) RefreshTradingPairCache() error { return nil } +func (m *resolverMarketStub) GetSpotSymbolByToken(token string) (string, bool) { + token = strings.ToUpper(token) + for sym := range m.spotPairs { + if strings.ToUpper(Symbol2Token(sym)) == token { + return sym, true + } + } + return "", false +} +func (m *resolverMarketStub) GetFutureSymbolByToken(token string) (string, bool) { + token = strings.ToUpper(token) + for sym := range m.futuresPairs { + if strings.ToUpper(Symbol2Token(sym)) == token { + return sym, true + } + } + return "", false +} +func (m *resolverMarketStub) RefreshTradingPairCache() error { return nil } func withResolverMarketStub(t *testing.T, marketStub *resolverMarketStub) { t.Helper() @@ -40,7 +58,7 @@ func withResolverMarketStub(t *testing.T, marketStub *resolverMarketStub) { t.Cleanup(func() { data.Market = orig }) } -func TestToken2FutureSymbols_ResolvesPrefixAndSuffix(t *testing.T) { +func TestToken2FutureSymbols_ReturnsCanonicalFutureSymbol(t *testing.T) { withResolverMarketStub(t, &resolverMarketStub{ futuresPairs: map[string]bool{ "1000PEPEUSDT": true, @@ -49,27 +67,12 @@ func TestToken2FutureSymbols_ResolvesPrefixAndSuffix(t *testing.T) { }) syms := Token2FutureSymbols("pepe") - if len(syms) == 0 { - t.Fatalf("expected future symbols for PEPE") - } - - foundUSDT := false - foundUSDC := false - for _, sym := range syms { - if sym == "1000PEPEUSDT" { - foundUSDT = true - } - if sym == "1000PEPEUSDC" { - foundUSDC = true - } - } - - if !foundUSDT || !foundUSDC { - t.Fatalf("expected both 1000PEPEUSDT and 1000PEPEUSDC, got %+v", syms) + if len(syms) != 1 || syms[0] != "1000PEPEUSDT" { + t.Fatalf("expected canonical [1000PEPEUSDT], got %+v", syms) } } -func TestToken2FutureSymbols_UsesDeterministicCandidateOrder(t *testing.T) { +func TestToken2FutureSymbols_UsesCanonicalQuotePriority(t *testing.T) { withResolverMarketStub(t, &resolverMarketStub{ futuresPairs: map[string]bool{ "1000PEPEUSDT": true, @@ -80,9 +83,8 @@ func TestToken2FutureSymbols_UsesDeterministicCandidateOrder(t *testing.T) { }) syms := Token2FutureSymbols("pepe") - expected := []string{"1000PEPEUSDT", "1000PEPEUSDC", "PEPEUSDT", "PEPEUSDC"} - if !reflect.DeepEqual(syms, expected) { - t.Fatalf("expected deterministic order %+v, got %+v", expected, syms) + if len(syms) != 1 || syms[0] != "1000PEPEUSDT" { + t.Fatalf("expected canonical [1000PEPEUSDT], got %+v", syms) } } @@ -92,9 +94,8 @@ func TestToken2FutureSymbols_ResolvesUSDCAbbreviation(t *testing.T) { }) syms := Token2FutureSymbols("pepec") - expected := []string{"PEPEUSDC"} - if !reflect.DeepEqual(syms, expected) { - t.Fatalf("expected USDC abbreviation resolution %+v, got %+v", expected, syms) + if len(syms) != 1 || syms[0] != "PEPEUSDC" { + t.Fatalf("expected [PEPEUSDC], got %+v", syms) } } diff --git a/internal/helper/binancex/symbol.go b/internal/helper/binancex/symbol.go index c79b8b9..78228a8 100644 --- a/internal/helper/binancex/symbol.go +++ b/internal/helper/binancex/symbol.go @@ -51,9 +51,9 @@ func Future2SpotSymbol(sym string) string { } } - spotSym, ok := binance.Future2SpotSymbolMap[sym] - if !ok { - return sym + token := Symbol2Token(sym) + if mapped, ok := binance.FutureToken2SpotTokenMap[token]; ok { + token = strings.ToUpper(mapped) } - return spotSym + return token + "USDT" } diff --git a/internal/services/tele/commands/token_test.go b/internal/services/tele/commands/token_test.go index 496aaea..3099ad8 100644 --- a/internal/services/tele/commands/token_test.go +++ b/internal/services/tele/commands/token_test.go @@ -1,10 +1,12 @@ package commands import ( + "strings" "testing" "me.thuanle/bbot/internal/data" "me.thuanle/bbot/internal/data/market" + "me.thuanle/bbot/internal/helper/binancex" ) func TestBuildRichTokenMessageInput_AllSources(t *testing.T) { @@ -88,7 +90,25 @@ func (m *marketStub) GetAlphaPrice(symbol string) (float64, bool) { } func (m *marketStub) IsSpotPair(symbol string) bool { return m.spotPairs[symbol] } func (m *marketStub) IsFuturesPair(symbol string) bool { return m.futuresPairs[symbol] } -func (m *marketStub) RefreshTradingPairCache() error { return nil } +func (m *marketStub) GetSpotSymbolByToken(token string) (string, bool) { + token = strings.ToUpper(token) + for symbol := range m.spotPairs { + if binancex.Symbol2Token(symbol) == token { + return symbol, true + } + } + return "", false +} +func (m *marketStub) GetFutureSymbolByToken(token string) (string, bool) { + token = strings.ToUpper(token) + for symbol := range m.futuresPairs { + if binancex.Symbol2Token(symbol) == token { + return symbol, true + } + } + return "", false +} +func (m *marketStub) RefreshTradingPairCache() error { return nil } func TestCollectRichTokenData_SpotOnlyReachable(t *testing.T) { orig := data.Market