24 Commits

Author SHA1 Message Date
thuanle 617b067203 chore: address review for market filename refactor
Rename pair tests to match post-refactor boundaries and remove unused alpha cache refresh helper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 09:51:24 +07:00
thuanle f5e2e178e1 refactor: standardize market filenames by data dimension
Split market pair logic into spot and futures files and rename price files to plural data-dimension names without changing behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 07:04:37 +07:00
thuanle 635b0c5b39 Merge pull request 'fix: recognize spot-only tokens in IsToken' (#25) from fix/issue-24-is-token-spot-only into main
Build Docker Image / build (amd64) (push) Successful in 1m1s
Reviewed-on: #25
2026-04-27 05:00:41 +07:00
thuanle d6338fa092 fix: preserve futures token identity in canonical cache
Keep futureToken2Symbol keyed by raw futures token and use explicit spot-to-future alias mapping during resolver fallback lookups.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 04:53:54 +07:00
thuanle 711721c1ee refactor: use canonical token-to-symbol maps for market lookup
Build canonical spot/future token maps with quote priority, unify cache refresh scheduling, and switch resolver/token tests to map-based token lookups.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 04:44:30 +07:00
thuanle 06c40ef30f refactor: separate direct and related spot resolvers
Keep IsToken checks explicit across futures and direct spot symbols, move futures-derived spot mapping to a clearly named helper, and update token data collection to use related spot resolution.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 04:10:06 +07:00
thuanle 0705e909dc fix: recognize spot-only tokens in IsToken
Gate token detection via spot symbol resolution so chat flow accepts spot-only tokens, and add regression coverage for the fallback path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 03:59:32 +07:00
thuanle 72bbe66c3e Merge pull request 'Refactor Binance symbol resolver' (#23) from binance-symbol-resolver-refactor into main
Build Docker Image / build (amd64) (push) Successful in 1m39s
Reviewed-on: #23
Reviewed-by: claudecode <38+claudecode@noreply.localhost>
2026-04-27 03:54:01 +07:00
thuanle 252e77c000 test binance symbol resolver 2026-04-27 03:46:46 +07:00
thuanle 2c5a9e23bf refactor binance symbol resolver 2026-04-27 03:32:59 +07:00
thuanle 47d07f2092 style: fix gofmt alignment in resolver_test.go stub
Build Docker Image / build (amd64) (push) Successful in 1m3s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 21:15:53 +07:00
thuanle 72fdb598af fix: resolve shared symbol mapping for token command (#22)
Build Docker Image / build (amd64) (push) Successful in 1m36s
Co-authored-by: thuanle <tl@thuanle.me>
Co-committed-by: thuanle <tl@thuanle.me>
2026-04-26 21:13:06 +07:00
thuanle 1486681ef9 Merge pull request 'fix: independent token source lookups' (#19) from feat/token-message-rich into main
Build Docker Image / build (amd64) (push) Successful in 2m10s
2026-04-26 18:51:17 +07:00
thuanle a4644f5982 Merge branch 'main' into feat/token-message-rich 2026-04-26 18:39:53 +07:00
thuanle 82b0f2f1a8 feat: fetch alpha price by symbol ticker
Keep alpha token existence from list cache and fetch live alpha price via symbol ticker endpoint for richer token response accuracy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 18:22:48 +07:00
thuanle 6e8a2e8f68 fix: decouple token spot/future/alpha lookups
Collect token market data independently for spot, futures, and alpha so any available source can be rendered even if others fail.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 18:13:13 +07:00
thuanle 194a235065 docs: document required environment variables
Build Docker Image / build (amd64) (push) Successful in 59s
Expand README with env setup, required TELEGRAM_TOKEN,
optional LOG_ENV and ADMIN_CHAT_ID, plus run notes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 17:50:41 +07:00
thuanle c841ca4421 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
2026-04-26 17:41:17 +07:00
thuanle 95e9217ef4 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 <noreply@anthropic.com>
2026-04-26 17:39:57 +07:00
thuanle 88de029e14 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 <noreply@anthropic.com>
2026-04-26 17:19:59 +07:00
thuanle 2f875032f6 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 <noreply@anthropic.com>
2026-04-26 17:18:34 +07:00
thuanle b376eba674 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 <noreply@anthropic.com>
2026-04-26 17:14:52 +07:00
thuanle e565503f24 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 <noreply@anthropic.com>
2026-04-26 17:09:23 +07:00
thuanle 77036f2211 Merge pull request 'Update module github.com/jedib0t/go-pretty/v6 to v6.7.10' (#14) from renovate/github.com-jedib0t-go-pretty-v6-6.x into main
Build Docker Image / build (amd64) (push) Successful in 1m11s
2026-04-26 16:52:45 +07:00
20 changed files with 1302 additions and 176 deletions
+1
View File
@@ -1 +1,2 @@
/.env
/.worktrees/
+31 -1
View File
@@ -1 +1,31 @@
# crypto price bot
# crypto price bot
Telegram bot tra giá crypto (Spot / Future / Alpha), funding rate và margin APR.
## Environment variables
Tạo file `.env` ở root project với các biến sau:
```env
# BẮT BUỘC: token bot Telegram
TELEGRAM_TOKEN=your_telegram_bot_token
# TÙY CHỌN: bật/tắt log env mode (mặc định true nếu không set)
# giá trị hợp lệ: true / false
LOG_ENV=true
# TÙY CHỌN nhưng KHUYẾN NGHỊ: Telegram user id được phép dùng /refresh
# nếu không set hoặc set sai format -> /refresh sẽ bị từ chối cho tất cả user
ADMIN_CHAT_ID=123456789
```
## Run
```bash
go run ./cmd/tele
```
## Notes
- `TELEGRAM_TOKEN` sai/thiếu: bot sẽ fail khi khởi tạo Telegram service.
- `/refresh` dùng để refresh trading pair cache thủ công và chỉ cho `ADMIN_CHAT_ID`.
@@ -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.
+14 -4
View File
@@ -1,11 +1,21 @@
package binance
var SymbolPrefixList = []string{"1000000", "1000", "1M"}
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",
}
+3
View File
@@ -13,9 +13,12 @@ type IMarket interface {
// Alpha token methods
IsAlphaToken(symbol string) bool
GetAlphaToken(symbol string) (market.AlphaTokenInfo, bool)
GetAlphaPrice(symbol string) (float64, bool)
// Trading pair methods
IsSpotPair(symbol string) bool
IsFuturesPair(symbol string) bool
GetSpotSymbolByToken(token string) (string, bool)
GetFutureSymbolByToken(token string) (string, bool)
RefreshTradingPairCache() error
}
+49 -3
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
@@ -54,10 +55,21 @@ type AlphaTokenInfo struct {
// AlphaTokenResponse represents the API response structure
type AlphaTokenResponse struct {
Code string `json:"code"`
Message *string `json:"message"`
Code string `json:"code"`
Message *string `json:"message"`
MessageDetail *string `json:"messageDetail"`
Data []AlphaTokenInfo `json:"data"`
}
type AlphaTickerData struct {
Price string `json:"price"`
}
type AlphaTickerResponse struct {
Code string `json:"code"`
Message *string `json:"message"`
MessageDetail *string `json:"messageDetail"`
Data []AlphaTokenInfo `json:"data"`
Data AlphaTickerData `json:"data"`
}
// GetPrice returns the price as float64
@@ -150,3 +162,37 @@ func (ms *MarketData) IsAlphaToken(symbol string) bool {
_, exists := ms.GetAlphaToken(symbol)
return exists
}
func (ms *MarketData) GetAlphaPrice(symbol string) (float64, bool) {
const alphaTickerURL = "https://www.binance.com/bapi/defi/v1/public/alpha-trade/ticker"
client := &http.Client{Timeout: 5 * time.Second}
endpoint := alphaTickerURL + "?" + url.Values{"symbol": []string{symbol}}.Encode()
resp, err := client.Get(endpoint)
if err != nil {
log.Error().Err(err).Str("symbol", symbol).Msg("Failed to fetch Alpha ticker")
return 0, false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, false
}
var tickerResp AlphaTickerResponse
if err := json.NewDecoder(resp.Body).Decode(&tickerResp); err != nil {
return 0, false
}
if tickerResp.Code != "000000" {
return 0, false
}
price, err := strconv.ParseFloat(tickerResp.Data.Price, 64)
if err != nil || price <= 0 {
return 0, false
}
return price, true
}
+65
View File
@@ -0,0 +1,65 @@
package market
import (
"context"
"strings"
"time"
"github.com/rs/zerolog/log"
)
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
}
futurePairs := make(map[string]bool, len(futuresInfo.Symbols))
futureTokenCandidates := make(map[string][]string)
for _, s := range futuresInfo.Symbols {
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()
ms.pairCacheMutex.Unlock()
return nil
}
func futureCacheTokenKey(token string) string {
return strings.ToUpper(token)
}
func (ms *MarketData) IsFuturesPair(symbol string) bool {
ms.pairCacheMutex.RLock()
defer ms.pairCacheMutex.RUnlock()
return ms.futuresPairs[symbol]
}
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
}
+49 -13
View File
@@ -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,27 +31,61 @@ 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
}
func (ms *MarketData) alphaCacheRefreshLoop() {
ms.refreshAlphaTokenCache()
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.refreshAlphaTokenCache()
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 (ms *MarketData) RefreshTradingPairCache() error {
return ms.refreshTradingPairCache()
}
+41
View File
@@ -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)
}
}
+103
View File
@@ -0,0 +1,103 @@
package market
import (
"context"
"sort"
"strings"
"time"
"github.com/rs/zerolog/log"
"me.thuanle/bbot/internal/configs/binance"
)
func (ms *MarketData) refreshSpotPairCache() error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
spotInfo, err := ms.spotClient.NewExchangeInfoService().Do(ctx)
if err != nil {
log.Error().Err(err).Msg("Failed to fetch spot exchange info")
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 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 (ms *MarketData) IsSpotPair(symbol string) bool {
ms.pairCacheMutex.RLock()
defer ms.pairCacheMutex.RUnlock()
return ms.spotPairs[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
}
-74
View File
@@ -1,74 +0,0 @@
package market
import (
"context"
"time"
"github.com/rs/zerolog/log"
)
func (ms *MarketData) refreshTradingPairCache() error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
spotInfo, err := ms.spotClient.NewExchangeInfoService().Do(ctx)
if err != nil {
log.Error().Err(err).Msg("Failed to fetch spot exchange info")
return err
}
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))
for _, s := range futuresInfo.Symbols {
if s.Status == "TRADING" {
ms.futuresPairs[s.Symbol] = true
}
}
ms.lastPairCacheUpdate = time.Now()
log.Info().
Int("spot", len(ms.spotPairs)).
Int("futures", len(ms.futuresPairs)).
Msg("Trading pair cache refreshed")
return nil
}
func (ms *MarketData) pairCacheRefreshLoop() {
ms.refreshTradingPairCache()
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
ms.refreshTradingPairCache()
}
}
func (ms *MarketData) IsSpotPair(symbol string) bool {
ms.pairCacheMutex.RLock()
defer ms.pairCacheMutex.RUnlock()
return ms.spotPairs[symbol]
}
func (ms *MarketData) IsFuturesPair(symbol string) bool {
ms.pairCacheMutex.RLock()
defer ms.pairCacheMutex.RUnlock()
return ms.futuresPairs[symbol]
}
func (ms *MarketData) RefreshTradingPairCache() error {
return ms.refreshTradingPairCache()
}
+68
View File
@@ -0,0 +1,68 @@
package binancex
import (
"strings"
"me.thuanle/bbot/internal/configs/binance"
"me.thuanle/bbot/internal/data"
"me.thuanle/bbot/internal/utils/stringx"
)
func Token2FutureSymbols(token string) []string {
if !stringx.IsAlphaNumeric(token) {
return nil
}
token = strings.ToUpper(token)
if mapped, ok := data.Market.GetFutureSymbolByToken(token); ok {
return []string{mapped}
}
if futureToken, ok := binance.SpotToken2FutureTokenMap[token]; ok {
if mapped, ok := data.Market.GetFutureSymbolByToken(strings.ToUpper(futureToken)); ok {
return []string{mapped}
}
}
return nil
}
func Token2SpotSymbols(token string) []string {
if !stringx.IsAlphaNumeric(token) {
return nil
}
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
}
seen[spotSymbol] = struct{}{}
spots = append(spots, spotSymbol)
}
return spots
}
+202
View File
@@ -0,0 +1,202 @@
package binancex
import (
"strings"
"testing"
"me.thuanle/bbot/internal/configs/binance"
"me.thuanle/bbot/internal/data"
"me.thuanle/bbot/internal/data/market"
)
type resolverMarketStub struct {
alphaTokens map[string]bool
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 m.alphaTokens[symbol] }
func (m *resolverMarketStub) GetAlphaToken(symbol string) (market.AlphaTokenInfo, bool) {
return market.AlphaTokenInfo{}, false
}
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) 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()
orig := data.Market
data.Market = marketStub
t.Cleanup(func() { data.Market = orig })
}
func TestToken2FutureSymbols_ReturnsCanonicalFutureSymbol(t *testing.T) {
withResolverMarketStub(t, &resolverMarketStub{
futuresPairs: map[string]bool{
"1000PEPEUSDT": true,
"1000PEPEUSDC": true,
},
})
syms := Token2FutureSymbols("pepe")
if len(syms) != 1 || syms[0] != "1000PEPEUSDT" {
t.Fatalf("expected canonical [1000PEPEUSDT], got %+v", syms)
}
}
func TestToken2FutureSymbols_UsesCanonicalQuotePriority(t *testing.T) {
withResolverMarketStub(t, &resolverMarketStub{
futuresPairs: map[string]bool{
"1000PEPEUSDT": true,
"1000PEPEUSDC": true,
"PEPEUSDT": true,
"PEPEUSDC": true,
},
})
syms := Token2FutureSymbols("pepe")
if len(syms) != 1 || syms[0] != "1000PEPEUSDT" {
t.Fatalf("expected canonical [1000PEPEUSDT], got %+v", syms)
}
}
func TestToken2FutureSymbols_ResolvesUSDCAbbreviation(t *testing.T) {
withResolverMarketStub(t, &resolverMarketStub{
futuresPairs: map[string]bool{"PEPEUSDC": true},
})
syms := Token2FutureSymbols("pepec")
if len(syms) != 1 || syms[0] != "PEPEUSDC" {
t.Fatalf("expected [PEPEUSDC], got %+v", syms)
}
}
func TestSymbolSuffixListMatchesSymbolSuffixMap(t *testing.T) {
seen := make(map[string]struct{}, len(binance.SymbolSuffixList))
for _, suffix := range binance.SymbolSuffixList {
if _, ok := binance.SymbolSuffixMap[suffix]; !ok {
t.Fatalf("SymbolSuffixList contains %q missing from SymbolSuffixMap", suffix)
}
seen[suffix] = struct{}{}
}
for suffix := range binance.SymbolSuffixMap {
if _, ok := seen[suffix]; !ok {
t.Fatalf("SymbolSuffixMap contains %q missing from SymbolSuffixList", suffix)
}
}
}
func TestIsToken(t *testing.T) {
tests := []struct {
name string
input string
marketStub *resolverMarketStub
want bool
}{
{
name: "futures token resolution success",
input: "pepe",
marketStub: &resolverMarketStub{
futuresPairs: map[string]bool{"1000PEPEUSDT": true},
},
want: true,
},
{
name: "alpha token fallback",
input: "alpha",
marketStub: &resolverMarketStub{
alphaTokens: map[string]bool{"ALPHA": true},
},
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!",
marketStub: &resolverMarketStub{},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
withResolverMarketStub(t, tt.marketStub)
got := IsToken(tt.input)
if got != tt.want {
t.Fatalf("expected %v, got %v", tt.want, got)
}
})
}
}
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)
}
}
func TestToken2SpotSymbols_SpotOnlyFallback(t *testing.T) {
withResolverMarketStub(t, &resolverMarketStub{
spotPairs: map[string]bool{"ABCUSDT": true},
})
spots := Token2SpotSymbols("abc")
if len(spots) != 1 || spots[0] != "ABCUSDT" {
t.Fatalf("expected [ABCUSDT], got %+v", spots)
}
}
+11 -54
View File
@@ -14,7 +14,8 @@ func Symbol2Token(sym string) string {
for _, prefix := range binance.SymbolPrefixList {
token, _ = strings.CutPrefix(token, prefix)
}
for suffix, abbr := range binance.SymbolSuffixMap {
for _, suffix := range binance.SymbolSuffixList {
abbr := binance.SymbolSuffixMap[suffix]
var f bool
token, f = stringx.ReplaceSuffix(token, suffix, abbr)
if f {
@@ -28,58 +29,14 @@ var (
checkingPrefixList = append(binance.SymbolPrefixList, "")
)
func testSym(sym string) bool {
return data.Market.IsFuturesPair(sym)
}
func Token2Symbols(token string) []string {
var syms []string
if !stringx.IsAlphaNumeric(token) {
return syms
}
token = strings.ToUpper(token)
for _, prefix := range checkingPrefixList {
prefix = strings.ToUpper(prefix)
s := prefix + token
//no suffix
if testSym(s) {
syms = append(syms, s)
continue
}
for suffix, abbr := range binance.SymbolSuffixMap {
suffix = strings.ToUpper(suffix)
abbr = strings.ToUpper(abbr)
//suffix
sym := s + suffix
if testSym(sym) {
syms = append(syms, sym)
continue
}
//suffix abbr
symAbr, found := stringx.ReplaceSuffix(s, abbr, suffix)
if found && testSym(symAbr) {
syms = append(syms, symAbr)
continue
}
}
}
return syms
}
func IsToken(s string) bool {
// First check regular symbols
if len(Token2Symbols(s)) > 0 {
if len(Token2FutureSymbols(s)) > 0 {
return true
}
// Then check Alpha tokens
if len(Token2SpotSymbols(s)) > 0 {
return true
}
s = strings.ToUpper(s)
return data.Market.IsAlphaToken(s)
}
@@ -94,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"
}
+84 -26
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]
@@ -23,39 +62,58 @@ func showStickerMode(context telebot.Context, token string) {
}
}
func collectRichTokenData(token string) buildRichTokenMessageArgs {
a := buildRichTokenMessageArgs{Token: token}
if alphaToken, ok := data.Market.GetAlphaToken(token); ok {
a.HasAlpha = true
a.HasAlpha24h = true
a.Alpha24h = alphaToken.GetPercentChange24h()
if alphaPrice, ok := data.Market.GetAlphaPrice(token + "USDT"); ok {
a.AlphaPrice = alphaPrice
} else {
a.AlphaPrice = alphaToken.GetPrice()
}
}
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
}
}
spotSymbols := binancex.Token2RelatedSpotSymbols(token)
for _, spotSymbol := range spotSymbols {
if sp, ok := data.Market.GetSpotPrice(spotSymbol); ok {
a.HasSpot = true
a.SpotPrice = sp
break
}
}
marginRates := data.Market.GetMarginInterestRates()
a.MarginAPRPercent = marginRates[token] * 365 * 100
a.HasMarginAPR = marginRates[token] != 0
return a
}
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])
marginRates := data.Market.GetMarginInterestRates()
tokenInterestRate := marginRates[token]
if !ok {
args := collectRichTokenData(token)
if !args.HasSpot && !args.HasFuture && !args.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(args))
_ = chat.ReplyMessage(context, msg)
return nil
}
@@ -0,0 +1,243 @@
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) {
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)
}
}
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
alphaPrices map[string]float64
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) GetAlphaPrice(symbol string) (float64, bool) {
v, ok := m.alphaPrices[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) 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
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_AlphaUsesSymbolPrice(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.1"},
},
alphaPrices: map[string]float64{"ABCUSDT": 0.1234},
}
args := collectRichTokenData("ABC")
if !args.HasAlpha {
t.Fatalf("expected alpha to be available: %+v", args)
}
if !args.HasAlpha24h {
t.Fatalf("expected alpha 24h to be available: %+v", args)
}
if args.AlphaPrice != 0.1234 {
t.Fatalf("expected alpha price from symbol lookup, got %.4f", args.AlphaPrice)
}
}
+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%)")
}