mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-08 18:17:54 +00:00
feat(coin): add crypto paper trading
This commit is contained in:
@@ -14,6 +14,7 @@ Plug-n-play Telegram bot framework in Go. Runs on AWS Lambda + DynamoDB + EventB
|
||||
| `twentyq` | 20-questions game (requires Gemini API key) |
|
||||
| `trading` | VN-stocks paper trading |
|
||||
| `gold` | Gold paper trading (opt-in; spot XAU converted to VND per luong) |
|
||||
| `coin` | Crypto paper trading in USD (Binance -> Coinbase -> CoinGecko price fallback) |
|
||||
| `stats` | `/stats` (top commands), `/stats users`, `/stats user <name>`, `/stats cmd <name>` |
|
||||
|
||||
Disable any module by editing `MODULES` in `template.yaml`.
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
"github.com/tiennm99/miti99bot/internal/metrics"
|
||||
"github.com/tiennm99/miti99bot/internal/modules"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/coin"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/gold"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/loldle"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/lolschedule"
|
||||
@@ -49,6 +50,7 @@ func factories() map[string]modules.Factory {
|
||||
"wordle": wordle.New,
|
||||
"loldle": loldle.New,
|
||||
"lolschedule": lolschedule.New,
|
||||
"coin": coin.New,
|
||||
"gold": gold.New,
|
||||
"twentyq": twentyq.New,
|
||||
"trading": trading.New,
|
||||
@@ -88,6 +90,9 @@ func main() {
|
||||
exportOptionalEnv("TRADING_INCOME_EVENTS_API_TOKEN", cfg.TradingIncomeEventsAPIToken)
|
||||
exportOptionalEnv("GOLD_PRICE_API_URL", cfg.GoldPriceAPIURL)
|
||||
exportOptionalEnv("GOLD_FX_API_URL", cfg.GoldFXAPIURL)
|
||||
exportOptionalEnv("COIN_BINANCE_API_URL", cfg.CoinBinanceAPIURL)
|
||||
exportOptionalEnv("COIN_COINBASE_API_URL", cfg.CoinCoinbaseAPIURL)
|
||||
exportOptionalEnv("COIN_COINGECKO_API_URL", cfg.CoinCoinGeckoAPIURL)
|
||||
|
||||
// Periodic metrics flush. Cancels with rootCtx and emits one final
|
||||
// flush on shutdown so the trailing window isn't lost.
|
||||
@@ -264,6 +269,9 @@ type config struct {
|
||||
TradingIncomeEventsAPIToken string
|
||||
GoldPriceAPIURL string
|
||||
GoldFXAPIURL string
|
||||
CoinBinanceAPIURL string
|
||||
CoinCoinbaseAPIURL string
|
||||
CoinCoinGeckoAPIURL string
|
||||
Modules []string
|
||||
BotOwnerID int64
|
||||
AdminUserIDs map[int64]bool
|
||||
@@ -305,6 +313,9 @@ func loadConfig() config {
|
||||
TradingIncomeEventsAPIToken: envMap["TRADING_INCOME_EVENTS_API_TOKEN"],
|
||||
GoldPriceAPIURL: envMap["GOLD_PRICE_API_URL"],
|
||||
GoldFXAPIURL: envMap["GOLD_FX_API_URL"],
|
||||
CoinBinanceAPIURL: envMap["COIN_BINANCE_API_URL"],
|
||||
CoinCoinbaseAPIURL: envMap["COIN_COINBASE_API_URL"],
|
||||
CoinCoinGeckoAPIURL: envMap["COIN_COINGECKO_API_URL"],
|
||||
Modules: splitCSV(envMap["MODULES"]),
|
||||
BotOwnerID: parseInt64(envMap["BOT_OWNER_ID"]),
|
||||
AdminUserIDs: parseInt64Set(envMap["ADMIN_USER_IDS"]),
|
||||
|
||||
@@ -7,16 +7,22 @@ import (
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
func TestFactoriesIncludesGold(t *testing.T) {
|
||||
func TestFactoriesIncludesGoldAndCoin(t *testing.T) {
|
||||
catalog := factories()
|
||||
if catalog["gold"] == nil {
|
||||
t.Fatal("factories missing gold")
|
||||
}
|
||||
reg, err := modules.Build([]string{"gold"}, catalog, storage.NewMemoryProvider(), modules.BuildOptions{})
|
||||
if catalog["coin"] == nil {
|
||||
t.Fatal("factories missing coin")
|
||||
}
|
||||
reg, err := modules.Build([]string{"gold", "coin"}, catalog, storage.NewMemoryProvider(), modules.BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build gold: %v", err)
|
||||
}
|
||||
for _, name := range []string{"gold_price", "gold_topup", "gold_buy", "gold_sell", "gold_stats"} {
|
||||
for _, name := range []string{
|
||||
"gold_price", "gold_topup", "gold_buy", "gold_sell", "gold_stats",
|
||||
"coin_price", "coin_topup", "coin_buy", "coin_sell", "coin_stats",
|
||||
} {
|
||||
if _, ok := reg.AllCommands[name]; !ok {
|
||||
t.Fatalf("missing command %s", name)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package coin
|
||||
|
||||
import "github.com/tiennm99/miti99bot/internal/modules"
|
||||
|
||||
// New is the coin paper-trading module factory. It is opt-in through MODULES
|
||||
// and keeps its portfolio state separate from stock and gold modules.
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
s := newState(deps.KV)
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "coin_price",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show current crypto price in USD",
|
||||
Handler: s.handlePrice,
|
||||
},
|
||||
{
|
||||
Name: "coin_topup",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Top up USD to your coin account",
|
||||
Handler: s.handleTopup,
|
||||
},
|
||||
{
|
||||
Name: "coin_buy",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Buy coin with USD amount",
|
||||
Handler: s.handleBuy,
|
||||
},
|
||||
{
|
||||
Name: "coin_sell",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Sell coin quantity back to USD",
|
||||
Handler: s.handleSell,
|
||||
},
|
||||
{
|
||||
Name: "coin_stats",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show coin account summary with P&L",
|
||||
Handler: s.handleStats,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package coin
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func FormatUSD(n float64) string {
|
||||
if math.IsNaN(n) || math.IsInf(n, 0) {
|
||||
return "invalid USD"
|
||||
}
|
||||
sign := ""
|
||||
if n < 0 {
|
||||
sign = "-"
|
||||
n = -n
|
||||
}
|
||||
whole := int64(math.Floor(n))
|
||||
cents := int64(math.Round((n - float64(whole)) * 100))
|
||||
if cents == 100 {
|
||||
whole++
|
||||
cents = 0
|
||||
}
|
||||
return sign + "$" + groupDigits(strconv.FormatInt(whole, 10)) + "." + twoDigits(cents)
|
||||
}
|
||||
|
||||
func FormatCoinQty(n float64) string {
|
||||
s := strconv.FormatFloat(n, 'f', 8, 64)
|
||||
s = strings.TrimRight(s, "0")
|
||||
s = strings.TrimRight(s, ".")
|
||||
if s == "" || s == "-0" {
|
||||
return "0"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func FormatPnLUSD(currentValue, invested float64) string {
|
||||
diff := currentValue - invested
|
||||
pct := 0.0
|
||||
if invested > 0 {
|
||||
pct = (diff / invested) * 100
|
||||
}
|
||||
sign := ""
|
||||
if diff >= 0 {
|
||||
sign = "+"
|
||||
}
|
||||
return sign + FormatUSD(diff) + " (" + sign + strconv.FormatFloat(pct, 'f', 2, 64) + "%)"
|
||||
}
|
||||
|
||||
func groupDigits(s string) string {
|
||||
var sb strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
sb.WriteByte(',')
|
||||
}
|
||||
sb.WriteByte(s[i])
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func twoDigits(n int64) string {
|
||||
if n < 10 {
|
||||
return "0" + strconv.FormatInt(n, 10)
|
||||
}
|
||||
return strconv.FormatInt(n, 10)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package coin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
|
||||
)
|
||||
|
||||
var (
|
||||
errInsufficientUSD = errors.New("coin: insufficient USD")
|
||||
errInsufficientCoin = errors.New("coin: insufficient coin")
|
||||
)
|
||||
|
||||
func (s *state) handlePrice(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
args := argsAfterCommand(update.Message.Text)
|
||||
if len(args) != 1 {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_price <COIN>\nExample: /coin_price BTC")
|
||||
}
|
||||
coin, err := ResolveCoinSymbol(args[0])
|
||||
if err != nil {
|
||||
return s.replyPriceError(ctx, b, update, err)
|
||||
}
|
||||
price, err := s.prices.FetchUSD(ctx, coin)
|
||||
if err != nil {
|
||||
return s.replyPriceError(ctx, b, update, err)
|
||||
}
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
coin.Symbol+" price: "+FormatUSD(price.USD)+" ("+price.Source+")")
|
||||
}
|
||||
|
||||
func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
userID, ok := senderInfo(update)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Cannot identify user - coin only works in private/group chats with a sender.")
|
||||
}
|
||||
args := argsAfterCommand(update.Message.Text)
|
||||
if len(args) != 1 {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_topup <usd_amount>\nExample: /coin_topup 1000")
|
||||
}
|
||||
amount, ok := parsePositiveFinite(args[0])
|
||||
if !ok || !isSafeUSD(amount) {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Amount must be a positive finite USD number within the supported range.")
|
||||
}
|
||||
defer s.locks.Acquire(strconv.FormatInt(userID, 10))()
|
||||
p, err := UpdatePortfolio(ctx, s.kv, userID, s.now().UnixMilli(), func(p *Portfolio) error {
|
||||
p.AddUSD(amount)
|
||||
p.Meta.Invested += amount
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("coin_save_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not save coin portfolio. Try again later.")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Topped up "+FormatUSD(amount)+".\nBalance: "+FormatUSD(p.USD))
|
||||
}
|
||||
|
||||
func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
userID, ok := senderInfo(update)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Cannot identify user - coin only works in private/group chats with a sender.")
|
||||
}
|
||||
args := argsAfterCommand(update.Message.Text)
|
||||
if len(args) != 2 {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_buy <usd_amount> <COIN>\nExample: /coin_buy 100 BTC")
|
||||
}
|
||||
amount, ok := parsePositiveFinite(args[0])
|
||||
if !ok || !isSafeUSD(amount) {
|
||||
return chathelper.Reply(ctx, b, update.Message, "USD amount must be a positive finite number within the supported range.")
|
||||
}
|
||||
coin, err := ResolveCoinSymbol(args[1])
|
||||
if err != nil {
|
||||
return s.replyPriceError(ctx, b, update, err)
|
||||
}
|
||||
price, err := s.prices.FetchUSD(ctx, coin)
|
||||
if err != nil {
|
||||
return s.replyPriceError(ctx, b, update, err)
|
||||
}
|
||||
qty := amount / price.USD
|
||||
if !isPositiveFinite(qty) {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Trade value is invalid.")
|
||||
}
|
||||
defer s.locks.Acquire(strconv.FormatInt(userID, 10))()
|
||||
var insufficientBalance *float64
|
||||
p, err := UpdatePortfolio(ctx, s.kv, userID, s.now().UnixMilli(), func(p *Portfolio) error {
|
||||
ok, balance := p.DeductUSD(amount)
|
||||
if !ok {
|
||||
insufficientBalance = &balance
|
||||
return errInsufficientUSD
|
||||
}
|
||||
p.AddAsset(coin.Symbol, qty)
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, errInsufficientUSD) && insufficientBalance != nil {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Insufficient USD. Need "+FormatUSD(amount)+", have "+FormatUSD(*insufficientBalance)+".")
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("coin_save_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not save coin portfolio. Try again later.")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Bought "+FormatCoinQty(qty)+" "+coin.Symbol+" @ "+FormatUSD(price.USD)+" ("+price.Source+")"+
|
||||
"\nCost: "+FormatUSD(amount)+"\nRemaining: "+FormatUSD(p.USD))
|
||||
}
|
||||
|
||||
func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
userID, ok := senderInfo(update)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Cannot identify user - coin only works in private/group chats with a sender.")
|
||||
}
|
||||
args := argsAfterCommand(update.Message.Text)
|
||||
if len(args) != 2 {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_sell <qty> <COIN>\nExample: /coin_sell 0.01 BTC")
|
||||
}
|
||||
qty, ok := parsePositiveFinite(args[0])
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Quantity must be a positive finite number.")
|
||||
}
|
||||
coin, err := ResolveCoinSymbol(args[1])
|
||||
if err != nil {
|
||||
return s.replyPriceError(ctx, b, update, err)
|
||||
}
|
||||
price, err := s.prices.FetchUSD(ctx, coin)
|
||||
if err != nil {
|
||||
return s.replyPriceError(ctx, b, update, err)
|
||||
}
|
||||
revenue := qty * price.USD
|
||||
if !isSafeUSD(revenue) {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Trade value is too large.")
|
||||
}
|
||||
defer s.locks.Acquire(strconv.FormatInt(userID, 10))()
|
||||
var insufficientHeld *float64
|
||||
p, err := UpdatePortfolio(ctx, s.kv, userID, s.now().UnixMilli(), func(p *Portfolio) error {
|
||||
ok, held := p.DeductAsset(coin.Symbol, qty)
|
||||
if !ok {
|
||||
insufficientHeld = &held
|
||||
return errInsufficientCoin
|
||||
}
|
||||
p.AddUSD(revenue)
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, errInsufficientCoin) && insufficientHeld != nil {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Insufficient "+coin.Symbol+". You have: "+FormatCoinQty(*insufficientHeld))
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("coin_save_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not save coin portfolio. Try again later.")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Sold "+FormatCoinQty(qty)+" "+coin.Symbol+" @ "+FormatUSD(price.USD)+" ("+price.Source+")"+
|
||||
"\nRevenue: "+FormatUSD(revenue)+"\nRemaining: "+FormatUSD(p.USD))
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package coin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/modules"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
"github.com/tiennm99/miti99bot/internal/testutil"
|
||||
)
|
||||
|
||||
type fakePriceFetcher struct {
|
||||
prices map[string]CoinPrice
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakePriceFetcher) FetchUSD(_ context.Context, coin CoinSymbol) (CoinPrice, error) {
|
||||
if f.err != nil {
|
||||
return CoinPrice{}, f.err
|
||||
}
|
||||
price, ok := f.prices[coin.Symbol]
|
||||
if !ok {
|
||||
return CoinPrice{}, ErrNoCoinPrice
|
||||
}
|
||||
if price.Symbol == "" {
|
||||
price.Symbol = coin.Symbol
|
||||
}
|
||||
return price, nil
|
||||
}
|
||||
|
||||
func newTestState(prices map[string]CoinPrice, err error) *state {
|
||||
return &state{
|
||||
kv: storage.NewMemoryKVStore(),
|
||||
prices: fakePriceFetcher{prices: prices, err: err},
|
||||
nowFn: func() time.Time { return time.UnixMilli(123) },
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePositiveFinite(t *testing.T) {
|
||||
bad := []string{"", "0", "-1", "NaN", "Inf", "+Inf", "-Inf", "1e9999"}
|
||||
for _, in := range bad {
|
||||
if got, ok := parsePositiveFinite(in); ok {
|
||||
t.Fatalf("parsePositiveFinite(%q) = %v, true; want false", in, got)
|
||||
}
|
||||
}
|
||||
if got, ok := parsePositiveFinite("0.5"); !ok || got != 0.5 {
|
||||
t.Fatalf("parsePositiveFinite valid = %v, %v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCoinSymbol(t *testing.T) {
|
||||
coin, err := ResolveCoinSymbol("btc")
|
||||
if err != nil || coin.Symbol != "BTC" || coin.CoinGeckoID != "bitcoin" {
|
||||
t.Fatalf("ResolveCoinSymbol btc = %+v, %v", coin, err)
|
||||
}
|
||||
if _, err := ResolveCoinSymbol("NOPE"); !errors.Is(err, ErrUnsupportedCoin) {
|
||||
t.Fatalf("got %v, want ErrUnsupportedCoin", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistersExpectedCommands(t *testing.T) {
|
||||
mod := New(modDepsForTest())
|
||||
got := map[string]bool{}
|
||||
for _, cmd := range mod.Commands {
|
||||
got[cmd.Name] = true
|
||||
}
|
||||
for _, name := range []string{"coin_price", "coin_topup", "coin_buy", "coin_sell", "coin_stats"} {
|
||||
if !got[name] {
|
||||
t.Fatalf("missing command %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePrice(t *testing.T) {
|
||||
s := newTestState(map[string]CoinPrice{"BTC": {USD: 67000, Source: "Binance"}}, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/coin_price btc")); err != nil {
|
||||
t.Fatalf("handlePrice: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "BTC price: $67,000.00 (Binance)")
|
||||
}
|
||||
|
||||
func TestHandlePriceRejectsUnsupportedCoin(t *testing.T) {
|
||||
s := newTestState(nil, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/coin_price nope")); err != nil {
|
||||
t.Fatalf("handlePrice: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Unsupported coin")
|
||||
}
|
||||
|
||||
func TestHandleTopup(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(nil, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handleTopup(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_topup 1000")); err != nil {
|
||||
t.Fatalf("handleTopup: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Topped up $1,000.00")
|
||||
p, err := LoadPortfolio(ctx, s.kv, 7, 999)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPortfolio: %v", err)
|
||||
}
|
||||
if p.USD != 1000 || p.Meta.Invested != 1000 {
|
||||
t.Fatalf("portfolio = %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBuyAndSell(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(map[string]CoinPrice{"BTC": {USD: 50000, Source: "Binance"}}, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
_ = s.handleTopup(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_topup 1000"))
|
||||
rb.Reset()
|
||||
if err := s.handleBuy(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_buy 500 BTC")); err != nil {
|
||||
t.Fatalf("handleBuy: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Bought 0.01 BTC")
|
||||
p, _ := LoadPortfolio(ctx, s.kv, 7, 999)
|
||||
if p.USD != 500 || p.Assets["BTC"] != 0.01 {
|
||||
t.Fatalf("after buy = %+v", p)
|
||||
}
|
||||
rb.Reset()
|
||||
if err := s.handleSell(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_sell 0.01 BTC")); err != nil {
|
||||
t.Fatalf("handleSell: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Sold 0.01 BTC")
|
||||
p, _ = LoadPortfolio(ctx, s.kv, 7, 999)
|
||||
if p.USD != 1000 || len(p.Assets) != 0 {
|
||||
t.Fatalf("after sell = %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBuyInsufficientUSD(t *testing.T) {
|
||||
s := newTestState(map[string]CoinPrice{"ETH": {USD: 3000, Source: "Coinbase"}}, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handleBuy(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/coin_buy 10 ETH")); err != nil {
|
||||
t.Fatalf("handleBuy: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Insufficient USD")
|
||||
}
|
||||
|
||||
func TestHandleSellInsufficientCoin(t *testing.T) {
|
||||
s := newTestState(map[string]CoinPrice{"ETH": {USD: 3000, Source: "Coinbase"}}, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handleSell(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/coin_sell 1 ETH")); err != nil {
|
||||
t.Fatalf("handleSell: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Insufficient ETH")
|
||||
}
|
||||
|
||||
func TestPriceErrorDoesNotMutatePortfolio(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(nil, errors.New("upstream down"))
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handleBuy(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_buy 10 BTC")); err != nil {
|
||||
t.Fatalf("handleBuy: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Could not fetch coin price")
|
||||
p, err := LoadPortfolio(ctx, s.kv, 7, 999)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPortfolio: %v", err)
|
||||
}
|
||||
if p.USD != 0 || len(p.Assets) != 0 {
|
||||
t.Fatalf("unexpected mutation = %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatsWithAndWithoutPrice(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(map[string]CoinPrice{"BTC": {USD: 50000, Source: "Binance"}}, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
_ = s.handleTopup(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_topup 1000"))
|
||||
_ = s.handleBuy(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_buy 500 BTC"))
|
||||
rb.Reset()
|
||||
if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_stats")); err != nil {
|
||||
t.Fatalf("handleStats: %v", err)
|
||||
}
|
||||
text := rb.LastSent().Text()
|
||||
for _, want := range []string{"Coin Account Summary", "BTC: 0.01", "(Binance)", "P&L:"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("stats missing %q in %q", want, text)
|
||||
}
|
||||
}
|
||||
s.prices = fakePriceFetcher{err: ErrNoCoinPrice}
|
||||
rb.Reset()
|
||||
if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_stats")); err != nil {
|
||||
t.Fatalf("handleStats no price: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "price unavailable")
|
||||
}
|
||||
|
||||
func modDepsForTest() modules.Deps {
|
||||
return modules.Deps{KV: storage.NewMemoryKVStore()}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package coin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/keylock"
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
type priceFetcher interface {
|
||||
FetchUSD(ctx context.Context, coin CoinSymbol) (CoinPrice, error)
|
||||
}
|
||||
|
||||
type state struct {
|
||||
kv storage.KVStore
|
||||
prices priceFetcher
|
||||
locks keylock.Map
|
||||
nowFn func() time.Time
|
||||
}
|
||||
|
||||
func newState(kv storage.KVStore) *state {
|
||||
return &state{kv: kv, prices: NewPriceClientFromEnv()}
|
||||
}
|
||||
|
||||
func (s *state) now() time.Time {
|
||||
if s.nowFn != nil {
|
||||
return s.nowFn()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func senderInfo(update *models.Update) (userID int64, ok bool) {
|
||||
msg := update.Message
|
||||
if msg == nil || msg.From == nil || msg.From.ID == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return msg.From.ID, true
|
||||
}
|
||||
|
||||
func argsAfterCommand(text string) []string {
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) <= 1 {
|
||||
return nil
|
||||
}
|
||||
return parts[1:]
|
||||
}
|
||||
|
||||
func parsePositiveFinite(s string) (float64, bool) {
|
||||
n, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil || !isPositiveFinite(n) {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func isPositiveFinite(n float64) bool {
|
||||
return n > 0 && !math.IsNaN(n) && !math.IsInf(n, 0)
|
||||
}
|
||||
|
||||
func isSafeUSD(n float64) bool {
|
||||
return isPositiveFinite(n) && n <= float64(math.MaxInt64)
|
||||
}
|
||||
|
||||
func (s *state) replyPriceError(ctx context.Context, b *bot.Bot, update *models.Update, err error) error {
|
||||
if errors.Is(err, ErrUnsupportedCoin) {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Unsupported coin. Supported: BTC, ETH, SOL, BNB, XRP, ADA, DOGE, TON.")
|
||||
}
|
||||
if errors.Is(err, ErrNoCoinPrice) {
|
||||
return chathelper.Reply(ctx, b, update.Message, "No coin price available.")
|
||||
}
|
||||
log.Error("coin_fetch_price", "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not fetch coin price. Try again later.")
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package coin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
const coinDustEpsilon = 1e-9
|
||||
const portfolioUpdateAttempts = 5
|
||||
|
||||
type Portfolio struct {
|
||||
USD float64 `json:"usd"`
|
||||
Assets map[string]float64 `json:"assets"`
|
||||
Meta PortfolioMeta `json:"meta"`
|
||||
}
|
||||
|
||||
type PortfolioMeta struct {
|
||||
Invested float64 `json:"invested"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
func NewPortfolio(now int64) Portfolio {
|
||||
return Portfolio{Assets: map[string]float64{}, Meta: PortfolioMeta{CreatedAt: now}}
|
||||
}
|
||||
|
||||
func portfolioKey(userID int64) string {
|
||||
return "user:" + strconv.FormatInt(userID, 10)
|
||||
}
|
||||
|
||||
func LoadPortfolio(ctx context.Context, kv storage.KVStore, userID int64, now int64) (Portfolio, error) {
|
||||
var p Portfolio
|
||||
err := kv.GetJSON(ctx, portfolioKey(userID), &p)
|
||||
switch {
|
||||
case err == nil:
|
||||
p.normalize()
|
||||
if p.Assets == nil {
|
||||
p.Assets = map[string]float64{}
|
||||
}
|
||||
if p.Meta.CreatedAt == 0 {
|
||||
p.Meta.CreatedAt = now
|
||||
}
|
||||
return p, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return NewPortfolio(now), nil
|
||||
default:
|
||||
return Portfolio{}, fmt.Errorf("coin: load portfolio %d: %w", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func SavePortfolio(ctx context.Context, kv storage.KVStore, userID int64, p Portfolio) error {
|
||||
p.normalize()
|
||||
if err := kv.PutJSON(ctx, portfolioKey(userID), p); err != nil {
|
||||
return fmt.Errorf("coin: save portfolio %d: %w", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdatePortfolio(ctx context.Context, kv storage.KVStore, userID int64, now int64, mutate func(*Portfolio) error) (Portfolio, error) {
|
||||
cas, ok := kv.(storage.CompareAndSwapStore)
|
||||
if !ok {
|
||||
return Portfolio{}, fmt.Errorf("coin: storage does not support conditional portfolio updates")
|
||||
}
|
||||
key := portfolioKey(userID)
|
||||
for attempt := 0; attempt < portfolioUpdateAttempts; attempt++ {
|
||||
p, expected, err := loadPortfolioForUpdate(ctx, kv, key, now)
|
||||
if err != nil {
|
||||
return Portfolio{}, fmt.Errorf("coin: load portfolio %d: %w", userID, err)
|
||||
}
|
||||
if err := mutate(&p); err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.normalize()
|
||||
next, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return Portfolio{}, fmt.Errorf("coin: save portfolio %d: json encode: %w", userID, err)
|
||||
}
|
||||
if err := cas.CompareAndSwap(ctx, key, expected, next); err == nil {
|
||||
return p, nil
|
||||
} else if !errors.Is(err, storage.ErrConflict) {
|
||||
return Portfolio{}, fmt.Errorf("coin: save portfolio %d: %w", userID, err)
|
||||
}
|
||||
}
|
||||
return Portfolio{}, fmt.Errorf("coin: save portfolio %d: %w", userID, storage.ErrConflict)
|
||||
}
|
||||
|
||||
func loadPortfolioForUpdate(ctx context.Context, kv storage.KVStore, key string, now int64) (Portfolio, []byte, error) {
|
||||
raw, err := kv.Get(ctx, key)
|
||||
switch {
|
||||
case err == nil:
|
||||
var p Portfolio
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return Portfolio{}, nil, fmt.Errorf("json decode: %w", err)
|
||||
}
|
||||
p.normalize()
|
||||
if p.Assets == nil {
|
||||
p.Assets = map[string]float64{}
|
||||
}
|
||||
if p.Meta.CreatedAt == 0 {
|
||||
p.Meta.CreatedAt = now
|
||||
}
|
||||
return p, raw, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return NewPortfolio(now), nil, nil
|
||||
default:
|
||||
return Portfolio{}, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Portfolio) AddUSD(amount float64) {
|
||||
p.USD += amount
|
||||
p.normalize()
|
||||
}
|
||||
|
||||
func (p *Portfolio) DeductUSD(amount float64) (ok bool, balance float64) {
|
||||
p.normalize()
|
||||
balance = p.USD
|
||||
if balance+coinDustEpsilon < amount {
|
||||
return false, balance
|
||||
}
|
||||
p.USD = balance - amount
|
||||
p.normalize()
|
||||
return true, p.USD
|
||||
}
|
||||
|
||||
func (p *Portfolio) AddAsset(symbol string, amount float64) {
|
||||
if p.Assets == nil {
|
||||
p.Assets = map[string]float64{}
|
||||
}
|
||||
p.Assets[symbol] += amount
|
||||
p.normalize()
|
||||
}
|
||||
|
||||
func (p *Portfolio) DeductAsset(symbol string, amount float64) (ok bool, held float64) {
|
||||
if p.Assets == nil {
|
||||
p.Assets = map[string]float64{}
|
||||
}
|
||||
p.normalize()
|
||||
held = p.Assets[symbol]
|
||||
if held+coinDustEpsilon < amount {
|
||||
return false, held
|
||||
}
|
||||
p.Assets[symbol] = held - amount
|
||||
p.normalize()
|
||||
return true, p.Assets[symbol]
|
||||
}
|
||||
|
||||
func (p *Portfolio) normalize() {
|
||||
p.USD = normalizeAmount(p.USD)
|
||||
p.Meta.Invested = normalizeAmount(p.Meta.Invested)
|
||||
if p.Assets == nil {
|
||||
return
|
||||
}
|
||||
for symbol, amount := range p.Assets {
|
||||
amount = normalizeAmount(amount)
|
||||
if amount == 0 {
|
||||
delete(p.Assets, symbol)
|
||||
} else {
|
||||
p.Assets[symbol] = amount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAmount(n float64) float64 {
|
||||
if math.IsNaN(n) || math.IsInf(n, 0) {
|
||||
return 0
|
||||
}
|
||||
if math.Abs(n) < coinDustEpsilon {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package coin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
func TestLoadPortfolioFirstTimeUser(t *testing.T) {
|
||||
p, err := LoadPortfolio(context.Background(), storage.NewMemoryKVStore(), 42, 123)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPortfolio: %v", err)
|
||||
}
|
||||
if p.USD != 0 || len(p.Assets) != 0 || p.Meta.CreatedAt != 123 {
|
||||
t.Fatalf("portfolio = %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortfolioBuySellMath(t *testing.T) {
|
||||
p := NewPortfolio(1)
|
||||
p.AddUSD(1000)
|
||||
p.Meta.Invested = 1000
|
||||
if ok, bal := p.DeductUSD(250); !ok || bal != 750 {
|
||||
t.Fatalf("DeductUSD ok=%v bal=%v", ok, bal)
|
||||
}
|
||||
p.AddAsset("BTC", 0.1)
|
||||
if ok, held := p.DeductAsset("BTC", 0.04); !ok || math.Abs(held-0.06) > 1e-12 {
|
||||
t.Fatalf("DeductAsset ok=%v held=%v", ok, held)
|
||||
}
|
||||
if p.Assets["BTC"] <= 0 {
|
||||
t.Fatalf("BTC holding missing: %+v", p.Assets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeductInsufficientBalances(t *testing.T) {
|
||||
p := NewPortfolio(1)
|
||||
p.AddUSD(10)
|
||||
p.AddAsset("ETH", 0.5)
|
||||
if ok, bal := p.DeductUSD(11); ok || bal != 10 || p.USD != 10 {
|
||||
t.Fatalf("DeductUSD ok=%v bal=%v p=%+v", ok, bal, p)
|
||||
}
|
||||
if ok, held := p.DeductAsset("ETH", 0.6); ok || held != 0.5 || p.Assets["ETH"] != 0.5 {
|
||||
t.Fatalf("DeductAsset ok=%v held=%v p=%+v", ok, held, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeductAssetDustCleanup(t *testing.T) {
|
||||
p := NewPortfolio(1)
|
||||
p.AddAsset("BTC", 0.1)
|
||||
p.AddAsset("BTC", 0.2)
|
||||
if ok, _ := p.DeductAsset("BTC", 0.3); !ok {
|
||||
t.Fatal("DeductAsset ok=false")
|
||||
}
|
||||
if _, ok := p.Assets["BTC"]; ok {
|
||||
t.Fatalf("dust key not removed: %+v", p.Assets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAmountSpecialValues(t *testing.T) {
|
||||
for _, n := range []float64{math.NaN(), math.Inf(1), math.Inf(-1), coinDustEpsilon / 2} {
|
||||
if got := normalizeAmount(n); got != 0 {
|
||||
t.Fatalf("normalizeAmount(%v) = %v, want 0", n, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type conflictOnceStore struct {
|
||||
storage.KVStore
|
||||
conflicted bool
|
||||
}
|
||||
|
||||
func (s *conflictOnceStore) CompareAndSwap(ctx context.Context, key string, expected []byte, val []byte) error {
|
||||
if !s.conflicted {
|
||||
s.conflicted = true
|
||||
competing := NewPortfolio(1)
|
||||
competing.AddUSD(10)
|
||||
if err := s.PutJSON(ctx, key, competing); err != nil {
|
||||
return err
|
||||
}
|
||||
return storage.ErrConflict
|
||||
}
|
||||
return s.KVStore.(storage.CompareAndSwapStore).CompareAndSwap(ctx, key, expected, val)
|
||||
}
|
||||
|
||||
func TestUpdatePortfolioRetriesAfterWriteConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := &conflictOnceStore{KVStore: storage.NewMemoryKVStore()}
|
||||
got, err := UpdatePortfolio(ctx, kv, 7, 1, func(p *Portfolio) error {
|
||||
p.AddUSD(5)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePortfolio: %v", err)
|
||||
}
|
||||
if got.USD != 15 {
|
||||
t.Fatalf("USD = %v, want 15", got.USD)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePortfolioMutateErrorDoesNotPersist(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := storage.NewMemoryKVStore()
|
||||
_, err := UpdatePortfolio(ctx, kv, 7, 1, func(p *Portfolio) error {
|
||||
return errInsufficientUSD
|
||||
})
|
||||
if !errors.Is(err, errInsufficientUSD) {
|
||||
t.Fatalf("got %v, want errInsufficientUSD", err)
|
||||
}
|
||||
if _, err := kv.Get(ctx, "user:7"); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Fatalf("failed mutate must not persist, Get = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package coin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var errProviderRateLimited = errors.New("coin: provider rate limited")
|
||||
|
||||
type BinanceProvider struct {
|
||||
HTTP *http.Client
|
||||
URL string
|
||||
}
|
||||
|
||||
type CoinbaseProvider struct {
|
||||
HTTP *http.Client
|
||||
URL string
|
||||
}
|
||||
|
||||
type CoinGeckoProvider struct {
|
||||
HTTP *http.Client
|
||||
URL string
|
||||
}
|
||||
|
||||
type binanceResponse struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Price string `json:"price"`
|
||||
}
|
||||
|
||||
type coinbaseResponse struct {
|
||||
Data struct {
|
||||
Currency string `json:"currency"`
|
||||
Rates map[string]string `json:"rates"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type coinGeckoQuote struct {
|
||||
USD float64 `json:"usd"`
|
||||
}
|
||||
|
||||
func (p *BinanceProvider) FetchUSD(ctx context.Context, coin CoinSymbol) (CoinPrice, error) {
|
||||
for _, quote := range []string{"USDT", "USD"} {
|
||||
price, err := p.fetchPair(ctx, coin.Symbol, quote)
|
||||
if err == nil {
|
||||
return price, nil
|
||||
}
|
||||
if errors.Is(err, errProviderRateLimited) {
|
||||
return CoinPrice{}, err
|
||||
}
|
||||
}
|
||||
return CoinPrice{}, ErrNoCoinPrice
|
||||
}
|
||||
|
||||
func (p *BinanceProvider) fetchPair(ctx context.Context, symbol, quote string) (CoinPrice, error) {
|
||||
endpoint := p.baseURL()
|
||||
if err := validateEndpoint(endpoint); err != nil {
|
||||
return CoinPrice{}, err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("symbol", symbol+quote)
|
||||
resp, err := getJSON(ctx, p.HTTP, endpoint+"?"+q.Encode())
|
||||
if err != nil {
|
||||
return CoinPrice{}, fmt.Errorf("coin: Binance request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusTeapot {
|
||||
return CoinPrice{}, errProviderRateLimited
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return CoinPrice{}, ErrNoCoinPrice
|
||||
}
|
||||
var body binanceResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return CoinPrice{}, fmt.Errorf("coin: Binance decode: %w", err)
|
||||
}
|
||||
price, err := parsePositivePrice(body.Price)
|
||||
if err != nil {
|
||||
return CoinPrice{}, ErrNoCoinPrice
|
||||
}
|
||||
return CoinPrice{Symbol: symbol, USD: price, Source: "Binance"}, nil
|
||||
}
|
||||
|
||||
func (p *BinanceProvider) baseURL() string {
|
||||
if strings.TrimSpace(p.URL) != "" {
|
||||
return strings.TrimSpace(p.URL)
|
||||
}
|
||||
return binanceDefaultURL
|
||||
}
|
||||
|
||||
func (p *CoinbaseProvider) FetchUSD(ctx context.Context, coin CoinSymbol) (CoinPrice, error) {
|
||||
endpoint := p.baseURL()
|
||||
if err := validateEndpoint(endpoint); err != nil {
|
||||
return CoinPrice{}, err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("currency", coin.Symbol)
|
||||
resp, err := getJSON(ctx, p.HTTP, endpoint+"?"+q.Encode())
|
||||
if err != nil {
|
||||
return CoinPrice{}, fmt.Errorf("coin: Coinbase request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return CoinPrice{}, ErrNoCoinPrice
|
||||
}
|
||||
var body coinbaseResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return CoinPrice{}, fmt.Errorf("coin: Coinbase decode: %w", err)
|
||||
}
|
||||
price, err := parsePositivePrice(body.Data.Rates["USD"])
|
||||
if err != nil {
|
||||
return CoinPrice{}, ErrNoCoinPrice
|
||||
}
|
||||
return CoinPrice{Symbol: coin.Symbol, USD: price, Source: "Coinbase"}, nil
|
||||
}
|
||||
|
||||
func (p *CoinbaseProvider) baseURL() string {
|
||||
if strings.TrimSpace(p.URL) != "" {
|
||||
return strings.TrimSpace(p.URL)
|
||||
}
|
||||
return coinbaseDefaultURL
|
||||
}
|
||||
|
||||
func (p *CoinGeckoProvider) FetchUSD(ctx context.Context, coin CoinSymbol) (CoinPrice, error) {
|
||||
endpoint := p.baseURL()
|
||||
if err := validateEndpoint(endpoint); err != nil {
|
||||
return CoinPrice{}, err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("ids", coin.CoinGeckoID)
|
||||
q.Set("vs_currencies", "usd")
|
||||
q.Set("include_last_updated_at", "true")
|
||||
resp, err := getJSON(ctx, p.HTTP, endpoint+"?"+q.Encode())
|
||||
if err != nil {
|
||||
return CoinPrice{}, fmt.Errorf("coin: CoinGecko request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return CoinPrice{}, ErrNoCoinPrice
|
||||
}
|
||||
var body map[string]coinGeckoQuote
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return CoinPrice{}, fmt.Errorf("coin: CoinGecko decode: %w", err)
|
||||
}
|
||||
quote := body[coin.CoinGeckoID]
|
||||
if quote.USD <= 0 {
|
||||
return CoinPrice{}, ErrNoCoinPrice
|
||||
}
|
||||
return CoinPrice{Symbol: coin.Symbol, USD: quote.USD, Source: "CoinGecko"}, nil
|
||||
}
|
||||
|
||||
func (p *CoinGeckoProvider) baseURL() string {
|
||||
if strings.TrimSpace(p.URL) != "" {
|
||||
return strings.TrimSpace(p.URL)
|
||||
}
|
||||
return coinGeckoDefaultURL
|
||||
}
|
||||
|
||||
func getJSON(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (miti99bot)")
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: coinHTTPTimeout}
|
||||
}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
func parsePositivePrice(raw string) (float64, error) {
|
||||
price, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||||
if err != nil || !isPositiveFinite(price) {
|
||||
return 0, ErrNoCoinPrice
|
||||
}
|
||||
return price, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package coin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validateEndpoint(raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return fmt.Errorf("coin: invalid API URL %q", raw)
|
||||
}
|
||||
if u.Scheme == "https" {
|
||||
return nil
|
||||
}
|
||||
if u.Scheme == "http" && isLocalHost(u.Hostname()) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("coin: API URL must be https: %s", raw)
|
||||
}
|
||||
|
||||
func isLocalHost(host string) bool {
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package coin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
binanceDefaultURL = "https://api.binance.com/api/v3/ticker/price"
|
||||
coinbaseDefaultURL = "https://api.coinbase.com/v2/exchange-rates"
|
||||
coinGeckoDefaultURL = "https://api.coingecko.com/api/v3/simple/price"
|
||||
coinHTTPTimeout = 10 * time.Second
|
||||
coinPriceCacheTTL = 30 * time.Second
|
||||
)
|
||||
|
||||
var ErrNoCoinPrice = errors.New("coin: no price available")
|
||||
|
||||
type CoinPrice struct {
|
||||
Symbol string
|
||||
USD float64
|
||||
Source string
|
||||
}
|
||||
|
||||
type PriceProvider interface {
|
||||
FetchUSD(ctx context.Context, coin CoinSymbol) (CoinPrice, error)
|
||||
}
|
||||
|
||||
type PriceClient struct {
|
||||
Providers []PriceProvider
|
||||
CacheTTL time.Duration
|
||||
nowFn func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]cachedPrice
|
||||
}
|
||||
|
||||
type cachedPrice struct {
|
||||
price CoinPrice
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
func NewPriceClientFromEnv() *PriceClient {
|
||||
httpClient := &http.Client{Timeout: coinHTTPTimeout}
|
||||
return &PriceClient{
|
||||
Providers: []PriceProvider{
|
||||
&BinanceProvider{HTTP: httpClient, URL: os.Getenv("COIN_BINANCE_API_URL")},
|
||||
&CoinbaseProvider{HTTP: httpClient, URL: os.Getenv("COIN_COINBASE_API_URL")},
|
||||
&CoinGeckoProvider{HTTP: httpClient, URL: os.Getenv("COIN_COINGECKO_API_URL")},
|
||||
},
|
||||
CacheTTL: coinPriceCacheTTL,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *PriceClient) FetchUSD(ctx context.Context, coin CoinSymbol) (CoinPrice, error) {
|
||||
if coin.Symbol == "" {
|
||||
return CoinPrice{}, ErrUnsupportedCoin
|
||||
}
|
||||
if price, ok := c.cached(coin.Symbol); ok {
|
||||
return price, nil
|
||||
}
|
||||
var errs []error
|
||||
for _, provider := range c.Providers {
|
||||
if provider == nil {
|
||||
continue
|
||||
}
|
||||
price, err := provider.FetchUSD(ctx, coin)
|
||||
if err == nil && price.USD > 0 {
|
||||
price.Symbol = coin.Symbol
|
||||
c.store(coin.Symbol, price)
|
||||
return price, nil
|
||||
}
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
return CoinPrice{}, ErrNoCoinPrice
|
||||
}
|
||||
return CoinPrice{}, fmt.Errorf("coin: all price providers failed: %w", ErrNoCoinPrice)
|
||||
}
|
||||
|
||||
func (c *PriceClient) cached(symbol string) (CoinPrice, bool) {
|
||||
ttl := c.CacheTTL
|
||||
if ttl <= 0 {
|
||||
return CoinPrice{}, false
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.cache == nil {
|
||||
return CoinPrice{}, false
|
||||
}
|
||||
entry, ok := c.cache[symbol]
|
||||
if !ok || !c.now().Before(entry.expiry) {
|
||||
return CoinPrice{}, false
|
||||
}
|
||||
return entry.price, true
|
||||
}
|
||||
|
||||
func (c *PriceClient) store(symbol string, price CoinPrice) {
|
||||
ttl := c.CacheTTL
|
||||
if ttl <= 0 || price.USD <= 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.cache == nil {
|
||||
c.cache = map[string]cachedPrice{}
|
||||
}
|
||||
c.cache[symbol] = cachedPrice{price: price, expiry: c.now().Add(ttl)}
|
||||
}
|
||||
|
||||
func (c *PriceClient) now() time.Time {
|
||||
if c.nowFn != nil {
|
||||
return c.nowFn()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package coin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBinanceProviderFetchUSD(t *testing.T) {
|
||||
coin := mustCoin(t, "BTC")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("symbol"); got != "BTCUSDT" {
|
||||
t.Fatalf("symbol = %q, want BTCUSDT", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"symbol":"BTCUSDT","price":"67000.25"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
price, err := (&BinanceProvider{URL: srv.URL}).FetchUSD(context.Background(), coin)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchUSD: %v", err)
|
||||
}
|
||||
if price.USD != 67000.25 || price.Source != "Binance" || price.Symbol != "BTC" {
|
||||
t.Fatalf("price = %+v", price)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBinanceRateLimitDoesNotTrySecondPair(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, err := (&BinanceProvider{URL: srv.URL}).FetchUSD(context.Background(), mustCoin(t, "BTC"))
|
||||
if err == nil {
|
||||
t.Fatal("want rate-limit error")
|
||||
}
|
||||
if hits.Load() != 1 {
|
||||
t.Fatalf("hits = %d, want 1", hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoinbaseProviderFetchUSD(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("currency"); got != "ETH" {
|
||||
t.Fatalf("currency = %q, want ETH", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":{"currency":"ETH","rates":{"USD":"3500.5"}}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
price, err := (&CoinbaseProvider{URL: srv.URL}).FetchUSD(context.Background(), mustCoin(t, "ETH"))
|
||||
if err != nil {
|
||||
t.Fatalf("FetchUSD: %v", err)
|
||||
}
|
||||
if price.USD != 3500.5 || price.Source != "Coinbase" {
|
||||
t.Fatalf("price = %+v", price)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoinGeckoProviderFetchUSD(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("ids"); got != "solana" {
|
||||
t.Fatalf("ids = %q, want solana", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"solana":{"usd":150.75,"last_updated_at":1711356300}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
price, err := (&CoinGeckoProvider{URL: srv.URL}).FetchUSD(context.Background(), mustCoin(t, "SOL"))
|
||||
if err != nil {
|
||||
t.Fatalf("FetchUSD: %v", err)
|
||||
}
|
||||
if price.USD != 150.75 || price.Source != "CoinGecko" {
|
||||
t.Fatalf("price = %+v", price)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeProvider struct {
|
||||
price CoinPrice
|
||||
err error
|
||||
hits atomic.Int32
|
||||
}
|
||||
|
||||
func (p *fakeProvider) FetchUSD(context.Context, CoinSymbol) (CoinPrice, error) {
|
||||
p.hits.Add(1)
|
||||
return p.price, p.err
|
||||
}
|
||||
|
||||
func TestPriceClientFallbackAndCache(t *testing.T) {
|
||||
first := &fakeProvider{err: ErrNoCoinPrice}
|
||||
second := &fakeProvider{price: CoinPrice{USD: 123, Source: "Coinbase"}}
|
||||
third := &fakeProvider{price: CoinPrice{USD: 456, Source: "CoinGecko"}}
|
||||
now := time.Unix(100, 0)
|
||||
client := &PriceClient{
|
||||
Providers: []PriceProvider{first, second, third},
|
||||
CacheTTL: time.Minute,
|
||||
nowFn: func() time.Time { return now },
|
||||
}
|
||||
coin := mustCoin(t, "BTC")
|
||||
got, err := client.FetchUSD(context.Background(), coin)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchUSD: %v", err)
|
||||
}
|
||||
if got.USD != 123 || got.Source != "Coinbase" {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
if _, err := client.FetchUSD(context.Background(), coin); err != nil {
|
||||
t.Fatalf("FetchUSD cached: %v", err)
|
||||
}
|
||||
if first.hits.Load() != 1 || second.hits.Load() != 1 || third.hits.Load() != 0 {
|
||||
t.Fatalf("hits first=%d second=%d third=%d", first.hits.Load(), second.hits.Load(), third.hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceClientFallsThroughToCoinGecko(t *testing.T) {
|
||||
client := &PriceClient{Providers: []PriceProvider{
|
||||
&fakeProvider{err: ErrNoCoinPrice},
|
||||
&fakeProvider{err: ErrNoCoinPrice},
|
||||
&fakeProvider{price: CoinPrice{USD: 42, Source: "CoinGecko"}},
|
||||
}}
|
||||
got, err := client.FetchUSD(context.Background(), mustCoin(t, "DOGE"))
|
||||
if err != nil {
|
||||
t.Fatalf("FetchUSD: %v", err)
|
||||
}
|
||||
if got.Source != "CoinGecko" || got.USD != 42 {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceClientAllProvidersFail(t *testing.T) {
|
||||
client := &PriceClient{Providers: []PriceProvider{&fakeProvider{err: ErrNoCoinPrice}}}
|
||||
_, err := client.FetchUSD(context.Background(), mustCoin(t, "BTC"))
|
||||
if !errors.Is(err, ErrNoCoinPrice) {
|
||||
t.Fatalf("got %v, want ErrNoCoinPrice", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEndpoint(t *testing.T) {
|
||||
if err := validateEndpoint("https://example.com/path"); err != nil {
|
||||
t.Fatalf("https should pass: %v", err)
|
||||
}
|
||||
if err := validateEndpoint("http://localhost:1234/path"); err != nil {
|
||||
t.Fatalf("localhost http should pass: %v", err)
|
||||
}
|
||||
if err := validateEndpoint("http://example.com/path"); err == nil {
|
||||
t.Fatal("remote http should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func mustCoin(t *testing.T, symbol string) CoinSymbol {
|
||||
t.Helper()
|
||||
coin, err := ResolveCoinSymbol(symbol)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveCoinSymbol(%q): %v", symbol, err)
|
||||
}
|
||||
return coin
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package coin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrUnsupportedCoin = errors.New("coin: unsupported coin")
|
||||
|
||||
type CoinSymbol struct {
|
||||
Symbol string
|
||||
CoinGeckoID string
|
||||
}
|
||||
|
||||
var supportedCoins = map[string]CoinSymbol{
|
||||
"BTC": {Symbol: "BTC", CoinGeckoID: "bitcoin"},
|
||||
"ETH": {Symbol: "ETH", CoinGeckoID: "ethereum"},
|
||||
"SOL": {Symbol: "SOL", CoinGeckoID: "solana"},
|
||||
"BNB": {Symbol: "BNB", CoinGeckoID: "binancecoin"},
|
||||
"XRP": {Symbol: "XRP", CoinGeckoID: "ripple"},
|
||||
"ADA": {Symbol: "ADA", CoinGeckoID: "cardano"},
|
||||
"DOGE": {Symbol: "DOGE", CoinGeckoID: "dogecoin"},
|
||||
"TON": {Symbol: "TON", CoinGeckoID: "the-open-network"},
|
||||
}
|
||||
|
||||
func ResolveCoinSymbol(input string) (CoinSymbol, error) {
|
||||
symbol := strings.ToUpper(strings.TrimSpace(input))
|
||||
if symbol == "" {
|
||||
return CoinSymbol{}, ErrUnsupportedCoin
|
||||
}
|
||||
coin, ok := supportedCoins[symbol]
|
||||
if !ok {
|
||||
return CoinSymbol{}, ErrUnsupportedCoin
|
||||
}
|
||||
return coin, nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package coin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
|
||||
)
|
||||
|
||||
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
userID, ok := senderInfo(update)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Cannot identify user - /coin_stats needs a sender.")
|
||||
}
|
||||
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
|
||||
if err != nil {
|
||||
log.Error("coin_load_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not load coin portfolio. Try again later.")
|
||||
}
|
||||
lines := []string{"Coin Account Summary\n", "USD: " + FormatUSD(p.USD)}
|
||||
totalValue := p.USD
|
||||
for _, symbol := range sortedAssetSymbols(p.Assets) {
|
||||
held := p.Assets[symbol]
|
||||
line := symbol + ": " + FormatCoinQty(held)
|
||||
if coin, err := ResolveCoinSymbol(symbol); err == nil {
|
||||
if price, err := s.prices.FetchUSD(ctx, coin); err == nil {
|
||||
value := held * price.USD
|
||||
totalValue += value
|
||||
line += " = " + FormatUSD(value) + " @ " + FormatUSD(price.USD) + " (" + price.Source + ")"
|
||||
} else {
|
||||
line += " (price unavailable)"
|
||||
}
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
lines = append(lines, "Total value: "+FormatUSD(totalValue))
|
||||
lines = append(lines, "Invested: "+FormatUSD(p.Meta.Invested))
|
||||
lines = append(lines, "P&L: "+FormatPnLUSD(totalValue, p.Meta.Invested))
|
||||
return chathelper.Reply(ctx, b, update.Message, strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func sortedAssetSymbols(assets map[string]float64) []string {
|
||||
symbols := make([]string, 0, len(assets))
|
||||
for symbol, amount := range assets {
|
||||
if amount > 0 {
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
}
|
||||
sort.Strings(symbols)
|
||||
return symbols
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
phase: 1
|
||||
title: Price provider chain
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: 3-4h
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 1: Price provider chain
|
||||
|
||||
## Context Links
|
||||
|
||||
- Research: `plans/reports/260612-0948-coin-module-research-report.md`
|
||||
- Reference price clients: `internal/modules/gold/prices.go`, `internal/modules/trading/prices.go`
|
||||
|
||||
## Overview
|
||||
|
||||
Build the crypto USD price lookup layer with fixed fallback order: Binance, Coinbase, CoinGecko. This phase should be independent from Telegram handlers and portfolio mutation.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- Binance `/api/v3/ticker/price` is best first for major `USDT` pairs but pair coverage is not universal.
|
||||
- Coinbase `/v2/exchange-rates` requires no auth and returns direct USD rates for supported base currencies.
|
||||
- CoinGecko `/simple/price` has broad coin-ID coverage but public/demo rate limit is around 30 calls/min and variable.
|
||||
- All provider failures must return typed errors and allow fallback where safe.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: fetch USD price for whitelisted symbol.
|
||||
- Functional: try Binance `SYMBOLUSDT`, then optionally `SYMBOLUSD`, then Coinbase, then CoinGecko.
|
||||
- Functional: return `CoinPrice{Symbol, USD, Source}` from first valid provider.
|
||||
- Functional: expose env URL overrides: `COIN_BINANCE_API_URL`, `COIN_COINBASE_API_URL`, `COIN_COINGECKO_API_URL`.
|
||||
- Non-functional: 10s HTTP timeout, injected HTTP client for tests, no network calls in tests.
|
||||
- Non-functional: 15-30s in-memory cache; cache only valid positive prices.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
handlers/stats
|
||||
-> PriceClient.FetchUSD(symbol)
|
||||
-> cache lookup
|
||||
-> BinanceProvider.FetchUSD(symbol)
|
||||
-> CoinbaseProvider.FetchUSD(symbol)
|
||||
-> CoinGeckoProvider.FetchUSD(symbol)
|
||||
-> ErrNoCoinPrice
|
||||
```
|
||||
|
||||
Provider-specific structs stay inside `internal/modules/coin`. Do not create shared price infrastructure until another module needs it.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `internal/modules/coin/prices.go`
|
||||
- Create: `internal/modules/coin/price_providers.go`
|
||||
- Create: `internal/modules/coin/symbols.go`
|
||||
- Create: `internal/modules/coin/prices_test.go`
|
||||
- Read: `internal/modules/gold/prices.go`
|
||||
- Read: `internal/modules/gold/price_providers.go`
|
||||
- Read: `internal/modules/trading/prices.go`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Define `CoinPrice`, `PriceProvider`, `PriceClient`, `ErrNoCoinPrice`, and provider error handling.
|
||||
2. Add supported symbol mapping:
|
||||
- `BTC -> bitcoin`
|
||||
- `ETH -> ethereum`
|
||||
- `SOL -> solana`
|
||||
- `BNB -> binancecoin`
|
||||
- `XRP -> ripple`
|
||||
- `ADA -> cardano`
|
||||
- `DOGE -> dogecoin`
|
||||
- `TON -> the-open-network`
|
||||
3. Implement Binance provider:
|
||||
- request `?symbol={SYMBOL}USDT` first
|
||||
- if no price, request `?symbol={SYMBOL}USD`
|
||||
- parse JSON string `price`
|
||||
- treat non-2xx, 429, invalid/zero price as fallback-safe errors
|
||||
4. Implement Coinbase provider:
|
||||
- request `?currency={SYMBOL}`
|
||||
- parse `data.rates.USD` string
|
||||
5. Implement CoinGecko provider:
|
||||
- request `?ids={coinID}&vs_currencies=usd&include_last_updated_at=true`
|
||||
- parse `{coinID}.usd`
|
||||
6. Implement cache keyed by symbol with source and expiry.
|
||||
7. Add URL validation if following gold's endpoint validation pattern. At minimum reject blank/malformed override URLs.
|
||||
8. Wrap errors with `coin:` prefix but keep typed `ErrNoCoinPrice` detectable.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [x] Price client and provider interfaces added.
|
||||
- [x] Binance provider implemented.
|
||||
- [x] Coinbase provider implemented.
|
||||
- [x] CoinGecko provider implemented.
|
||||
- [x] Symbol whitelist and CoinGecko ID mapping added.
|
||||
- [x] Cache implemented and tested.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] First valid provider wins and returns source name.
|
||||
- [x] 429/non-2xx/decode/no-price failures fall through to next provider.
|
||||
- [x] Unsupported local symbols fail before network calls.
|
||||
- [x] Unit tests cover each provider and fallback path.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Main risk: accidental provider spam from `/coin_stats`. Mitigation: cache and no retry loops. Binance has IP-ban risk after repeated 429 abuse; on 429, immediately fall through and do not re-call within cache/backoff window.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
No API keys required. Do not add secrets. Do not accept arbitrary user-provided URLs; env overrides only. Reject untrusted symbols before request building.
|
||||
|
||||
## Next Steps
|
||||
|
||||
Phase 2 consumes `PriceClient` via a small interface so handlers can use fakes in tests.
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
phase: 2
|
||||
title: Portfolio and commands
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: 4-5h
|
||||
dependencies:
|
||||
- 1
|
||||
---
|
||||
|
||||
# Phase 2: Portfolio and commands
|
||||
|
||||
## Context Links
|
||||
|
||||
- Price client phase: `phase-01-price-provider-chain.md`
|
||||
- Gold handler pattern: `internal/modules/gold/handlers.go`, `internal/modules/gold/portfolio.go`
|
||||
- Trading handler pattern: `internal/modules/trading/handlers.go`, `internal/modules/trading/portfolio.go`
|
||||
|
||||
## Overview
|
||||
|
||||
Implement the standalone `coin` module state, portfolio model, formatting helpers, and Telegram commands for USD paper trading.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- Coin balances must be fractional `float64`, like gold holdings.
|
||||
- Portfolio state must stay module-scoped via `kv.For("coin")`; storage key can remain `user:<telegramID>` inside namespace.
|
||||
- Fetch price before acquiring per-user lock or CAS update.
|
||||
- Use `UpdatePortfolio` CAS pattern from `gold` if storage supports `CompareAndSwapStore`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: `/coin_price <COIN>` shows USD price and provider source.
|
||||
- Functional: `/coin_topup <usd_amount>` credits fake USD and increments invested.
|
||||
- Functional: `/coin_buy <usd_amount> <COIN>` deducts USD and credits `usd_amount / price` units.
|
||||
- Functional: `/coin_sell <qty> <COIN>` deducts coin units and credits `qty * price` USD.
|
||||
- Functional: `/coin_stats` shows USD, holdings, price source, market value, total, invested, P&L.
|
||||
- Non-functional: reject invalid sender, unsupported coin, non-finite amount, insufficient balance, and too-large trade values.
|
||||
|
||||
## Architecture
|
||||
|
||||
```go
|
||||
type Portfolio struct {
|
||||
USD float64 `json:"usd"`
|
||||
Assets map[string]float64 `json:"assets"`
|
||||
Meta PortfolioMeta `json:"meta"`
|
||||
}
|
||||
|
||||
type PortfolioMeta struct {
|
||||
Invested float64 `json:"invested"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
```
|
||||
|
||||
Handlers depend on a `priceFetcher` interface returning `CoinPrice`, not concrete providers. This keeps tests deterministic.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `internal/modules/coin/coin.go`
|
||||
- Create: `internal/modules/coin/helpers.go`
|
||||
- Create: `internal/modules/coin/handlers.go`
|
||||
- Create: `internal/modules/coin/portfolio.go`
|
||||
- Create: `internal/modules/coin/format.go`
|
||||
- Create: `internal/modules/coin/handlers_test.go`
|
||||
- Create: `internal/modules/coin/portfolio_test.go`
|
||||
- Read: `internal/modules/gold/*`
|
||||
- Read: `internal/modules/trading/*`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Create `coin.go` with public commands:
|
||||
- `coin_price`
|
||||
- `coin_topup`
|
||||
- `coin_buy`
|
||||
- `coin_sell`
|
||||
- `coin_stats`
|
||||
2. Create `helpers.go` with local copies/patterns for `senderInfo`, `argsAfterCommand`, finite positive parsing, and safe USD checks.
|
||||
3. Create `portfolio.go` using gold's `UpdatePortfolio` retry/CAS approach:
|
||||
- initialize `Assets` map on load
|
||||
- normalize NaN/Inf and dust below `1e-9`
|
||||
- delete asset key when holding hits zero
|
||||
4. Create formatting helpers:
|
||||
- `FormatUSD`
|
||||
- `FormatCoinQty`
|
||||
- `FormatPnLUSD`
|
||||
5. Implement `/coin_price <COIN>` read-only path.
|
||||
6. Implement `/coin_topup <usd_amount>` with no provider call.
|
||||
7. Implement `/coin_buy <usd_amount> <COIN>`:
|
||||
- validate amount and symbol
|
||||
- fetch price
|
||||
- compute qty
|
||||
- CAS update deduct USD/add asset
|
||||
8. Implement `/coin_sell <qty> <COIN>`:
|
||||
- validate qty and symbol
|
||||
- fetch price
|
||||
- CAS update deduct asset/add USD
|
||||
9. Implement `/coin_stats`:
|
||||
- load portfolio
|
||||
- fetch prices for held assets through cached client
|
||||
- degrade gracefully if some prices fail
|
||||
10. Keep replies concise and include source, e.g. `Price: $67,321.42 (Binance)`.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [x] Module factory created.
|
||||
- [x] Portfolio state and update helpers created.
|
||||
- [x] Command handlers implemented.
|
||||
- [x] Formatting helpers created.
|
||||
- [x] Handler tests use fake price fetcher.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] All commands return usage messages for bad argument count.
|
||||
- [x] Topup changes USD and invested only.
|
||||
- [x] Buy/sell mutate state only after price success.
|
||||
- [x] Stats works with empty portfolio and with held assets.
|
||||
- [x] Source name visible in price-dependent replies.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Main risk: floating-point dust or accidental negative balances. Mitigation: finite validation, dust normalization, safe range checks, and table-driven tests around exact insufficient-balance paths.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
This is fake money. Still treat state updates as user-owned data: key by Telegram user ID, reject senderless updates, and avoid logging user balances unnecessarily.
|
||||
|
||||
## Next Steps
|
||||
|
||||
Phase 3 wires the package into startup and deployment docs.
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
---
|
||||
phase: 3
|
||||
title: Module registration and docs
|
||||
status: completed
|
||||
priority: P2
|
||||
effort: 1.5-2h
|
||||
dependencies:
|
||||
- 2
|
||||
---
|
||||
|
||||
# Phase 3: Module registration and docs
|
||||
|
||||
## Context Links
|
||||
|
||||
- Composition root: `cmd/server/main.go`
|
||||
- Module docs: `README.md`
|
||||
- Deploy config: `template.yaml`
|
||||
- Gold registration reference: `plans/260611-0735-gold-module-trading-parity/phase-04-module-registration-and-docs.md`
|
||||
|
||||
## Overview
|
||||
|
||||
Register `coin` as a first-class module, enable it in the deployed AWS module default, and document optional provider URL overrides.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- `cmd/server/main.go` owns the factory map to avoid import cycles.
|
||||
- `template.yaml` controls deployed `MODULES` default via `ModulesCSV`.
|
||||
- User explicitly requested AWS registration; `coin` is included in deployed `ModulesCSV` default.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: deployed `MODULES` default includes `coin`; `MODULES=coin` also starts and registers all coin commands.
|
||||
- Functional: optional env overrides are passed through startup config if needed by `NewCoinPriceClientFromEnv`.
|
||||
- Functional: README module table includes `coin` and command summary.
|
||||
- Non-functional: keep deployment free-tier; no SSM secret required.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
cmd/server/main.go factories()
|
||||
"coin": coin.New
|
||||
|
||||
loadConfig()
|
||||
CoinBinanceAPIURL
|
||||
CoinCoinbaseAPIURL
|
||||
CoinCoinGeckoAPIURL
|
||||
|
||||
main()
|
||||
exportOptionalEnv("COIN_BINANCE_API_URL", cfg.CoinBinanceAPIURL)
|
||||
exportOptionalEnv("COIN_COINBASE_API_URL", cfg.CoinCoinbaseAPIURL)
|
||||
exportOptionalEnv("COIN_COINGECKO_API_URL", cfg.CoinCoinGeckoAPIURL)
|
||||
```
|
||||
|
||||
If the coin price client reads env directly, config additions are optional. Prefer explicit config additions only if matching the gold pattern is worth the extra lines.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `cmd/server/main.go`
|
||||
- Modify: `README.md`
|
||||
- Modify: `template.yaml`
|
||||
- Modify/create: `cmd/server/*_test.go` if factory/config tests exist or are needed
|
||||
- Read: `internal/modules/registry_test.go`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Import `internal/modules/coin` in `cmd/server/main.go`.
|
||||
2. Add `"coin": coin.New` to `factories()`.
|
||||
3. Decide default deployment behavior:
|
||||
- add `coin` to `ModulesCSV` default so AWS deploy enables it
|
||||
4. Add non-secret template parameters only if URL overrides must be deploy-configurable:
|
||||
- `CoinBinanceAPIURL`
|
||||
- `CoinCoinbaseAPIURL`
|
||||
- `CoinCoinGeckoAPIURL`
|
||||
5. Wire env variables into Lambda environment if parameters are added.
|
||||
6. Update README module table with `coin` and provider fallback summary.
|
||||
7. Add command usage examples in README only if existing docs style supports it; otherwise keep docs minimal.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [x] Factory import and map entry added.
|
||||
- [x] README module table updated.
|
||||
- [x] `template.yaml` updated with `coin` in deployed `ModulesCSV` default.
|
||||
- [x] Optional env overrides documented.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] `modules.Build([]string{"coin"}, factories(), ...)` succeeds in test or local verification.
|
||||
- [x] `/help` can list coin commands when module enabled.
|
||||
- [x] No new secrets or paid services required.
|
||||
- [x] Docs do not overpromise real trading.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Adding `coin` to default deployed modules exposes new public commands immediately. User explicitly chose AWS registration; commands are public paper-trading only and require no secrets.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Do not add API keys or Parameter Store secrets. Provider URL overrides are non-secret config only.
|
||||
|
||||
## Next Steps
|
||||
|
||||
Phase 4 verifies unit behavior and full module integration.
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
phase: 4
|
||||
title: Tests and verification
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: 2-3h
|
||||
dependencies:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
---
|
||||
|
||||
# Phase 4: Tests and verification
|
||||
|
||||
## Context Links
|
||||
|
||||
- Existing tests: `internal/modules/gold/*_test.go`, `internal/modules/trading/*_test.go`
|
||||
- Module registry tests: `internal/modules/registry_test.go`, `cmd/server` tests if present
|
||||
- Commands: `make test`, `make vet`
|
||||
|
||||
## Overview
|
||||
|
||||
Add focused unit tests and run compile/test gates. Tests must not call real Binance, Coinbase, or CoinGecko.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- Provider tests should use `httptest.Server` or fake `RoundTripper` like existing modules.
|
||||
- Handler tests should inject fake price fetchers, not rely on provider chain.
|
||||
- Fallback behavior is the highest-risk logic; test it explicitly.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: unit tests cover provider decoding, fallback, cache, portfolio mutation, handlers, and registration.
|
||||
- Non-functional: no network in tests, deterministic time where needed, no flaky provider timing.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
coin tests
|
||||
provider fixtures -> prices.go / price_providers.go
|
||||
portfolio tests -> portfolio.go
|
||||
handler tests -> fake priceFetcher + memory KV
|
||||
registration -> factories/build if practical
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `internal/modules/coin/prices_test.go`
|
||||
- Create: `internal/modules/coin/portfolio_test.go`
|
||||
- Create: `internal/modules/coin/handlers_test.go`
|
||||
- Modify/create: `cmd/server/main_test.go` if factory coverage is missing
|
||||
- Modify: existing docs only if verification changes behavior
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add provider tests:
|
||||
- Binance success parses `price` string.
|
||||
- Binance non-2xx/429 falls back.
|
||||
- Coinbase success parses `data.rates.USD`.
|
||||
- CoinGecko success parses `{id}.usd`.
|
||||
- all providers fail -> `ErrNoCoinPrice`.
|
||||
2. Add cache tests:
|
||||
- repeated fetch within TTL avoids second provider call.
|
||||
- expired cache refetches.
|
||||
3. Add portfolio tests:
|
||||
- new user initializes USD 0 and empty assets.
|
||||
- topup increments USD and invested.
|
||||
- buy/sell math and dust cleanup.
|
||||
- insufficient USD/asset returns current balance.
|
||||
4. Add handler tests:
|
||||
- usage errors.
|
||||
- unknown coin.
|
||||
- topup success.
|
||||
- buy success and insufficient USD.
|
||||
- sell success and insufficient coin.
|
||||
- stats empty and stats with price failure partial display.
|
||||
5. Add registration test if current test structure supports it.
|
||||
6. Run verification commands:
|
||||
- `go test ./internal/modules/coin`
|
||||
- `go test ./internal/modules/... ./cmd/server`
|
||||
- `go test ./...`
|
||||
- `go vet ./...` or `make vet`
|
||||
|
||||
## Todo List
|
||||
|
||||
- [x] Provider tests added.
|
||||
- [x] Cache tests added.
|
||||
- [x] Portfolio tests added.
|
||||
- [x] Handler tests added.
|
||||
- [x] Registration/docs verification added.
|
||||
- [x] Compile/test commands run and results recorded.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] All coin tests pass without network.
|
||||
- [x] Provider fallback test proves order Binance -> Coinbase -> CoinGecko.
|
||||
- [x] Handler tests prove portfolio not mutated on price failure.
|
||||
- [x] Broader module/server tests pass.
|
||||
- [x] README/template docs match actual default module behavior.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Main risk: passing unit tests with fake providers while real endpoints have shape drift. Mitigation: keep provider decoders strict but small, include env URL overrides for quick hotfix, and make runtime error messages graceful.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Tests must not require real API keys or real network. Do not add dotenv or credentials. Avoid fixtures with sensitive data.
|
||||
|
||||
## Next Steps
|
||||
|
||||
After this phase, implementation is ready for code review and optional release decision on default enablement.
|
||||
|
||||
## Verification Results
|
||||
|
||||
- `go test ./internal/modules/coin` passed.
|
||||
- `go test ./...` passed.
|
||||
- `go vet ./...` passed.
|
||||
- `sam validate` not run: SAM CLI is not installed in this environment.
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
title: Coin module with crypto price fallback
|
||||
description: >-
|
||||
Add a standalone coin paper-trading module with USD balances and Binance ->
|
||||
Coinbase -> CoinGecko price fallback.
|
||||
status: completed
|
||||
priority: P2
|
||||
effort: 10-14h
|
||||
branch: main
|
||||
tags:
|
||||
- feature
|
||||
- backend
|
||||
- api
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
created: '2026-06-12'
|
||||
createdBy: 'ck:plan'
|
||||
source: skill
|
||||
---
|
||||
|
||||
# Coin module with crypto price fallback
|
||||
|
||||
## Overview
|
||||
|
||||
Add `internal/modules/coin` as a standalone crypto paper-trading module. Users top up fake USD, buy/sell supported coins at market price, and view portfolio stats. Price lookup uses a best-effort provider chain: Binance first, Coinbase second, CoinGecko third.
|
||||
|
||||
## Scope Challenge
|
||||
|
||||
- Existing code: `gold` already has the closest fractional-asset portfolio, CAS update, command, price-client, and docs pattern. `trading` has useful asset-map and command naming patterns.
|
||||
- Minimum changes: new `coin` package, factory registration, optional env URL config, README/template docs, tests. No shared trading refactor required.
|
||||
- Complexity: expected 10-12 touched files. New abstractions limited to `PriceProvider` interface and provider structs inside `coin` package.
|
||||
- Selected mode: HOLD SCOPE. Deliver robust MVP, defer nonessential crypto features.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- Paper trading only. No wallets, private keys, deposits, withdrawals, real exchange orders, leverage, charts, or tax logic.
|
||||
- USD-only cash balance for v1.
|
||||
- Supported coin whitelist first; no arbitrary symbols.
|
||||
- Provider order fixed for v1: Binance -> Coinbase -> CoinGecko.
|
||||
- Include price source in user replies so provider differences are visible.
|
||||
- Enable `coin` in the deployed `ModulesCSV` default so AWS registration includes the module.
|
||||
|
||||
## References
|
||||
|
||||
- Research: `plans/reports/260612-0948-coin-module-research-report.md`
|
||||
- Patterns: `internal/modules/gold`, `internal/modules/trading`
|
||||
- Composition root: `cmd/server/main.go`
|
||||
- Deployment config: `template.yaml`
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [Price provider chain](./phase-01-price-provider-chain.md) | Completed |
|
||||
| 2 | [Portfolio and commands](./phase-02-portfolio-and-commands.md) | Completed |
|
||||
| 3 | [Module registration and docs](./phase-03-module-registration-and-docs.md) | Completed |
|
||||
| 4 | [Tests and verification](./phase-04-tests-and-verification.md) | Completed |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- No blocking active plan detected.
|
||||
- Completed gold plan is reference only: `plans/260611-0735-gold-module-trading-parity/plan.md`.
|
||||
- Existing unresolved migration/deploy plans touch `trading`/infra, not `coin`; no bidirectional dependency needed.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- `/coin_price`, `/coin_topup`, `/coin_buy`, `/coin_sell`, `/coin_stats` work when `coin` is enabled.
|
||||
- Price client falls back Binance -> Coinbase -> CoinGecko and never mutates portfolio when all providers fail.
|
||||
- Tests cover portfolio math, handler validation, provider decoding, provider fallback, and module registration.
|
||||
- `go test ./internal/modules/coin ./cmd/server ./internal/modules/...` passes; run `go test ./...` before push.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Real exchange trading.
|
||||
- Wallet/on-chain integration.
|
||||
- Cross-module transfers between `trading`, `gold`, and `coin`.
|
||||
- Limit orders, recurring buys, alerts, charts, or leaderboards.
|
||||
- Dynamic coin discovery from public APIs.
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
- Should `/coin_buy` remain USD amount only, or support quantity mode too?
|
||||
- Initial whitelist: top 8 from research enough, or include more now?
|
||||
@@ -0,0 +1,431 @@
|
||||
# Research Report: Coin Module For USD Topup And Crypto Paper Trading
|
||||
|
||||
---
|
||||
type: report
|
||||
topic: coin-module
|
||||
created_at: 2026-06-12 09:48 UTC
|
||||
status: complete
|
||||
---
|
||||
|
||||
## Table Of Contents
|
||||
|
||||
- [Executive Summary](#executive-summary)
|
||||
- [Research Methodology](#research-methodology)
|
||||
- [Key Findings](#key-findings)
|
||||
- [Comparative Analysis](#comparative-analysis)
|
||||
- [Implementation Recommendations](#implementation-recommendations)
|
||||
- [Resources And References](#resources-and-references)
|
||||
- [Next Steps](#next-steps)
|
||||
- [Unresolved Questions](#unresolved-questions)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Add a separate `coin` module, not an extension of `trading`. Existing `trading` is VN-stock/VND oriented. Existing `gold` is the better structural reference for fractional assets, standalone KV namespace, price client, topup/buy/sell/stats commands, and optional env URL overrides.
|
||||
|
||||
Recommended free price strategy for MVP: provider chain with Binance, Coinbase, and CoinGecko. Try Binance first for exchange-listed USD/USDT pairs, then Coinbase public exchange rates, then CoinGecko simple price. If one provider fails, rate limits, returns no price, or does not support the coin, fall back to the next provider without mutating portfolio state.
|
||||
|
||||
Keep scope tight: paper trading only, USD cash balance only, market-price buy/sell only, whitelist common coins first. No deposits, withdrawals, live orders, wallets, tax, charts, limit orders, leverage, or on-chain tokens.
|
||||
|
||||
## Research Methodology
|
||||
|
||||
- Sources consulted: 4 official docs plus local repo source.
|
||||
- Date range: live docs checked on 2026-06-12.
|
||||
- Key search terms used: `CoinGecko simple price free API`, `Binance ticker price endpoint`, `Coinbase exchange rates unauthenticated`, `crypto price API rate limit`.
|
||||
- Local references checked:
|
||||
- `README.md`
|
||||
- `internal/modules/trading/*`
|
||||
- `internal/modules/gold/*`
|
||||
- `internal/modules/registry.go`
|
||||
- `template.yaml`
|
||||
|
||||
## Key Findings
|
||||
|
||||
### 1. Repo Architecture
|
||||
|
||||
The bot loads modules by name from `MODULES`. Each module gets module-scoped KV through `kv.For(name)`, so a new `coin` module can own independent per-user state with key `user:{telegram_id}`.
|
||||
|
||||
Existing trading flow:
|
||||
|
||||
```text
|
||||
Telegram command
|
||||
-> parse sender and args
|
||||
-> fetch/resolve price before lock
|
||||
-> acquire per-user lock or CAS update
|
||||
-> load portfolio
|
||||
-> mutate cash/assets
|
||||
-> save portfolio
|
||||
-> reply
|
||||
```
|
||||
|
||||
Use same flow. Network calls should stay outside mutation critical path.
|
||||
|
||||
### 2. Current Trade References
|
||||
|
||||
`trading` strengths:
|
||||
|
||||
- command registration style is clear.
|
||||
- `senderInfo` and `argsAfterCommand` are reusable patterns.
|
||||
- price client uses `http.Client` timeout and test injection.
|
||||
- portfolio methods keep mutation readable.
|
||||
|
||||
`gold` strengths:
|
||||
|
||||
- standalone module for one asset class.
|
||||
- fractional quantity model.
|
||||
- normalize invalid floats.
|
||||
- CAS `UpdatePortfolio` avoids lost updates when storage supports it.
|
||||
- env URL overrides for price APIs.
|
||||
|
||||
For `coin`, use `gold` as the closer reference, with `trading` command names and asset map style.
|
||||
|
||||
### 3. API Recommendation
|
||||
|
||||
Recommended primary architecture: three-provider failover.
|
||||
|
||||
```text
|
||||
Fetch coin USD price
|
||||
-> Binance ticker price: SYMBOLUSDT, then SYMBOLUSD when supported
|
||||
-> Coinbase exchange rates: currency=SYMBOL, read rates.USD
|
||||
-> CoinGecko simple price: local symbol -> CoinGecko coin ID, read usd
|
||||
-> return ErrNoCoinPrice if all fail
|
||||
```
|
||||
|
||||
Provider order:
|
||||
|
||||
1. Binance: best first source for highly traded pairs, simple ticker endpoint, live exchange quote.
|
||||
2. Coinbase: broad public exchange-rate endpoint, no auth, direct USD quote.
|
||||
3. CoinGecko: broadest metadata-backed fallback, useful when exchanges miss a symbol.
|
||||
|
||||
Binance API:
|
||||
|
||||
```http
|
||||
GET https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT
|
||||
```
|
||||
|
||||
Expected shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"symbol": "BTCUSDT",
|
||||
"price": "67321.42"
|
||||
}
|
||||
```
|
||||
|
||||
Coinbase API:
|
||||
|
||||
```http
|
||||
GET https://api.coinbase.com/v2/exchange-rates?currency=BTC
|
||||
```
|
||||
|
||||
Expected shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"currency": "BTC",
|
||||
"rates": {
|
||||
"USD": "67321.42"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
CoinGecko API:
|
||||
|
||||
```http
|
||||
GET https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd&include_last_updated_at=true
|
||||
```
|
||||
|
||||
Expected shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"bitcoin": {
|
||||
"usd": 67321.42,
|
||||
"last_updated_at": 1711356300
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pros:
|
||||
|
||||
- resilient to provider-specific outages.
|
||||
- Binance gives exchange-like current pair quote for major coins.
|
||||
- Coinbase gives direct USD rates without API key.
|
||||
- CoinGecko gives broad ID-based coverage and optional freshness metadata.
|
||||
- no secret key required for MVP if using public/demo-free paths.
|
||||
|
||||
Cons:
|
||||
|
||||
- more code than one provider.
|
||||
- Binance is pair-based; some coins will not have `USD`/`USDT` pairs.
|
||||
- Coinbase quote availability depends on Coinbase supported currencies.
|
||||
- CoinGecko public/demo rate limits vary by traffic; avoid chatty stats calls.
|
||||
- provider prices can differ. Reply should include source used.
|
||||
|
||||
### 4. Provider Notes
|
||||
|
||||
Binance:
|
||||
|
||||
- `/api/v3/ticker/price?symbol=BTCUSDT` returns latest exchange-pair price.
|
||||
- Very simple and high-quality for listed USDT pairs.
|
||||
- Good first provider for `BTCUSDT`, `ETHUSDT`, etc.
|
||||
- Requires strict 429 backoff because repeat abuse can lead to temporary IP ban.
|
||||
|
||||
Coinbase:
|
||||
|
||||
- `/v2/exchange-rates?currency=BTC` returns rates for one base currency.
|
||||
- No authentication required.
|
||||
- Good second provider because it provides direct USD quote and simple JSON.
|
||||
- Rate limits are less explicit in the opened exchange-rate page, so still cache.
|
||||
|
||||
CoinGecko:
|
||||
|
||||
- `/simple/price` supports coin IDs/symbols/names, `vs_currencies`, market cap, volume, 24h change, and `last_updated_at`.
|
||||
- Docs warn public/demo usage is around 30 calls/minute and varies by traffic.
|
||||
- Use as third provider because it has broad coverage and stable coin IDs.
|
||||
- Maintain local symbol-to-ID mapping to avoid ambiguous symbol lookup.
|
||||
|
||||
CoinCap:
|
||||
|
||||
- Current docs redirect to pro API docs.
|
||||
- Not recommended for no-key MVP unless verified in implementation.
|
||||
|
||||
### 5. Security Considerations
|
||||
|
||||
- This is paper trading. User balances are self-declared topups, not real money.
|
||||
- Do not integrate wallets, private keys, exchange accounts, deposits, withdrawals, or API trade credentials.
|
||||
- Whitelist supported coins to avoid phishing-style fake tickers and ambiguous symbols.
|
||||
- Validate finite positive amounts and quantities. Reject NaN, Inf, zero, negative.
|
||||
- Use Telegram user ID for state, same as trading/gold.
|
||||
- Rate-limit failure must not mutate portfolio.
|
||||
- Never log full Telegram message text if it could include user-entered values beyond command diagnostics.
|
||||
|
||||
### 6. Performance And Reliability
|
||||
|
||||
- Use 10s HTTP timeout, same as existing price clients.
|
||||
- Cache prices in memory for short TTL, recommended 15-30 seconds, to reduce API calls during `/coin_stats`.
|
||||
- Fetch price before portfolio update.
|
||||
- For stats, use cache first. If cache misses, CoinGecko can batch via `ids=bitcoin,ethereum&vs_currencies=usd`; Binance can fetch multiple symbols through the `symbols` parameter, but only for listed pairs. Keep MVP simple: per-symbol provider chain with 15-30s cache and cap displayed holdings if needed.
|
||||
- Surface `429` as "price API rate limited, try later"; do not retry in tight loop.
|
||||
|
||||
## Comparative Analysis
|
||||
|
||||
| Provider | Auth | Best For | Weakness | MVP Role |
|
||||
|---|---:|---|---|---|
|
||||
| Binance ticker price | No for market data | listed exchange pairs | USDT/USD-pair only, IP ban on abuse | First |
|
||||
| Coinbase exchange rates | No | direct crypto-to-USD quote | unclear explicit rate quota on page | Second |
|
||||
| CoinGecko simple price | Demo/pro key preferred; public limits vary | broad coin IDs, batch, freshness | rate limits, key/root URL confusion | Third |
|
||||
| CoinCap | unclear/current pro docs | asset market data | free no-key path unclear | Avoid now |
|
||||
|
||||
## Implementation Recommendations
|
||||
|
||||
### Module Shape
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
internal/modules/coin/
|
||||
coin.go
|
||||
handlers.go
|
||||
portfolio.go
|
||||
prices.go
|
||||
format.go
|
||||
symbols.go
|
||||
*_test.go
|
||||
```
|
||||
|
||||
Register in composition root where current module factories live. Add `coin` to `template.yaml` `ModulesCSV` only if this module should be enabled by default.
|
||||
|
||||
### Commands
|
||||
|
||||
Use public commands:
|
||||
|
||||
```text
|
||||
/coin_price <COIN>
|
||||
/coin_topup <usd_amount>
|
||||
/coin_buy <usd_amount> <COIN>
|
||||
/coin_sell <qty> <COIN>
|
||||
/coin_stats
|
||||
```
|
||||
|
||||
Reasoning:
|
||||
|
||||
- Buy by USD amount is easier for users than fractional coin quantity.
|
||||
- Sell by quantity is explicit and avoids accidental "sell all" behavior.
|
||||
- Add `/coin_sell_usd <usd_amount> <COIN>` later only if needed.
|
||||
|
||||
### Supported Coins
|
||||
|
||||
Start with local whitelist:
|
||||
|
||||
```go
|
||||
var supportedCoins = map[string]string{
|
||||
"BTC": "BTC",
|
||||
"ETH": "ETH",
|
||||
"SOL": "SOL",
|
||||
"BNB": "BNB",
|
||||
"XRP": "XRP",
|
||||
"ADA": "ADA",
|
||||
"DOGE": "DOGE",
|
||||
"TON": "TON",
|
||||
}
|
||||
```
|
||||
|
||||
Keep symbols uppercase. Do not accept arbitrary names at first.
|
||||
|
||||
### Portfolio Model
|
||||
|
||||
Use USD cash plus fractional holdings:
|
||||
|
||||
```go
|
||||
type Portfolio struct {
|
||||
USD float64 `json:"usd"`
|
||||
Assets map[string]float64 `json:"assets"`
|
||||
Meta PortfolioMeta `json:"meta"`
|
||||
}
|
||||
|
||||
type PortfolioMeta struct {
|
||||
Invested float64 `json:"invested"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
```
|
||||
|
||||
This mirrors `gold` more than `trading`, because coins are fractional.
|
||||
|
||||
### Price Client
|
||||
|
||||
MVP client should be a small provider chain, not one hardcoded API:
|
||||
|
||||
```go
|
||||
type PriceClient struct {
|
||||
HTTP *http.Client
|
||||
Providers []PriceProvider
|
||||
CacheTTL time.Duration
|
||||
}
|
||||
|
||||
type PriceProvider interface {
|
||||
FetchUSD(ctx context.Context, symbol string) (CoinPrice, error)
|
||||
}
|
||||
|
||||
type CoinPrice struct {
|
||||
Symbol string
|
||||
USD float64
|
||||
Source string
|
||||
}
|
||||
```
|
||||
|
||||
Default provider URLs:
|
||||
|
||||
```text
|
||||
Binance: https://api.binance.com/api/v3/ticker/price
|
||||
Coinbase: https://api.coinbase.com/v2/exchange-rates
|
||||
CoinGecko: https://api.coingecko.com/api/v3/simple/price
|
||||
```
|
||||
|
||||
Env overrides:
|
||||
|
||||
```text
|
||||
COIN_BINANCE_API_URL
|
||||
COIN_COINBASE_API_URL
|
||||
COIN_COINGECKO_API_URL
|
||||
```
|
||||
|
||||
Failover rules:
|
||||
|
||||
```text
|
||||
for provider in providers:
|
||||
price, err := provider.FetchUSD(symbol)
|
||||
if err == nil && price.USD > 0:
|
||||
return price
|
||||
if err is rate-limit/network/no-price:
|
||||
continue
|
||||
return ErrNoCoinPrice
|
||||
```
|
||||
|
||||
Do not fallback after invalid user input or unsupported local symbol; fail before provider calls.
|
||||
|
||||
### Mutation Rules
|
||||
|
||||
- Topup: add USD, increment `Meta.Invested`.
|
||||
- Buy: deduct USD amount, add `usd_amount / price` units.
|
||||
- Sell: deduct units, add `qty * price` USD.
|
||||
- Stats: show USD balance, each coin holding, market value, total value, simple P/L vs `Meta.Invested`.
|
||||
|
||||
### Validation
|
||||
|
||||
Reject:
|
||||
|
||||
- unsupported coin.
|
||||
- amount <= 0.
|
||||
- qty <= 0.
|
||||
- NaN/Inf.
|
||||
- price <= 0.
|
||||
- insufficient USD or coin balance.
|
||||
|
||||
Normalize dust below `1e-9` to zero.
|
||||
|
||||
### Tests
|
||||
|
||||
Minimum test set:
|
||||
|
||||
- portfolio load new user.
|
||||
- topup increments USD and invested.
|
||||
- buy deducts USD and credits fractional coin.
|
||||
- sell deducts coin and credits USD.
|
||||
- insufficient USD.
|
||||
- insufficient coin.
|
||||
- unsupported coin.
|
||||
- Binance price decode success.
|
||||
- Coinbase price decode success.
|
||||
- CoinGecko price decode success.
|
||||
- provider chain falls back from Binance to Coinbase.
|
||||
- provider chain falls back from Coinbase to CoinGecko.
|
||||
- provider chain returns no price after all providers fail.
|
||||
- price no USD rate.
|
||||
- API 429 / non-2xx error.
|
||||
- stats with cached/fake price client.
|
||||
|
||||
## Quick Start Guide
|
||||
|
||||
1. Copy structure from `internal/modules/gold`.
|
||||
2. Replace VND/Luong with USD/assets map.
|
||||
3. Implement provider-chain price client with injected HTTP client and URL overrides.
|
||||
4. Register commands in `coin.go`.
|
||||
5. Register factory in server composition root.
|
||||
6. Add module to `MODULES` for local testing.
|
||||
7. Run `go test ./internal/modules/coin ./internal/modules/...`.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Do not store real exchange credentials. Out of scope.
|
||||
- Do not use arbitrary ticker lookup. Whitelist first.
|
||||
- Do not mutate portfolio before price fetch succeeds.
|
||||
- Do not assume all symbols have Binance pairs, Coinbase USD rates, or CoinGecko IDs.
|
||||
- Do not hide price source; include source in `/coin_price`, buy, sell, and stats replies.
|
||||
- Do not fan out unlimited price calls in stats. Add cache or cap holdings.
|
||||
- Do not use `int64` for coin holdings; crypto needs fractional units.
|
||||
|
||||
## Resources And References
|
||||
|
||||
- Coinbase Exchange Rates API: https://docs.cdp.coinbase.com/coinbase-app/track-apis/exchange-rates
|
||||
- CoinGecko Simple Price API: https://docs.coingecko.com/reference/simple-price
|
||||
- CoinGecko common errors and rate limits: https://docs.coingecko.com/docs/common-errors-rate-limit
|
||||
- Binance symbol price ticker: https://developers.binance.com/docs/binance-spot-api-docs/rest-api/market-data-endpoints
|
||||
- Binance limits: https://developers.binance.com/docs/binance-spot-api-docs/rest-api/limits
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Implement `internal/modules/coin` using `gold` as base pattern.
|
||||
2. Use Binance -> Coinbase -> CoinGecko price provider chain with per-provider URL overrides.
|
||||
3. Add short TTL cache in price client.
|
||||
4. Add unit tests for portfolio, handlers, and price decode.
|
||||
5. Update `README.md` module table after implementation.
|
||||
6. Decide whether `coin` is enabled by default in `template.yaml`.
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
- Enable `coin` by default in deployed `ModulesCSV`, or keep opt-in like `gold`?
|
||||
- Should `/coin_buy` accept USD amount only, or also support quantity mode?
|
||||
- Which initial whitelist: top 8 above enough, or include more from day one?
|
||||
- Should provider order be configurable by env, or fixed as Binance -> Coinbase -> CoinGecko?
|
||||
+19
-1
@@ -14,7 +14,7 @@ Parameters:
|
||||
|
||||
ModulesCSV:
|
||||
Type: String
|
||||
Default: util,misc,wordle,loldle,lolschedule,twentyq,trading,stats
|
||||
Default: util,misc,wordle,loldle,lolschedule,twentyq,trading,coin,stats
|
||||
Description: Comma-separated module names enabled at runtime (matches MODULES env).
|
||||
|
||||
BotOwnerID:
|
||||
@@ -47,6 +47,21 @@ Parameters:
|
||||
Default: ""
|
||||
Description: Optional USD/VND FX API URL override. Empty uses the built-in ExchangeRate-API open endpoint.
|
||||
|
||||
CoinBinanceAPIURL:
|
||||
Type: String
|
||||
Default: ""
|
||||
Description: Optional Binance ticker price API URL override. Empty uses the built-in public endpoint.
|
||||
|
||||
CoinCoinbaseAPIURL:
|
||||
Type: String
|
||||
Default: ""
|
||||
Description: Optional Coinbase exchange-rates API URL override. Empty uses the built-in public endpoint.
|
||||
|
||||
CoinCoinGeckoAPIURL:
|
||||
Type: String
|
||||
Default: ""
|
||||
Description: Optional CoinGecko simple price API URL override. Empty uses the built-in public endpoint.
|
||||
|
||||
# AWS Lambda Web Adapter ARM64 layer ARN. Pin a specific version so deploys
|
||||
# are reproducible. Bump by checking the latest at:
|
||||
# https://github.com/awslabs/aws-lambda-web-adapter/releases
|
||||
@@ -154,6 +169,9 @@ Resources:
|
||||
TRADING_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME: !Ref TradingIncomeEventsAPITokenParameterName
|
||||
GOLD_PRICE_API_URL: !Ref GoldPriceAPIURL
|
||||
GOLD_FX_API_URL: !Ref GoldFXAPIURL
|
||||
COIN_BINANCE_API_URL: !Ref CoinBinanceAPIURL
|
||||
COIN_COINBASE_API_URL: !Ref CoinCoinbaseAPIURL
|
||||
COIN_COINGECKO_API_URL: !Ref CoinCoinGeckoAPIURL
|
||||
# ---- Secrets (fetched from Parameter Store at Lambda cold start) ----
|
||||
TELEGRAM_BOT_TOKEN_PARAMETER_NAME: !Sub "/miti99bot/${StackEnv}/telegram-bot-token"
|
||||
TELEGRAM_WEBHOOK_SECRET_PARAMETER_NAME: !Sub "/miti99bot/${StackEnv}/telegram-webhook-secret"
|
||||
|
||||
Reference in New Issue
Block a user