diff --git a/internal/services/tele/view/price.go b/internal/services/tele/view/price.go index cdba285..d1cb652 100644 --- a/internal/services/tele/view/price.go +++ b/internal/services/tele/view/price.go @@ -2,13 +2,15 @@ package view import ( "fmt" + "strings" + "time" + "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 { @@ -63,6 +65,69 @@ func RenderOnAlphaPriceMessage(symbol string, price float64, change24h float64) ) } +type RichTokenMessageInput struct { + Token string + + HasSpot bool + SpotPrice float64 + + HasFuture bool + FuturePrice float64 + FundingRate float64 + FundingTimeMs int64 + + HasAlpha bool + AlphaPrice float64 + + HasAlpha24h bool + Alpha24hChange float64 + + HasMarginAPR bool + MarginAPRPercent float64 +} + +func RenderRichTokenMessage(in RichTokenMessageInput) string { + rows := []string{fmt.Sprintf("🪙 %s", strings.ToUpper(in.Token))} + + if in.HasSpot { + rows = append(rows, fmt.Sprintf("💵 Spot: $%s", RenderPrice(in.SpotPrice))) + } + if in.HasFuture { + rows = append(rows, fmt.Sprintf("📈 Future: $%s", RenderPrice(in.FuturePrice))) + } + if in.HasAlpha { + rows = append(rows, fmt.Sprintf("🅰️ Alpha: $%s", RenderPrice(in.AlphaPrice))) + } + + if in.HasSpot && in.HasFuture && in.SpotPrice > 0 { + delta := in.FuturePrice - in.SpotPrice + deltaPct := delta / in.SpotPrice * 100 + rows = append(rows, fmt.Sprintf("🧭 Basis: $%+s (%+.2f%%)", RenderPrice(delta), deltaPct)) + } + + if in.HasFuture { + rows = append(rows, fmt.Sprintf("💸 Funding: %.4f%% %s in %s", + in.FundingRate*100, + IconOfFundingFeeDirection(in.FundingRate), + timex.CdMinuteStringTime(time.UnixMilli(in.FundingTimeMs)), + )) + } + + if in.HasMarginAPR { + rows = append(rows, fmt.Sprintf("🏦 Margin: %.3f%% APR", in.MarginAPRPercent)) + } + + if in.HasAlpha24h { + icon := "🟢" + if in.Alpha24hChange < 0 { + icon = "🔴" + } + rows = append(rows, fmt.Sprintf("📊 Alpha 24h: %s%+.2f%%", icon, in.Alpha24hChange)) + } + + return strings.Join(rows, "\n") +} + func RenderOnGetTopPricesMessage(symbols []string, price []float64, fundRate []float64) string { t := helper.NewNoBorderTableWriter("", "Price", "Fund") mFmt := message.NewPrinter(language.AmericanEnglish) diff --git a/internal/services/tele/view/price_test.go b/internal/services/tele/view/price_test.go new file mode 100644 index 0000000..ab03fa9 --- /dev/null +++ b/internal/services/tele/view/price_test.go @@ -0,0 +1,123 @@ +package view + +import ( + "strings" + "testing" +) + +func assertContainsAll(t *testing.T, msg string, rows ...string) { + t.Helper() + for _, r := range rows { + if !strings.Contains(msg, r) { + t.Fatalf("expected row %q in message:\n%s", r, msg) + } + } +} + +func assertContainsNone(t *testing.T, msg string, rows ...string) { + t.Helper() + for _, r := range rows { + if strings.Contains(msg, r) { + t.Fatalf("unexpected row %q in message:\n%s", r, msg) + } + } +} + +func TestRenderRichTokenMessage_AllSources(t *testing.T) { + msg := RenderRichTokenMessage(RichTokenMessageInput{ + Token: "ETH", + HasSpot: true, + SpotPrice: 3245, + HasFuture: true, + FuturePrice: 3251, + FundingRate: 0.000123, + FundingTimeMs: 1740000000000, + HasAlpha: true, + AlphaPrice: 3248, + HasAlpha24h: true, + Alpha24hChange: -3.21, + HasMarginAPR: true, + MarginAPRPercent: 7.665, + }) + + expectedOrder := []string{ + "🪙 ETH", + "💵 Spot:", + "📈 Future:", + "🅰️ Alpha:", + "🧭 Basis:", + "💸 Funding:", + "🏦 Margin:", + "📊 Alpha 24h:", + } + + last := -1 + for _, part := range expectedOrder { + i := strings.Index(msg, part) + if i == -1 { + t.Fatalf("missing row %q in message:\n%s", part, msg) + } + if i < last { + t.Fatalf("row %q appears out of order in message:\n%s", part, msg) + } + last = i + } +} + +func TestRenderRichTokenMessage_FutureOnly(t *testing.T) { + msg := RenderRichTokenMessage(RichTokenMessageInput{ + Token: "ABC", + HasFuture: true, + FuturePrice: 1234, + FundingRate: 0.000081, + FundingTimeMs: 1740000000000, + HasMarginAPR: true, + MarginAPRPercent: 6.2, + }) + + assertContainsAll(t, msg, "🪙 ABC", "📈 Future:", "💸 Funding:", "🏦 Margin:") + assertContainsNone(t, msg, "💵 Spot:", "🅰️ Alpha:", "🧭 Basis:", "📊 Alpha 24h:") +} + +func TestRenderRichTokenMessage_SpotOnly(t *testing.T) { + msg := RenderRichTokenMessage(RichTokenMessageInput{ + Token: "XRP", + HasSpot: true, + SpotPrice: 2.1, + HasMarginAPR: true, + MarginAPRPercent: 5.11, + }) + + assertContainsAll(t, msg, "🪙 XRP", "💵 Spot:", "🏦 Margin:") + assertContainsNone(t, msg, "📈 Future:", "🅰️ Alpha:", "🧭 Basis:", "💸 Funding:", "📊 Alpha 24h:") +} + +func TestRenderRichTokenMessage_AlphaOnly(t *testing.T) { + msg := RenderRichTokenMessage(RichTokenMessageInput{ + Token: "ABC", + HasAlpha: true, + AlphaPrice: 0.1234, + HasAlpha24h: true, + Alpha24hChange: 12.4, + }) + + assertContainsAll(t, msg, "🪙 ABC", "🅰️ Alpha:", "📊 Alpha 24h:") + assertContainsNone(t, msg, "💵 Spot:", "📈 Future:", "🧭 Basis:", "💸 Funding:", "🏦 Margin:") +} + +func TestRenderRichTokenMessage_SpotAndFuture(t *testing.T) { + msg := RenderRichTokenMessage(RichTokenMessageInput{ + Token: "SOL", + HasSpot: true, + SpotPrice: 182.4, + HasFuture: true, + FuturePrice: 183.0, + FundingRate: -0.000041, + FundingTimeMs: 1740000000000, + HasMarginAPR: true, + MarginAPRPercent: 5.11, + }) + + assertContainsAll(t, msg, "🪙 SOL", "💵 Spot:", "📈 Future:", "🧭 Basis:", "💸 Funding:", "🏦 Margin:") + assertContainsNone(t, msg, "🅰️ Alpha:", "📊 Alpha 24h:") +}