Merge pull request 'Add rich unified token price message' (#17) from feat/token-message-rich into main
Build Docker Image / build (amd64) (push) Successful in 1m2s

This commit was merged in pull request #17.
This commit is contained in:
2026-04-26 17:41:17 +07:00
6 changed files with 475 additions and 26 deletions
+1
View File
@@ -1 +1,2 @@
/.env
/.worktrees/
@@ -0,0 +1,130 @@
# Token message rich design
## Context
Current token message flow returns early for Alpha tokens, so users cannot see Spot/Future together with Alpha when multiple sources are available. Message content is also rigid and less informative for partial-source cases.
Goal: redesign Telegram token message so each available price source is shown in one rich message, with stable ordering and conditional rows.
## Scope
Applies to token query message rendering in Telegram bot (`OnTokenInfoByToken` flow and price view formatting).
Out of scope:
- changing market data fetch logic
- changing command routing
- trend arrows / historical diff
## Requirements
1. Rich, multi-line Telegram message format.
2. Spot is primary source when present.
3. Show all available sources in a single message: Spot, Future, Alpha.
4. Hide rows for missing data.
5. Show Basis only when both Spot and Future exist.
6. Show Alpha 24h when Alpha data exists.
7. No trend arrows.
8. Preserve current behavior of no reply when no Spot/Future/Alpha data exists.
## Message format
Fixed row order (hide missing rows):
1. `🪙 TOKEN`
2. `💵 Spot: ...` (primary if exists)
3. `📈 Future: ...`
4. `🅰️ Alpha: ...`
5. `🧭 Basis: Future-Spot` (only if Spot+Future)
6. `💸 Funding: ... in ...` (if Future exists)
7. `🏦 Margin: ... APR` (if margin exists)
8. `📊 Alpha 24h: ...` (if Alpha change exists)
## Fallback rules
- **All three available**: render all rows above (except any missing optional fields).
- **Spot + Future**: render Spot, Future, Basis, Funding, Margin.
- **Alpha only**: render Alpha and Alpha 24h.
- **Future only**: render Future, Funding, Margin.
- **Spot only**: render Spot, Margin.
- **No Spot/Future/Alpha**: no reply.
## Data flow behavior
1. Resolve token to candidate futures symbol(s) using existing resolver.
2. Collect available values from Spot/Future/Alpha/margin sources.
3. Build one unified message model.
4. Render rows in fixed order; each row is conditional on availability.
5. Send single Telegram message.
Important behavioral change: remove alpha-first early-return behavior; Alpha becomes a peer data source in unified rendering.
## Formatting notes
- Reuse existing numeric formatter (`RenderPrice`) for price values.
- Keep current funding icon and countdown style.
- Basis format:
- absolute delta: `Future - Spot`
- percent delta: `(Future-Spot)/Spot*100`
- sign preserved (`+`/`-`)
## Example outputs
### A) Spot + Future + Alpha
```text
🪙 ETH
💵 Spot: $3,245
📈 Future: $3,251
🅰️ Alpha: $3,248
🧭 Basis: +$6 (+0.18%)
💸 Funding: +0.0123% 🟢 in 05m
🏦 Margin: 7.665% APR
📊 Alpha 24h: 🔴 -3.21%
```
### B) Spot + Future
```text
🪙 SOL
💵 Spot: $182.4
📈 Future: $183.0
🧭 Basis: +$0.6 (+0.33%)
💸 Funding: -0.0041% 🔴 in 42m
🏦 Margin: 5.110% APR
```
### C) Alpha only
```text
🪙 ABC
🅰️ Alpha: $0.1234
📊 Alpha 24h: 🟢 +12.40%
```
### D) Future only
```text
🪙 TOKEN
📈 Future: $1,234
💸 Funding: +0.0081% 🟢 in 17m
🏦 Margin: 6.200% APR
```
### E) Spot only
```text
🪙 TOKEN
💵 Spot: $1,228
🏦 Margin: 6.200% APR
```
## Verification checklist
- Token with all 3 sources -> all expected rows appear, ordered correctly.
- Token with Spot+Future but no Alpha -> no Alpha rows.
- Token with Alpha only -> no Spot/Future/Basis/Funding rows.
- Token with Future only -> no Spot/Basis/Alpha rows.
- Token with Spot only -> no Future/Basis/Funding/Alpha rows.
- Token with no data -> no reply.
- Basis not shown unless both Spot and Future exist.
- No trend arrows anywhere.
+94 -25
View File
@@ -11,6 +11,45 @@ import (
"me.thuanle/bbot/internal/services/tele/view"
)
type buildRichTokenMessageArgs struct {
Token string
HasSpot bool
SpotPrice float64
HasFuture bool
FuturePrice float64
FundingRate float64
FundingTimeMs int64
HasAlpha bool
AlphaPrice float64
HasAlpha24h bool
Alpha24h float64
HasMarginAPR bool
MarginAPRPercent float64
}
func buildRichTokenMessageInput(a buildRichTokenMessageArgs) view.RichTokenMessageInput {
return view.RichTokenMessageInput{
Token: a.Token,
HasSpot: a.HasSpot,
SpotPrice: a.SpotPrice,
HasFuture: a.HasFuture,
FuturePrice: a.FuturePrice,
FundingRate: a.FundingRate,
FundingTimeMs: a.FundingTimeMs,
HasAlpha: a.HasAlpha,
AlphaPrice: a.AlphaPrice,
HasAlpha24h: a.HasAlpha24h,
Alpha24hChange: a.Alpha24h,
HasMarginAPR: a.HasMarginAPR,
MarginAPRPercent: a.MarginAPRPercent,
}
}
func showStickerMode(context telebot.Context, token string) {
token = strings.ToUpper(token)
stickerIdx, ok := tele.Token2StickerIdxMap[token]
@@ -25,37 +64,67 @@ func showStickerMode(context telebot.Context, token string) {
func OnTokenInfoByToken(context telebot.Context, token string) error {
token = strings.ToUpper(token)
// Check if it's an Alpha token first
if data.Market.IsAlphaToken(token) {
showStickerMode(context, token)
if alphaToken, exists := data.Market.GetAlphaToken(token); exists {
sp := alphaToken.GetPrice()
change24h := alphaToken.GetPercentChange24h()
_ = chat.ReplyMessage(context, view.RenderOnAlphaPriceMessage(token, sp, change24h))
}
return nil
}
// Regular token handling
symbols := binancex.Token2Symbols(token)
if len(symbols) == 0 {
return nil
}
showStickerMode(context, token)
fp, fundRate, fundTime, ok := data.Market.GetFuturePrice(symbols[0])
var (
hasAlpha, hasAlpha24h bool
alphaPrice, alpha24h float64
hasFuture, hasSpot bool
futurePrice float64
fundingRate float64
fundingTime int64
spotPrice float64
)
if alphaToken, ok := data.Market.GetAlphaToken(token); ok {
hasAlpha = true
alphaPrice = alphaToken.GetPrice()
hasAlpha24h = true
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
sSymbol := binancex.Future2SpotSymbol(symbols[0])
if sp, ok := data.Market.GetSpotPrice(sSymbol); ok {
hasSpot = true
spotPrice = sp
}
}
}
marginRates := data.Market.GetMarginInterestRates()
tokenInterestRate := marginRates[token]
if !ok {
marginAPR := marginRates[token] * 365 * 100
hasMarginAPR := marginRates[token] != 0
if !hasSpot && !hasFuture && !hasAlpha {
return nil
}
sSymbol := binancex.Future2SpotSymbol(symbols[0])
sp, _ := data.Market.GetSpotPrice(sSymbol)
_ = chat.ReplyMessage(context, view.RenderOnPriceMessage(symbols[0], sp, fp, fundRate, fundTime, tokenInterestRate))
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,
}))
_ = chat.ReplyMessage(context, msg)
return nil
}
@@ -0,0 +1,42 @@
package commands
import "testing"
func TestBuildRichTokenMessageInput_AllSources(t *testing.T) {
in := buildRichTokenMessageInput(buildRichTokenMessageArgs{
Token: "ETH",
SpotPrice: 3245,
HasSpot: true,
FuturePrice: 3251,
FundingRate: 0.000123,
FundingTimeMs: 1740000000000,
HasFuture: true,
AlphaPrice: 3248,
HasAlpha: true,
Alpha24h: -3.21,
HasAlpha24h: true,
MarginAPRPercent: 7.665,
HasMarginAPR: true,
})
if !in.HasSpot || !in.HasFuture || !in.HasAlpha || !in.HasAlpha24h || !in.HasMarginAPR {
t.Fatalf("expected all source flags true: %+v", in)
}
}
func TestBuildRichTokenMessageInput_OnlyAlpha(t *testing.T) {
in := buildRichTokenMessageInput(buildRichTokenMessageArgs{
Token: "ABC",
HasAlpha: true,
AlphaPrice: 0.1234,
HasAlpha24h: true,
Alpha24h: 12.4,
})
if !in.HasAlpha || !in.HasAlpha24h {
t.Fatalf("expected alpha flags true: %+v", in)
}
if in.HasSpot || in.HasFuture {
t.Fatalf("did not expect spot/future flags true: %+v", in)
}
}
+71 -1
View File
@@ -2,13 +2,16 @@ package view
import (
"fmt"
"math"
"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 +66,73 @@ 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
sign := "+"
if delta < 0 {
sign = "-"
}
rows = append(rows, fmt.Sprintf("🧭 Basis: %s$%s (%+.2f%%)", sign, RenderPrice(math.Abs(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)
+137
View File
@@ -0,0 +1,137 @@
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: +$0.6000", "💸 Funding:", "🏦 Margin:")
assertContainsNone(t, msg, "🅰️ Alpha:", "📊 Alpha 24h:")
}
func TestRenderRichTokenMessage_SpotAndFutureNegativeBasis(t *testing.T) {
msg := RenderRichTokenMessage(RichTokenMessageInput{
Token: "ADA",
HasSpot: true,
SpotPrice: 1.2,
HasFuture: true,
FuturePrice: 1.1,
FundingRate: 0.000011,
FundingTimeMs: 1740000000000,
})
assertContainsAll(t, msg, "🧭 Basis: -$0.10000 (-8.33%)")
}