diff --git a/internal/configs/binance/asset.go b/internal/configs/binance/asset.go index 0d06e06..974151e 100644 --- a/internal/configs/binance/asset.go +++ b/internal/configs/binance/asset.go @@ -2,13 +2,20 @@ 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", +} + +var SpotToken2FutureTokenMap = map[string]string{ + "LUNA": "LUNA2", } 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..e19e31e 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,158 @@ 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 = futureCacheTokenKey(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 futureCacheTokenKey(token string) string { + return strings.ToUpper(token) +} + func (ms *MarketData) IsSpotPair(symbol string) bool { ms.pairCacheMutex.RLock() defer ms.pairCacheMutex.RUnlock() @@ -69,6 +184,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..0e7c94f --- /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 TestFutureCacheTokenKey_PreservesRawFutureToken(t *testing.T) { + got := futureCacheTokenKey("LUNA2") + if got != "LUNA2" { + t.Fatalf("expected LUNA2, got %q", got) + } +} + +func TestFutureCacheTokenKey_NoOverride(t *testing.T) { + got := futureCacheTokenKey("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 c60b0b1..4a08091 100644 --- a/internal/helper/binancex/resolver.go +++ b/internal/helper/binancex/resolver.go @@ -9,50 +9,54 @@ 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) - 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 futureToken, ok := binance.SpotToken2FutureTokenMap[token]; ok { + if mapped, ok := data.Market.GetFutureSymbolByToken(strings.ToUpper(futureToken)); ok { + return []string{mapped} } } - return syms + + return nil } func Token2SpotSymbols(token string) []string { - futureSymbols := Token2FutureSymbols(token) - spots := make([]string, 0, len(futureSymbols)+1) - seen := make(map[string]struct{}, len(futureSymbols)+1) + if !stringx.IsAlphaNumeric(token) { + return nil + } - for _, futureSymbol := range futureSymbols { + token = strings.ToUpper(token) + if mapped, ok := data.Market.GetSpotSymbolByToken(token); ok { + return []string{mapped} + } + + return nil +} + +func Token2RelatedSpotSymbols(token string) []string { + token = strings.ToUpper(token) + seen := make(map[string]struct{}, 2) + spots := make([]string, 0, 2) + + 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 } @@ -60,12 +64,5 @@ func Token2SpotSymbols(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 b3f13fc..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) } } @@ -137,6 +138,14 @@ func TestIsToken(t *testing.T) { }, want: true, }, + { + name: "spot only token fallback", + input: "abc", + marketStub: &resolverMarketStub{ + spotPairs: map[string]bool{"ABCUSDT": true}, + }, + want: true, + }, { name: "non alphanumeric input", input: "bad!", @@ -157,13 +166,25 @@ func TestIsToken(t *testing.T) { } } -func TestToken2SpotSymbols_AppliesExplicitRemap(t *testing.T) { +func TestToken2SpotSymbols_DoesNotDependOnFutureMappings(t *testing.T) { withResolverMarketStub(t, &resolverMarketStub{ futuresPairs: map[string]bool{"LUNA2USDT": true}, spotPairs: map[string]bool{"LUNAUSDT": true}, }) spots := Token2SpotSymbols("luna2") + if len(spots) != 0 { + t.Fatalf("expected no direct spot symbols for LUNA2, got %+v", spots) + } +} + +func TestToken2RelatedSpotSymbols_AppliesExplicitRemap(t *testing.T) { + withResolverMarketStub(t, &resolverMarketStub{ + futuresPairs: map[string]bool{"LUNA2USDT": true}, + spotPairs: map[string]bool{"LUNAUSDT": true}, + }) + + spots := Token2RelatedSpotSymbols("luna2") if len(spots) != 1 || spots[0] != "LUNAUSDT" { t.Fatalf("expected [LUNAUSDT], got %+v", spots) } diff --git a/internal/helper/binancex/symbol.go b/internal/helper/binancex/symbol.go index b45c06d..78228a8 100644 --- a/internal/helper/binancex/symbol.go +++ b/internal/helper/binancex/symbol.go @@ -30,12 +30,13 @@ var ( ) func IsToken(s string) bool { - // First check regular symbols if len(Token2FutureSymbols(s)) > 0 { return true } + if len(Token2SpotSymbols(s)) > 0 { + return true + } - // Then check Alpha tokens s = strings.ToUpper(s) return data.Market.IsAlphaToken(s) } @@ -50,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.go b/internal/services/tele/commands/token.go index 7267264..202e84c 100644 --- a/internal/services/tele/commands/token.go +++ b/internal/services/tele/commands/token.go @@ -87,7 +87,7 @@ func collectRichTokenData(token string) buildRichTokenMessageArgs { } } - spotSymbols := binancex.Token2SpotSymbols(token) + spotSymbols := binancex.Token2RelatedSpotSymbols(token) for _, spotSymbol := range spotSymbols { if sp, ok := data.Market.GetSpotPrice(spotSymbol); ok { a.HasSpot = true 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