diff --git a/internal/helper/binancex/resolver.go b/internal/helper/binancex/resolver.go new file mode 100644 index 0000000..817a2be --- /dev/null +++ b/internal/helper/binancex/resolver.go @@ -0,0 +1,35 @@ +package binancex + +import ( + "strings" + + "me.thuanle/bbot/internal/data" +) + +func Token2FutureSymbols(token string) []string { + return Token2Symbols(token) +} + +func Token2SpotSymbols(token string) []string { + futureSymbols := Token2FutureSymbols(token) + spots := make([]string, 0, len(futureSymbols)+1) + seen := make(map[string]struct{}, len(futureSymbols)+1) + + for _, futureSymbol := range futureSymbols { + spotSymbol := Future2SpotSymbol(futureSymbol) + if _, ok := seen[spotSymbol]; ok { + continue + } + seen[spotSymbol] = struct{}{} + 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 new file mode 100644 index 0000000..a757627 --- /dev/null +++ b/internal/helper/binancex/resolver_test.go @@ -0,0 +1,91 @@ +package binancex + +import ( + "testing" + + "me.thuanle/bbot/internal/data" + "me.thuanle/bbot/internal/data/market" +) + +type resolverMarketStub struct { + spotPairs map[string]bool + futuresPairs map[string]bool +} + +func (m *resolverMarketStub) GetFuturePrice(symbol string) (float64, float64, int64, bool) { + return 0, 0, 0, false +} +func (m *resolverMarketStub) GetAllPremiumIndex() (map[string]market.PremiumIndex, error) { + return nil, nil +} +func (m *resolverMarketStub) GetAllFundRate() (map[string]float64, map[string]int64) { return nil, nil } +func (m *resolverMarketStub) GetSpotPrice(symbol string) (float64, bool) { return 0, false } +func (m *resolverMarketStub) GetMarginInterestRates() map[string]float64 { return nil } +func (m *resolverMarketStub) IsAlphaToken(symbol string) bool { return false } +func (m *resolverMarketStub) GetAlphaToken(symbol string) (market.AlphaTokenInfo, bool) { + return market.AlphaTokenInfo{}, 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 TestToken2FutureSymbols_ResolvesPrefixAndSuffix(t *testing.T) { + orig := data.Market + defer func() { data.Market = orig }() + + data.Market = &resolverMarketStub{ + futuresPairs: map[string]bool{ + "1000PEPEUSDT": true, + "1000PEPEUSDC": true, + }, + } + + 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) + } +} + +func TestToken2SpotSymbols_AppliesExplicitRemap(t *testing.T) { + orig := data.Market + defer func() { data.Market = orig }() + + data.Market = &resolverMarketStub{ + futuresPairs: map[string]bool{"LUNA2USDT": true}, + spotPairs: map[string]bool{"LUNAUSDT": true}, + } + + spots := Token2SpotSymbols("luna2") + if len(spots) != 1 || spots[0] != "LUNAUSDT" { + t.Fatalf("expected [LUNAUSDT], got %+v", spots) + } +} + +func TestToken2SpotSymbols_SpotOnlyFallback(t *testing.T) { + orig := data.Market + defer func() { data.Market = orig }() + + data.Market = &resolverMarketStub{ + spotPairs: map[string]bool{"ABCUSDT": true}, + } + + spots := Token2SpotSymbols("abc") + if len(spots) != 1 || spots[0] != "ABCUSDT" { + t.Fatalf("expected [ABCUSDT], got %+v", spots) + } +} diff --git a/internal/services/tele/commands/token.go b/internal/services/tele/commands/token.go index 01ce105..e3df729 100644 --- a/internal/services/tele/commands/token.go +++ b/internal/services/tele/commands/token.go @@ -62,67 +62,53 @@ func showStickerMode(context telebot.Context, token string) { } } -func OnTokenInfoByToken(context telebot.Context, token string) error { - token = strings.ToUpper(token) - showStickerMode(context, token) - - var ( - hasAlpha, hasAlpha24h bool - alphaPrice, alpha24h float64 - hasFuture, hasSpot bool - futurePrice float64 - fundingRate float64 - fundingTime int64 - spotPrice float64 - ) +func collectRichTokenData(token string) buildRichTokenMessageArgs { + a := buildRichTokenMessageArgs{Token: token} if alphaToken, ok := data.Market.GetAlphaToken(token); ok { - hasAlpha = true - alphaPrice = alphaToken.GetPrice() - hasAlpha24h = true - alpha24h = alphaToken.GetPercentChange24h() + a.HasAlpha = true + a.AlphaPrice = alphaToken.GetPrice() + a.HasAlpha24h = true + a.Alpha24h = alphaToken.GetPercentChange24h() } - symbols := binancex.Token2Symbols(token) - if len(symbols) > 0 { - fp, fr, ft, ok := data.Market.GetFuturePrice(symbols[0]) - if ok { - hasFuture = true - futurePrice = fp - fundingRate = fr - fundingTime = ft + futureSymbols := binancex.Token2FutureSymbols(token) + for _, futureSymbol := range futureSymbols { + if fp, fr, ft, ok := data.Market.GetFuturePrice(futureSymbol); ok { + a.HasFuture = true + a.FuturePrice = fp + a.FundingRate = fr + a.FundingTimeMs = ft + break + } + } - sSymbol := binancex.Future2SpotSymbol(symbols[0]) - if sp, ok := data.Market.GetSpotPrice(sSymbol); ok { - hasSpot = true - spotPrice = sp - } + spotSymbols := binancex.Token2SpotSymbols(token) + for _, spotSymbol := range spotSymbols { + if sp, ok := data.Market.GetSpotPrice(spotSymbol); ok { + a.HasSpot = true + a.SpotPrice = sp + break } } marginRates := data.Market.GetMarginInterestRates() - marginAPR := marginRates[token] * 365 * 100 - hasMarginAPR := marginRates[token] != 0 + a.MarginAPRPercent = marginRates[token] * 365 * 100 + a.HasMarginAPR = marginRates[token] != 0 - if !hasSpot && !hasFuture && !hasAlpha { + return a +} + +func OnTokenInfoByToken(context telebot.Context, token string) error { + token = strings.ToUpper(token) + showStickerMode(context, token) + + args := collectRichTokenData(token) + if !args.HasSpot && !args.HasFuture && !args.HasAlpha { return nil } - msg := view.RenderRichTokenMessage(buildRichTokenMessageInput(buildRichTokenMessageArgs{ - Token: token, - HasSpot: hasSpot, - SpotPrice: spotPrice, - HasFuture: hasFuture, - FuturePrice: futurePrice, - FundingRate: fundingRate, - FundingTimeMs: fundingTime, - HasAlpha: hasAlpha, - AlphaPrice: alphaPrice, - HasAlpha24h: hasAlpha24h, - Alpha24h: alpha24h, - HasMarginAPR: hasMarginAPR, - MarginAPRPercent: marginAPR, - })) + msg := view.RenderRichTokenMessage(buildRichTokenMessageInput(args)) _ = chat.ReplyMessage(context, msg) return nil diff --git a/internal/services/tele/commands/token_test.go b/internal/services/tele/commands/token_test.go index 21bbb99..640e7d5 100644 --- a/internal/services/tele/commands/token_test.go +++ b/internal/services/tele/commands/token_test.go @@ -1,6 +1,50 @@ package commands -import "testing" +import ( + "testing" + + "me.thuanle/bbot/internal/data" + "me.thuanle/bbot/internal/data/market" +) + +type marketStub struct { + spotPairs map[string]bool + futuresPairs map[string]bool + spotPrices map[string]float64 + futurePrices map[string]struct { + price float64 + rate float64 + time int64 + } + alphaTokens map[string]market.AlphaTokenInfo + marginRates map[string]float64 +} + +func (m *marketStub) GetFuturePrice(symbol string) (float64, float64, int64, bool) { + v, ok := m.futurePrices[symbol] + if !ok { + return 0, 0, 0, false + } + return v.price, v.rate, v.time, true +} +func (m *marketStub) GetAllPremiumIndex() (map[string]market.PremiumIndex, error) { return nil, nil } +func (m *marketStub) GetAllFundRate() (map[string]float64, map[string]int64) { return nil, nil } +func (m *marketStub) GetSpotPrice(symbol string) (float64, bool) { + v, ok := m.spotPrices[symbol] + return v, ok +} +func (m *marketStub) GetMarginInterestRates() map[string]float64 { return m.marginRates } +func (m *marketStub) IsAlphaToken(symbol string) bool { + _, ok := m.alphaTokens[symbol] + return ok +} +func (m *marketStub) GetAlphaToken(symbol string) (market.AlphaTokenInfo, bool) { + v, ok := m.alphaTokens[symbol] + return v, ok +} +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 TestBuildRichTokenMessageInput_AllSources(t *testing.T) { in := buildRichTokenMessageInput(buildRichTokenMessageArgs{ @@ -40,3 +84,131 @@ func TestBuildRichTokenMessageInput_OnlyAlpha(t *testing.T) { t.Fatalf("did not expect spot/future flags true: %+v", in) } } + +func TestCollectRichTokenData_SpotOnlyReachable(t *testing.T) { + orig := data.Market + defer func() { data.Market = orig }() + + data.Market = &marketStub{ + spotPairs: map[string]bool{"ABCUSDT": true}, + spotPrices: map[string]float64{"ABCUSDT": 1.23}, + } + + args := collectRichTokenData("ABC") + if !args.HasSpot { + t.Fatalf("expected spot to be reachable for spot-only token: %+v", args) + } + if args.HasFuture || args.HasAlpha { + t.Fatalf("did not expect future/alpha for spot-only token: %+v", args) + } +} + +func TestCollectRichTokenData_FutureFailureStillKeepsSpot(t *testing.T) { + orig := data.Market + defer func() { data.Market = orig }() + + data.Market = &marketStub{ + spotPairs: map[string]bool{ + "ETHUSDT": true, + }, + futuresPairs: map[string]bool{ + "ETHUSDT": true, + }, + spotPrices: map[string]float64{ + "ETHUSDT": 3245, + }, + futurePrices: map[string]struct { + price float64 + rate float64 + time int64 + }{}, + } + + args := collectRichTokenData("ETH") + if !args.HasSpot { + t.Fatalf("expected spot to remain available when future lookup fails: %+v", args) + } + if args.HasFuture { + t.Fatalf("did not expect future when future lookup fails: %+v", args) + } +} + +func TestCollectRichTokenData_UsesSharedResolverMapping(t *testing.T) { + orig := data.Market + defer func() { data.Market = orig }() + + data.Market = &marketStub{ + spotPairs: map[string]bool{ + "LUNAUSDT": true, + }, + futuresPairs: map[string]bool{ + "LUNA2USDT": true, + }, + spotPrices: map[string]float64{ + "LUNAUSDT": 0.49, + }, + futurePrices: map[string]struct { + price float64 + rate float64 + time int64 + }{ + "LUNA2USDT": {price: 0.50, rate: 0.0001, time: 1740000000000}, + }, + } + + args := collectRichTokenData("LUNA2") + if !args.HasFuture || !args.HasSpot { + t.Fatalf("expected both future and mapped spot, got %+v", args) + } + if args.SpotPrice != 0.49 { + t.Fatalf("expected mapped spot price 0.49, got %f", args.SpotPrice) + } +} + +func TestCollectRichTokenData_PrefixFutureMapsToSpot(t *testing.T) { + orig := data.Market + defer func() { data.Market = orig }() + + data.Market = &marketStub{ + spotPairs: map[string]bool{ + "PEPEUSDT": true, + }, + futuresPairs: map[string]bool{ + "1000PEPEUSDT": true, + }, + spotPrices: map[string]float64{ + "PEPEUSDT": 0.000012, + }, + futurePrices: map[string]struct { + price float64 + rate float64 + time int64 + }{ + "1000PEPEUSDT": {price: 0.000013, rate: 0.0002, time: 1740000000000}, + }, + } + + args := collectRichTokenData("PEPE") + if !args.HasFuture || !args.HasSpot { + t.Fatalf("expected both future and mapped spot for prefixed contract, got %+v", args) + } +} + +func TestCollectRichTokenData_AlphaIndependent(t *testing.T) { + orig := data.Market + defer func() { data.Market = orig }() + + data.Market = &marketStub{ + alphaTokens: map[string]market.AlphaTokenInfo{ + "ABC": {Symbol: "ABC", PercentChange24h: "12.4", Price: "0.1234"}, + }, + } + + args := collectRichTokenData("ABC") + if !args.HasAlpha { + t.Fatalf("expected alpha source available, got %+v", args) + } + if args.HasSpot || args.HasFuture { + t.Fatalf("expected alpha independent from spot/future, got %+v", args) + } +}