From e565503f2494c750c7a459a6da1124a55036f348 Mon Sep 17 00:00:00 2001 From: thuanle Date: Sun, 26 Apr 2026 17:09:23 +0700 Subject: [PATCH 1/5] Add rich token message design spec Document unified Telegram token message format that includes all available sources (spot/future/alpha) with conditional rows. Co-Authored-By: Claude Opus 4.7 --- .../2026-04-26-token-message-rich-design.md | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-26-token-message-rich-design.md diff --git a/docs/superpowers/specs/2026-04-26-token-message-rich-design.md b/docs/superpowers/specs/2026-04-26-token-message-rich-design.md new file mode 100644 index 0000000..9ffa610 --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-token-message-rich-design.md @@ -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. -- 2.52.0 From b376eba674c88e9472a4bdb0b4f5c8ab71a798ed Mon Sep 17 00:00:00 2001 From: thuanle Date: Sun, 26 Apr 2026 17:14:52 +0700 Subject: [PATCH 2/5] Ignore local worktree directory Add .worktrees to gitignore to prevent worktree contents from being tracked in the repository. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f10862a..eb8fb6c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /.env +/.worktrees/ -- 2.52.0 From 2f875032f6eab86b7184f8dc6d90e8a0e1be9e66 Mon Sep 17 00:00:00 2001 From: thuanle Date: Sun, 26 Apr 2026 17:18:34 +0700 Subject: [PATCH 3/5] feat: add rich token message renderer with conditional rows Introduce unified rich token message rendering with fixed row order and conditional visibility based on available sources. Co-Authored-By: Claude Opus 4.7 --- internal/services/tele/view/price.go | 67 +++++++++++- internal/services/tele/view/price_test.go | 123 ++++++++++++++++++++++ 2 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 internal/services/tele/view/price_test.go 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:") +} -- 2.52.0 From 88de029e142b2e92eb5a59ffe769f1636147efe5 Mon Sep 17 00:00:00 2001 From: thuanle Date: Sun, 26 Apr 2026 17:19:59 +0700 Subject: [PATCH 4/5] feat: unify token message composition across sources Remove alpha-first early return and build one rich message from all available sources (spot/future/alpha) with conditional rows. Co-Authored-By: Claude Opus 4.7 --- internal/services/tele/commands/token.go | 119 ++++++++++++++---- internal/services/tele/commands/token_test.go | 42 +++++++ 2 files changed, 136 insertions(+), 25 deletions(-) create mode 100644 internal/services/tele/commands/token_test.go diff --git a/internal/services/tele/commands/token.go b/internal/services/tele/commands/token.go index 0c93d14..01ce105 100644 --- a/internal/services/tele/commands/token.go +++ b/internal/services/tele/commands/token.go @@ -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 } diff --git a/internal/services/tele/commands/token_test.go b/internal/services/tele/commands/token_test.go new file mode 100644 index 0000000..21bbb99 --- /dev/null +++ b/internal/services/tele/commands/token_test.go @@ -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) + } +} -- 2.52.0 From 95e9217ef4c6f3893f90037bdbd861c565a4a623 Mon Sep 17 00:00:00 2001 From: thuanle Date: Sun, 26 Apr 2026 17:39:57 +0700 Subject: [PATCH 5/5] fix: render basis with explicit sign and absolute delta Format basis row as +/-$abs(delta) to match the spec and avoid awkward %+s formatting behavior. Co-Authored-By: Claude Opus 4.7 --- internal/services/tele/view/price.go | 7 ++++++- internal/services/tele/view/price_test.go | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/internal/services/tele/view/price.go b/internal/services/tele/view/price.go index d1cb652..5c773ee 100644 --- a/internal/services/tele/view/price.go +++ b/internal/services/tele/view/price.go @@ -2,6 +2,7 @@ package view import ( "fmt" + "math" "strings" "time" @@ -102,7 +103,11 @@ func RenderRichTokenMessage(in RichTokenMessageInput) string { 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)) + sign := "+" + if delta < 0 { + sign = "-" + } + rows = append(rows, fmt.Sprintf("🧭 Basis: %s$%s (%+.2f%%)", sign, RenderPrice(math.Abs(delta)), deltaPct)) } if in.HasFuture { diff --git a/internal/services/tele/view/price_test.go b/internal/services/tele/view/price_test.go index ab03fa9..71c343f 100644 --- a/internal/services/tele/view/price_test.go +++ b/internal/services/tele/view/price_test.go @@ -118,6 +118,20 @@ func TestRenderRichTokenMessage_SpotAndFuture(t *testing.T) { MarginAPRPercent: 5.11, }) - assertContainsAll(t, msg, "🪙 SOL", "💵 Spot:", "📈 Future:", "🧭 Basis:", "💸 Funding:", "🏦 Margin:") + 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%)") +} -- 2.52.0