mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-16 00:20:28 +00:00
feat(gold): add gold paper trading module
Opt-in module for gold paper trading with VND currency. Commands: /gold_topup, /gold_buy, /gold_sell, /gold_stats. Pricing: spot XAU USD converted to VND per luong via GoldPrice.org + ExchangeRate-API. Features: FX caching, dust normalization (1e-9), HTTPS-only URL validation, per-user key locking, namespace isolation from trading module.
This commit is contained in:
@@ -13,6 +13,7 @@ Plug-n-play Telegram bot framework in Go. Runs on AWS Lambda + DynamoDB + EventB
|
||||
| `lolschedule` | Pro-match schedule + daily push |
|
||||
| `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) |
|
||||
| `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/gold"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/loldle"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/lolschedule"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/misc"
|
||||
@@ -48,6 +49,7 @@ func factories() map[string]modules.Factory {
|
||||
"wordle": wordle.New,
|
||||
"loldle": loldle.New,
|
||||
"lolschedule": lolschedule.New,
|
||||
"gold": gold.New,
|
||||
"twentyq": twentyq.New,
|
||||
"trading": trading.New,
|
||||
"stats": stats.New,
|
||||
@@ -84,6 +86,8 @@ func main() {
|
||||
}
|
||||
exportOptionalEnv("TRADING_INCOME_EVENTS_API_URL", cfg.TradingIncomeEventsAPIURL)
|
||||
exportOptionalEnv("TRADING_INCOME_EVENTS_API_TOKEN", cfg.TradingIncomeEventsAPIToken)
|
||||
exportOptionalEnv("GOLD_PRICE_API_URL", cfg.GoldPriceAPIURL)
|
||||
exportOptionalEnv("GOLD_FX_API_URL", cfg.GoldFXAPIURL)
|
||||
|
||||
// Periodic metrics flush. Cancels with rootCtx and emits one final
|
||||
// flush on shutdown so the trailing window isn't lost.
|
||||
@@ -258,6 +262,8 @@ type config struct {
|
||||
GeminiAPIKey string
|
||||
TradingIncomeEventsAPIURL string
|
||||
TradingIncomeEventsAPIToken string
|
||||
GoldPriceAPIURL string
|
||||
GoldFXAPIURL string
|
||||
Modules []string
|
||||
BotOwnerID int64
|
||||
AdminUserIDs map[int64]bool
|
||||
@@ -297,6 +303,8 @@ func loadConfig() config {
|
||||
GeminiAPIKey: envMap["GEMINI_API_KEY"],
|
||||
TradingIncomeEventsAPIURL: envMap["TRADING_INCOME_EVENTS_API_URL"],
|
||||
TradingIncomeEventsAPIToken: envMap["TRADING_INCOME_EVENTS_API_TOKEN"],
|
||||
GoldPriceAPIURL: envMap["GOLD_PRICE_API_URL"],
|
||||
GoldFXAPIURL: envMap["GOLD_FX_API_URL"],
|
||||
Modules: splitCSV(envMap["MODULES"]),
|
||||
BotOwnerID: parseInt64(envMap["BOT_OWNER_ID"]),
|
||||
AdminUserIDs: parseInt64Set(envMap["ADMIN_USER_IDS"]),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/modules"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
func TestFactoriesIncludesGold(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 err != nil {
|
||||
t.Fatalf("Build gold: %v", err)
|
||||
}
|
||||
for _, name := range []string{"gold_topup", "gold_buy", "gold_sell", "gold_stats"} {
|
||||
if _, ok := reg.AllCommands[name]; !ok {
|
||||
t.Fatalf("missing command %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,24 @@ Expect: `url` matches Function URL, `pending_update_count` ≈ 0, `last_error_da
|
||||
|
||||
FireAnt response is an array of timescale marks with `id`, `label`, `date`, `title`, and `color`. The bot keeps marks whose label/title indicate dividends, ex-right dates, final registration dates, rights issues, or bonus/share dividends.
|
||||
|
||||
## Gold module
|
||||
|
||||
`gold` is opt-in for first deploy. Enable it by adding `gold` to the `ModulesCSV` parameter / `MODULES` env, for example `util,misc,wordle,loldle,lolschedule,twentyq,trading,stats,gold`.
|
||||
|
||||
Commands:
|
||||
|
||||
- `/gold_topup <amount>` credits VND. No currency argument is accepted.
|
||||
- `/gold_buy <luong>` buys gold in Vietnamese `luong`. No symbol or unit argument is accepted.
|
||||
- `/gold_sell <luong>` sells gold in Vietnamese `luong`.
|
||||
- `/gold_stats` shows VND balance, gold holding, current spot price, total value, invested amount, and P&L.
|
||||
|
||||
Price source: v1 uses world spot XAU from GoldPrice.org converted through USD/VND from ExchangeRate-API open endpoint. It is not SJC local retail buy/sell pricing. The defaults require no secrets. Optional overrides:
|
||||
|
||||
- `GOLD_PRICE_API_URL`: gold spot JSON endpoint override. Remote URLs must be HTTPS; localhost HTTP is allowed for local tests.
|
||||
- `GOLD_FX_API_URL`: USD/VND FX JSON endpoint override. Remote URLs must be HTTPS; localhost HTTP is allowed for local tests.
|
||||
|
||||
ExchangeRate-API open endpoint requires attribution if surfaced publicly and updates once per day; the bot caches FX responses until the provider `time_next_update_unix` when available.
|
||||
|
||||
## Rotate secrets
|
||||
|
||||
```sh
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package gold
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func FormatVND(n float64) string {
|
||||
if math.IsNaN(n) || math.IsInf(n, 0) || n > float64(math.MaxInt64) || n < float64(math.MinInt64) {
|
||||
return "invalid VND"
|
||||
}
|
||||
rounded := int64(math.Round(n))
|
||||
abs := strconv.FormatInt(absInt64(rounded), 10)
|
||||
var sb strings.Builder
|
||||
if rounded < 0 {
|
||||
sb.WriteByte('-')
|
||||
}
|
||||
for i := 0; i < len(abs); i++ {
|
||||
if i > 0 && (len(abs)-i)%3 == 0 {
|
||||
sb.WriteByte('.')
|
||||
}
|
||||
sb.WriteByte(abs[i])
|
||||
}
|
||||
sb.WriteString(" VND")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func FormatLuong(n float64) string {
|
||||
s := strconv.FormatFloat(n, 'f', 4, 64)
|
||||
s = strings.TrimRight(s, "0")
|
||||
s = strings.TrimRight(s, ".")
|
||||
if s == "" || s == "-0" {
|
||||
return "0"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func FormatPnL(currentValue, invested float64) string {
|
||||
diff := currentValue - invested
|
||||
pct := 0.0
|
||||
if invested > 0 {
|
||||
pct = (diff / invested) * 100
|
||||
}
|
||||
sign := ""
|
||||
if diff >= 0 {
|
||||
sign = "+"
|
||||
}
|
||||
return sign + FormatVND(diff) + " (" + sign + strconv.FormatFloat(pct, 'f', 2, 64) + "%)"
|
||||
}
|
||||
|
||||
func absInt64(n int64) int64 {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package gold
|
||||
|
||||
import "github.com/tiennm99/miti99bot/internal/modules"
|
||||
|
||||
// New is the gold paper-trading module factory. It is opt-in through MODULES
|
||||
// and keeps its portfolio state separate from the VN-stocks trading module.
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
s := newState(deps.KV)
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "gold_topup",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Top up VND to your gold account",
|
||||
Handler: s.handleTopup,
|
||||
},
|
||||
{
|
||||
Name: "gold_buy",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Buy gold at spot price (luong)",
|
||||
Handler: s.handleBuy,
|
||||
},
|
||||
{
|
||||
Name: "gold_sell",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Sell gold back to VND (luong)",
|
||||
Handler: s.handleSell,
|
||||
},
|
||||
{
|
||||
Name: "gold_stats",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show gold account summary with P&L",
|
||||
Handler: s.handleStats,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package gold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"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) 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 - gold 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: /gold_topup <amount>\nExample: /gold_topup 5000000")
|
||||
}
|
||||
amount, ok := parsePositiveFinite(args[0])
|
||||
if !ok || !isSafeVND(amount) {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Amount must be a positive finite number within the supported range.")
|
||||
}
|
||||
|
||||
defer s.locks.Acquire(strconv.FormatInt(userID, 10))()
|
||||
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
|
||||
if err != nil {
|
||||
log.Error("gold_load_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not load gold portfolio. Try again later.")
|
||||
}
|
||||
p.AddVND(amount)
|
||||
p.Meta.Invested += amount
|
||||
if err := SavePortfolio(ctx, s.kv, userID, p); err != nil {
|
||||
log.Error("gold_save_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not save gold portfolio. Try again later.")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Topped up "+FormatVND(amount)+".\nBalance: "+FormatVND(p.VND))
|
||||
}
|
||||
|
||||
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 - gold 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: /gold_buy <luong>\nExample: /gold_buy 1")
|
||||
}
|
||||
qty, ok := parsePositiveFinite(args[0])
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Luong must be a positive finite number.")
|
||||
}
|
||||
price, err := s.prices.FetchLuongPrice(ctx)
|
||||
if err != nil {
|
||||
return s.replyPriceError(ctx, b, update, err)
|
||||
}
|
||||
cost := qty * price
|
||||
if !isSafeVND(cost) {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Trade value is too large.")
|
||||
}
|
||||
|
||||
defer s.locks.Acquire(strconv.FormatInt(userID, 10))()
|
||||
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
|
||||
if err != nil {
|
||||
log.Error("gold_load_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not load gold portfolio. Try again later.")
|
||||
}
|
||||
ok, balance := p.DeductVND(cost)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Insufficient VND. Need "+FormatVND(cost)+", have "+FormatVND(balance)+".")
|
||||
}
|
||||
p.AddLuong(qty)
|
||||
if err := SavePortfolio(ctx, s.kv, userID, p); err != nil {
|
||||
log.Error("gold_save_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not save gold portfolio. Try again later.")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Bought "+FormatLuong(qty)+" luong gold @ "+FormatVND(price)+"/luong\nCost: "+FormatVND(cost)+
|
||||
"\nRemaining: "+FormatVND(p.VND))
|
||||
}
|
||||
|
||||
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 - gold 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: /gold_sell <luong>\nExample: /gold_sell 0.5")
|
||||
}
|
||||
qty, ok := parsePositiveFinite(args[0])
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Luong must be a positive finite number.")
|
||||
}
|
||||
price, err := s.prices.FetchLuongPrice(ctx)
|
||||
if err != nil {
|
||||
return s.replyPriceError(ctx, b, update, err)
|
||||
}
|
||||
|
||||
defer s.locks.Acquire(strconv.FormatInt(userID, 10))()
|
||||
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
|
||||
if err != nil {
|
||||
log.Error("gold_load_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not load gold portfolio. Try again later.")
|
||||
}
|
||||
ok, held := p.DeductLuong(qty)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Insufficient gold. You have: "+FormatLuong(held)+" luong")
|
||||
}
|
||||
revenue := qty * price
|
||||
if !isSafeVND(revenue) {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Trade value is too large.")
|
||||
}
|
||||
p.AddVND(revenue)
|
||||
if err := SavePortfolio(ctx, s.kv, userID, p); err != nil {
|
||||
log.Error("gold_save_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not save gold portfolio. Try again later.")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Sold "+FormatLuong(qty)+" luong gold @ "+FormatVND(price)+"/luong\nRevenue: "+FormatVND(revenue)+
|
||||
"\nRemaining: "+FormatVND(p.VND))
|
||||
}
|
||||
|
||||
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 - /gold_stats needs a sender.")
|
||||
}
|
||||
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
|
||||
if err != nil {
|
||||
log.Error("gold_load_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not load gold portfolio. Try again later.")
|
||||
}
|
||||
|
||||
lines := []string{"Gold Account Summary\n", "VND: " + FormatVND(p.VND), "Gold: " + FormatLuong(p.Luong) + " luong"}
|
||||
totalValue := p.VND
|
||||
if price, err := s.prices.FetchLuongPrice(ctx); err == nil {
|
||||
goldValue := p.Luong * price
|
||||
totalValue += goldValue
|
||||
lines = append(lines, "Price: "+FormatVND(price)+"/luong")
|
||||
lines = append(lines, "Gold value: "+FormatVND(goldValue))
|
||||
lines = append(lines, "Total value: "+FormatVND(totalValue))
|
||||
lines = append(lines, "Invested: "+FormatVND(p.Meta.Invested))
|
||||
lines = append(lines, "P&L: "+FormatPnL(totalValue, p.Meta.Invested))
|
||||
} else {
|
||||
lines = append(lines, "Price: no price")
|
||||
lines = append(lines, "Total value: "+FormatVND(totalValue)+" + gold holdings")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, update.Message, strings.Join(lines, "\n"))
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package gold
|
||||
|
||||
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 {
|
||||
price float64
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakePriceFetcher) FetchLuongPrice(context.Context) (float64, error) {
|
||||
return f.price, f.err
|
||||
}
|
||||
|
||||
func newTestState(price float64, err error) *state {
|
||||
return &state{
|
||||
kv: storage.NewMemoryKVStore(),
|
||||
prices: fakePriceFetcher{price: price, 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 TestModuleRegistersExpectedCommands(t *testing.T) {
|
||||
mod := New(modDepsForTest())
|
||||
got := map[string]bool{}
|
||||
for _, cmd := range mod.Commands {
|
||||
got[cmd.Name] = true
|
||||
}
|
||||
for _, name := range []string{"gold_topup", "gold_buy", "gold_sell", "gold_stats"} {
|
||||
if !got[name] {
|
||||
t.Fatalf("missing command %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTopup(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(1000, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handleTopup(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_topup 5000000")); err != nil {
|
||||
t.Fatalf("handleTopup: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Topped up 5.000.000 VND")
|
||||
p, err := LoadPortfolio(ctx, s.kv, 7, 999)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPortfolio: %v", err)
|
||||
}
|
||||
if p.VND != 5_000_000 || p.Meta.Invested != 5_000_000 {
|
||||
t.Fatalf("portfolio: got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBuyAndSell(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(2_000_000, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handleTopup(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_topup 5000000")); err != nil {
|
||||
t.Fatalf("topup: %v", err)
|
||||
}
|
||||
rb.Reset()
|
||||
if err := s.handleBuy(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_buy 1.25")); err != nil {
|
||||
t.Fatalf("buy: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Bought 1.25 luong gold")
|
||||
p, _ := LoadPortfolio(ctx, s.kv, 7, 999)
|
||||
if p.Luong != 1.25 || p.VND != 2_500_000 {
|
||||
t.Fatalf("after buy: %+v", p)
|
||||
}
|
||||
rb.Reset()
|
||||
if err := s.handleSell(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_sell 1.25")); err != nil {
|
||||
t.Fatalf("sell: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Sold 1.25 luong gold")
|
||||
p, _ = LoadPortfolio(ctx, s.kv, 7, 999)
|
||||
if p.Luong != 0 || p.VND != 5_000_000 {
|
||||
t.Fatalf("after sell: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBuyInsufficientVND(t *testing.T) {
|
||||
s := newTestState(2_000_000, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handleBuy(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/gold_buy 1")); err != nil {
|
||||
t.Fatalf("buy: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Insufficient VND")
|
||||
}
|
||||
|
||||
func TestHandleSellInsufficientGold(t *testing.T) {
|
||||
s := newTestState(2_000_000, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handleSell(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/gold_sell 1")); err != nil {
|
||||
t.Fatalf("sell: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Insufficient gold")
|
||||
}
|
||||
|
||||
func TestPriceErrorDoesNotMutatePortfolio(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(0, errors.New("upstream down"))
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handleBuy(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_buy 1")); err != nil {
|
||||
t.Fatalf("buy: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Could not fetch gold price")
|
||||
p, err := LoadPortfolio(ctx, s.kv, 7, 999)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPortfolio: %v", err)
|
||||
}
|
||||
if p.VND != 0 || p.Luong != 0 {
|
||||
t.Fatalf("unexpected mutation: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatsWithAndWithoutPrice(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(2_000_000, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
_ = s.handleTopup(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_topup 5000000"))
|
||||
_ = s.handleBuy(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_buy 1"))
|
||||
rb.Reset()
|
||||
if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_stats")); err != nil {
|
||||
t.Fatalf("stats: %v", err)
|
||||
}
|
||||
text := rb.LastSent().Text()
|
||||
for _, want := range []string{"Gold Account Summary", "Gold: 1 luong", "Price: 2.000.000 VND/luong", "P&L:"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("stats missing %q in %q", want, text)
|
||||
}
|
||||
}
|
||||
s.prices = fakePriceFetcher{err: ErrNoGoldPrice}
|
||||
rb.Reset()
|
||||
if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_stats")); err != nil {
|
||||
t.Fatalf("stats no price: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Price: no price")
|
||||
}
|
||||
|
||||
func modDepsForTest() modules.Deps {
|
||||
return modules.Deps{KV: storage.NewMemoryKVStore()}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package gold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/testutil"
|
||||
)
|
||||
|
||||
func TestHandlersRejectExtraArgs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(2_000_000, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want string
|
||||
}{
|
||||
{name: "topup currency", text: "/gold_topup 100 USD", want: "Usage: /gold_topup <amount>"},
|
||||
{name: "buy unit", text: "/gold_buy 1 oz", want: "Usage: /gold_buy <luong>"},
|
||||
{name: "sell symbol", text: "/gold_sell 1 SJC", want: "Usage: /gold_sell <luong>"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rb.Reset()
|
||||
update := testutil.NewPrivateMessage(7, tc.text)
|
||||
var err error
|
||||
switch {
|
||||
case strings.HasPrefix(tc.text, "/gold_topup"):
|
||||
err = s.handleTopup(ctx, rb.Bot, update)
|
||||
case strings.HasPrefix(tc.text, "/gold_buy"):
|
||||
err = s.handleBuy(ctx, rb.Bot, update)
|
||||
case strings.HasPrefix(tc.text, "/gold_sell"):
|
||||
err = s.handleSell(ctx, rb.Bot, update)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlersRejectTooLargeValues(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(1e308, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handleTopup(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_topup 1e308")); err != nil {
|
||||
t.Fatalf("topup: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "supported range")
|
||||
rb.Reset()
|
||||
if err := s.handleBuy(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_buy 2")); err != nil {
|
||||
t.Fatalf("buy: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Trade value is too large")
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package gold
|
||||
|
||||
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 {
|
||||
FetchLuongPrice(ctx context.Context) (float64, 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: NewGoldPriceClientFromEnv()}
|
||||
}
|
||||
|
||||
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 isSafeVND(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, ErrNoGoldPrice) {
|
||||
return chathelper.Reply(ctx, b, update.Message, "No gold price available.")
|
||||
}
|
||||
log.Error("gold_fetch_price", "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not fetch gold price. Try again later.")
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package gold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
const goldDustEpsilon = 1e-9
|
||||
|
||||
type Portfolio struct {
|
||||
VND float64 `json:"vnd"`
|
||||
Luong float64 `json:"luong"`
|
||||
Meta PortfolioMeta `json:"meta"`
|
||||
}
|
||||
|
||||
type PortfolioMeta struct {
|
||||
Invested float64 `json:"invested"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
func NewPortfolio(now int64) Portfolio {
|
||||
return Portfolio{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.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("gold: 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("gold: save portfolio %d: %w", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Portfolio) AddVND(amount float64) {
|
||||
p.VND += amount
|
||||
p.normalize()
|
||||
}
|
||||
|
||||
func (p *Portfolio) DeductVND(amount float64) (ok bool, balance float64) {
|
||||
p.normalize()
|
||||
balance = p.VND
|
||||
if balance+goldDustEpsilon < amount {
|
||||
return false, balance
|
||||
}
|
||||
p.VND = balance - amount
|
||||
p.normalize()
|
||||
return true, p.VND
|
||||
}
|
||||
|
||||
func (p *Portfolio) AddLuong(amount float64) {
|
||||
p.Luong += amount
|
||||
p.normalize()
|
||||
}
|
||||
|
||||
func (p *Portfolio) DeductLuong(amount float64) (ok bool, held float64) {
|
||||
p.normalize()
|
||||
held = p.Luong
|
||||
if held+goldDustEpsilon < amount {
|
||||
return false, held
|
||||
}
|
||||
p.Luong = held - amount
|
||||
p.normalize()
|
||||
return true, p.Luong
|
||||
}
|
||||
|
||||
func (p *Portfolio) normalize() {
|
||||
p.VND = normalizeAmount(p.VND)
|
||||
p.Luong = normalizeAmount(p.Luong)
|
||||
p.Meta.Invested = normalizeAmount(p.Meta.Invested)
|
||||
}
|
||||
|
||||
func normalizeAmount(n float64) float64 {
|
||||
if math.IsNaN(n) || math.IsInf(n, 0) {
|
||||
return 0
|
||||
}
|
||||
if math.Abs(n) < goldDustEpsilon {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package gold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
stocktrading "github.com/tiennm99/miti99bot/internal/modules/trading"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
func TestLoadPortfolio_FirstTimeUser(t *testing.T) {
|
||||
kv := storage.NewMemoryKVStore()
|
||||
p, err := LoadPortfolio(context.Background(), kv, 42, 123)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPortfolio: %v", err)
|
||||
}
|
||||
if p.VND != 0 || p.Luong != 0 || p.Meta.CreatedAt != 123 {
|
||||
t.Fatalf("portfolio: got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoadRoundTrip(t *testing.T) {
|
||||
kv := storage.NewMemoryKVStore()
|
||||
p := NewPortfolio(1)
|
||||
p.AddVND(5_000_000)
|
||||
p.AddLuong(1.25)
|
||||
p.Meta.Invested = 5_000_000
|
||||
if err := SavePortfolio(context.Background(), kv, 42, p); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
got, err := LoadPortfolio(context.Background(), kv, 42, 999)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got.VND != 5_000_000 || got.Luong != 1.25 || got.Meta.CreatedAt != 1 {
|
||||
t.Fatalf("round trip: got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeductLuongDustCleanup(t *testing.T) {
|
||||
p := NewPortfolio(0)
|
||||
p.AddLuong(0.1)
|
||||
p.AddLuong(0.2)
|
||||
ok, held := p.DeductLuong(0.3)
|
||||
if !ok {
|
||||
t.Fatalf("deduct: ok=false held=%v", held)
|
||||
}
|
||||
if p.Luong != 0 {
|
||||
t.Fatalf("dust not cleaned: got %.20f", p.Luong)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeductVNDInsufficient(t *testing.T) {
|
||||
p := NewPortfolio(0)
|
||||
p.AddVND(1000)
|
||||
ok, bal := p.DeductVND(1500)
|
||||
if ok || bal != 1000 || p.VND != 1000 {
|
||||
t.Fatalf("deduct: ok=%v bal=%v p=%+v", ok, bal, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAmountSpecialValues(t *testing.T) {
|
||||
for _, n := range []float64{math.NaN(), math.Inf(1), math.Inf(-1), goldDustEpsilon / 2} {
|
||||
if got := normalizeAmount(n); got != 0 {
|
||||
t.Fatalf("normalizeAmount(%v) = %v, want 0", n, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTradingAndGoldPortfolioKeysDoNotCollide(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
provider := storage.NewMemoryProvider()
|
||||
goldPortfolio := NewPortfolio(1)
|
||||
goldPortfolio.AddLuong(2)
|
||||
if err := SavePortfolio(ctx, provider.For("gold"), 7, goldPortfolio); err != nil {
|
||||
t.Fatalf("save gold: %v", err)
|
||||
}
|
||||
tradingPortfolio := stocktrading.NewPortfolio(1)
|
||||
tradingPortfolio.AddAsset("TCB", 100)
|
||||
if err := stocktrading.SavePortfolio(ctx, provider.For("trading"), 7, tradingPortfolio); err != nil {
|
||||
t.Fatalf("save trading: %v", err)
|
||||
}
|
||||
keys, err := provider.Base().List(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
want := map[string]bool{"gold:user:7": false, "trading:user:7": false}
|
||||
for _, key := range keys {
|
||||
if _, ok := want[key]; ok {
|
||||
want[key] = true
|
||||
}
|
||||
}
|
||||
for key, seen := range want {
|
||||
if !seen {
|
||||
t.Fatalf("missing raw key %q in %v", key, keys)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package gold
|
||||
|
||||
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("gold: invalid API URL %q", raw)
|
||||
}
|
||||
if u.Scheme == "https" {
|
||||
return nil
|
||||
}
|
||||
if u.Scheme == "http" && isLocalHost(u.Hostname()) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("gold: 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,194 @@
|
||||
package gold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
goldDefaultURL = "https://data-asg.goldprice.org/dbXRates/USD"
|
||||
fxDefaultURL = "https://open.er-api.com/v6/latest/USD"
|
||||
goldHTTPTimeout = 10 * time.Second
|
||||
fxFallbackCacheTTL = time.Hour
|
||||
gramsPerLuong = 37.5
|
||||
gramsPerTroyOunce = 31.1034768
|
||||
)
|
||||
|
||||
var ErrNoGoldPrice = errors.New("gold: no price available")
|
||||
|
||||
type GoldPriceClient struct {
|
||||
HTTP *http.Client
|
||||
GoldURL string
|
||||
FXURL string
|
||||
|
||||
defaultOnce sync.Once
|
||||
defaultClient *http.Client
|
||||
nowFn func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
fxRate float64
|
||||
fxExpiry time.Time
|
||||
}
|
||||
|
||||
func NewGoldPriceClientFromEnv() *GoldPriceClient {
|
||||
return &GoldPriceClient{
|
||||
GoldURL: strings.TrimSpace(os.Getenv("GOLD_PRICE_API_URL")),
|
||||
FXURL: strings.TrimSpace(os.Getenv("GOLD_FX_API_URL")),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) FetchLuongPrice(ctx context.Context) (float64, error) {
|
||||
xauUSD, err := c.fetchXAUUSD(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
usdToVND, err := c.fetchUSDVND(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
price := xauUSD * usdToVND * (gramsPerLuong / gramsPerTroyOunce)
|
||||
if price <= 0 || math.IsNaN(price) || math.IsInf(price, 0) {
|
||||
return 0, ErrNoGoldPrice
|
||||
}
|
||||
return price, nil
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) httpClient() *http.Client {
|
||||
if c.HTTP != nil {
|
||||
return c.HTTP
|
||||
}
|
||||
c.defaultOnce.Do(func() {
|
||||
c.defaultClient = &http.Client{Timeout: goldHTTPTimeout}
|
||||
})
|
||||
return c.defaultClient
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) now() time.Time {
|
||||
if c.nowFn != nil {
|
||||
return c.nowFn()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) goldURL() string {
|
||||
if strings.TrimSpace(c.GoldURL) != "" {
|
||||
return strings.TrimSpace(c.GoldURL)
|
||||
}
|
||||
return goldDefaultURL
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) fxURL() string {
|
||||
if strings.TrimSpace(c.FXURL) != "" {
|
||||
return strings.TrimSpace(c.FXURL)
|
||||
}
|
||||
return fxDefaultURL
|
||||
}
|
||||
|
||||
type goldResponse struct {
|
||||
Items []goldItem `json:"items"`
|
||||
}
|
||||
|
||||
type goldItem struct {
|
||||
Currency string `json:"curr"`
|
||||
XAUPrice float64 `json:"xauPrice"`
|
||||
}
|
||||
|
||||
type fxResponse struct {
|
||||
Result string `json:"result"`
|
||||
Rates map[string]float64 `json:"rates"`
|
||||
TimeNextUpdateUnix int64 `json:"time_next_update_unix"`
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) fetchXAUUSD(ctx context.Context) (float64, error) {
|
||||
endpoint := c.goldURL()
|
||||
if err := validateEndpoint(endpoint); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
resp, err := c.getJSON(ctx, endpoint)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("gold: GoldPrice request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return 0, ErrNoGoldPrice
|
||||
}
|
||||
var body goldResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return 0, fmt.Errorf("gold: GoldPrice decode: %w", err)
|
||||
}
|
||||
if len(body.Items) == 0 {
|
||||
return 0, ErrNoGoldPrice
|
||||
}
|
||||
item := body.Items[0]
|
||||
if item.Currency != "" && item.Currency != "USD" {
|
||||
return 0, ErrNoGoldPrice
|
||||
}
|
||||
if item.XAUPrice <= 0 {
|
||||
return 0, ErrNoGoldPrice
|
||||
}
|
||||
return item.XAUPrice, nil
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) fetchUSDVND(ctx context.Context) (float64, error) {
|
||||
c.mu.Lock()
|
||||
now := c.now()
|
||||
if c.fxRate > 0 && now.Before(c.fxExpiry) {
|
||||
rate := c.fxRate
|
||||
c.mu.Unlock()
|
||||
return rate, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
endpoint := c.fxURL()
|
||||
if err := validateEndpoint(endpoint); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
resp, err := c.getJSON(ctx, endpoint)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("gold: FX request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return 0, errors.New("gold: FX rate limited")
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return 0, ErrNoGoldPrice
|
||||
}
|
||||
var body fxResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return 0, fmt.Errorf("gold: FX decode: %w", err)
|
||||
}
|
||||
if body.Result != "" && body.Result != "success" {
|
||||
return 0, ErrNoGoldPrice
|
||||
}
|
||||
rate := body.Rates["VND"]
|
||||
if rate <= 0 {
|
||||
return 0, ErrNoGoldPrice
|
||||
}
|
||||
expiry := now.Add(fxFallbackCacheTTL)
|
||||
if body.TimeNextUpdateUnix > now.Unix() {
|
||||
expiry = time.Unix(body.TimeNextUpdateUnix, 0)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.fxRate = rate
|
||||
c.fxExpiry = expiry
|
||||
c.mu.Unlock()
|
||||
return rate, nil
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) getJSON(ctx context.Context, 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)")
|
||||
return c.httpClient().Do(req)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package gold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGoldPriceClient_FetchLuongPrice(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
var fxHits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/gold":
|
||||
_, _ = w.Write([]byte(`{"items":[{"curr":"USD","xauPrice":2000}]}`))
|
||||
case "/fx":
|
||||
atomic.AddInt32(&fxHits, 1)
|
||||
_, _ = w.Write([]byte(`{"result":"success","rates":{"VND":25000},"time_next_update_unix":1000}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := &GoldPriceClient{GoldURL: srv.URL + "/gold", FXURL: srv.URL + "/fx", nowFn: func() time.Time { return now }}
|
||||
|
||||
got, err := c.FetchLuongPrice(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchLuongPrice: %v", err)
|
||||
}
|
||||
want := 2000 * 25000 * (gramsPerLuong / gramsPerTroyOunce)
|
||||
if math.Abs(got-want) > 0.01 {
|
||||
t.Errorf("price: got %v, want %v", got, want)
|
||||
}
|
||||
if _, err := c.FetchLuongPrice(context.Background()); err != nil {
|
||||
t.Fatalf("FetchLuongPrice cached: %v", err)
|
||||
}
|
||||
if atomic.LoadInt32(&fxHits) != 1 {
|
||||
t.Errorf("FX hits: got %d, want 1", fxHits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoldPriceClient_InvalidResponses(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
gold string
|
||||
fx string
|
||||
}{
|
||||
{name: "missing gold", gold: `{"items":[]}`, fx: `{"result":"success","rates":{"VND":25000}}`},
|
||||
{name: "wrong currency", gold: `{"items":[{"curr":"EUR","xauPrice":2000}]}`, fx: `{"result":"success","rates":{"VND":25000}}`},
|
||||
{name: "missing fx", gold: `{"items":[{"curr":"USD","xauPrice":2000}]}`, fx: `{"result":"success","rates":{}}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/gold" {
|
||||
_, _ = w.Write([]byte(tc.gold))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(tc.fx))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := &GoldPriceClient{GoldURL: srv.URL + "/gold", FXURL: srv.URL + "/fx"}
|
||||
_, err := c.FetchLuongPrice(context.Background())
|
||||
if !errors.Is(err, ErrNoGoldPrice) {
|
||||
t.Errorf("got %v, want ErrNoGoldPrice", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoldPriceClient_OverflowPriceReturnsNoPrice(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/gold" {
|
||||
_, _ = w.Write([]byte(`{"items":[{"curr":"USD","xauPrice":1e308}]}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"result":"success","rates":{"VND":1e308}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := &GoldPriceClient{GoldURL: srv.URL + "/gold", FXURL: srv.URL + "/fx"}
|
||||
_, err := c.FetchLuongPrice(context.Background())
|
||||
if !errors.Is(err, ErrNoGoldPrice) {
|
||||
t.Fatalf("got %v, want ErrNoGoldPrice", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoldPriceClient_FXRateLimitedIsRetryable(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/gold" {
|
||||
_, _ = w.Write([]byte(`{"items":[{"curr":"USD","xauPrice":2000}]}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := &GoldPriceClient{GoldURL: srv.URL + "/gold", FXURL: srv.URL + "/fx"}
|
||||
_, err := c.FetchLuongPrice(context.Background())
|
||||
if err == nil || errors.Is(err, ErrNoGoldPrice) {
|
||||
t.Fatalf("got %v, want retryable non-ErrNoGoldPrice", 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://127.0.0.1:1234/path"); err != nil {
|
||||
t.Fatalf("loopback http should pass: %v", err)
|
||||
}
|
||||
if err := validateEndpoint("http://example.com/path"); err == nil {
|
||||
t.Fatal("remote http should fail")
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
---
|
||||
phase: 1
|
||||
title: Research and existing trade pattern
|
||||
status: completed
|
||||
priority: P2
|
||||
effort: 1h
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 1: Research and existing trade pattern
|
||||
|
||||
## Overview
|
||||
|
||||
Lock the exact behavior to mirror from the existing trading module and verify the external price path with a small manual smoke test before writing code.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: identify which trading behaviors apply directly to gold: topup, buy, sell, stats, per-user lock, per-user KV state, reply style.
|
||||
- Functional: verify default command syntax: `/gold_topup <amount>`, `/gold_buy <luong>`, `/gold_sell <luong>`, `/gold_stats`.
|
||||
- Non-functional: avoid coupling gold state to trading state; no shared mutable state between modules.
|
||||
- Non-functional: keep new code files under 200 lines where practical by splitting price, portfolio, handlers, format, and factory files.
|
||||
|
||||
## Architecture
|
||||
|
||||
Gold should copy the trading module's workflow, not import trading handlers. The shared pattern is conceptual:
|
||||
|
||||
1. Parse command args.
|
||||
2. Fetch current price if needed.
|
||||
3. Acquire `keylock.Map` by Telegram user ID.
|
||||
4. Load module-local portfolio from KV.
|
||||
5. Mutate portfolio.
|
||||
6. Save portfolio.
|
||||
7. Reply via `chathelper`.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Read: `internal/modules/trading/trading.go`
|
||||
- Read: `internal/modules/trading/handlers.go`
|
||||
- Read: `internal/modules/trading/portfolio.go`
|
||||
- Read: `internal/modules/trading/prices.go`
|
||||
- Read: `internal/modules/trading/format.go`
|
||||
- Read: `cmd/server/main.go`
|
||||
- Read: `template.yaml`
|
||||
- Modify later: none in this phase
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Re-read trading command tests to copy expected style for parser and recording bot assertions.
|
||||
2. Smoke-test GoldPrice.org JSON shape with `curl https://data-asg.goldprice.org/dbXRates/USD`.
|
||||
3. Smoke-test USD/VND conversion source with `curl https://open.er-api.com/v6/latest/USD` and confirm `rates.VND` exists.
|
||||
4. Decide provider fallback behavior:
|
||||
- If GoldPrice.org fails: return user-facing "Could not fetch gold price. Try again later."
|
||||
- If FX conversion fails: same error; do not trade on stale unknown conversion.
|
||||
5. Record actual response fields used by code: `items[0].xauPrice`, `items[0].curr`, `ts`.
|
||||
6. Confirm all command names pass existing command validation regex.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] Existing trading workflow documented enough to implement without changing trading files.
|
||||
- [x] Price source response fields verified against live endpoint or a captured fixture.
|
||||
- [x] Decision recorded that v1 is spot gold converted to VND, not SJC retail price.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
GoldPrice.org endpoint is not formal API docs. Mitigation: isolate behind `GoldPriceClient`, keep tests fixture-based, and make endpoint overrideable by env/test injection so provider can be swapped without rewriting handlers.
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
phase: 2
|
||||
title: Gold price client
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: 2h
|
||||
dependencies:
|
||||
- 1
|
||||
---
|
||||
|
||||
# Phase 2: Gold price client
|
||||
|
||||
## Overview
|
||||
|
||||
Implement a small HTTP client that returns VND price per `luong`, with injectable endpoints/HTTP client for tests and runtime endpoint overrides for operational fallback.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: fetch spot XAU price in USD per troy ounce.
|
||||
- Functional: fetch USD to VND exchange rate.
|
||||
- Functional: convert to VND per `luong`.
|
||||
- Functional: expose one method, `FetchLuongPrice(ctx) (float64, error)`.
|
||||
- Functional: support runtime endpoint overrides for gold and FX URLs.
|
||||
- Functional: cache FX response until `time_next_update_unix` when available; otherwise use a bounded fallback TTL.
|
||||
- Non-functional: no API key required in default path; timeout bounded for Lambda.
|
||||
- Non-functional: no global mutable client per request; reuse `http.Client` like trading does.
|
||||
- Non-functional: reject non-HTTPS override URLs except localhost/127.0.0.1 test servers.
|
||||
|
||||
## Architecture
|
||||
|
||||
Create `internal/modules/gold/prices.go`:
|
||||
|
||||
```go
|
||||
const gramsPerLuong = 37.5
|
||||
const gramsPerTroyOunce = 31.1034768
|
||||
|
||||
priceVNDPerLuong := xauUSDPerTroyOunce * usdToVND * (gramsPerLuong / gramsPerTroyOunce)
|
||||
```
|
||||
|
||||
Use two HTTP calls in v1. Keep each response struct minimal and defensive. FX can cache because ExchangeRate-API updates once daily; GoldPrice remains uncached in v1 unless latency proves painful.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `internal/modules/gold/prices.go`
|
||||
- Create: `internal/modules/gold/prices_test.go`
|
||||
- Modify: `cmd/server/main.go` if endpoint env vars are wired through config
|
||||
- Modify: `template.yaml` only for optional env var pass-through, not default module enablement
|
||||
- Read: `internal/modules/trading/prices.go`
|
||||
- Read: `internal/modules/trading/income_events.go` for HTTPS URL validation pattern
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add `GoldPriceClient` with `HTTP`, `GoldURL`, `FXURL`, `defaultOnce`, and `defaultClient`.
|
||||
2. Add default URLs:
|
||||
- `https://data-asg.goldprice.org/dbXRates/USD`
|
||||
- `https://open.er-api.com/v6/latest/USD`
|
||||
3. Add optional env/config plumbing for override URLs:
|
||||
- `GOLD_PRICE_API_URL`
|
||||
- `GOLD_FX_API_URL`
|
||||
4. Validate override URLs:
|
||||
- remote URLs must be `https`
|
||||
- `http://localhost`, `http://127.0.0.1`, and `http://[::1]` allowed for tests/local dev only
|
||||
5. Add bounded timeout, likely 10s to match trading.
|
||||
6. Decode GoldPrice.org response:
|
||||
- require non-empty `items`
|
||||
- require `curr == "USD"` if present
|
||||
- require `xauPrice > 0`
|
||||
7. Decode FX response:
|
||||
- require success result when field exists
|
||||
- require `rates.VND > 0`
|
||||
- read `time_next_update_unix` when present and cache until then
|
||||
- treat HTTP 429 as retryable upstream failure, not no-price
|
||||
8. Return a domain error `ErrNoGoldPrice` for empty/invalid upstream data.
|
||||
9. Wrap network/decode errors with `gold:` prefix.
|
||||
10. Unit-test success conversion, cache behavior, 429 handling, HTTPS validation, localhost exception, and invalid response paths with `httptest.Server`.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] `FetchLuongPrice` returns expected VND/luong value from fixtures.
|
||||
- [x] Non-2xx, 429, malformed JSON, missing XAU, missing VND, and zero prices are covered.
|
||||
- [x] FX cache uses `time_next_update_unix` when present and avoids repeated FX calls inside that window.
|
||||
- [x] Runtime override URLs are validated and test-local HTTP URLs still work.
|
||||
- [x] No API key or secret is required for default client construction.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Two upstream calls increase latency and failure rate. Mitigation: cache FX by provider metadata, keep GoldPrice isolated behind a small client, and make provider URLs overrideable without source changes.
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
phase: 3
|
||||
title: Gold portfolio commands
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: 3h
|
||||
dependencies:
|
||||
- 2
|
||||
---
|
||||
|
||||
# Phase 3: Gold portfolio commands
|
||||
|
||||
## Overview
|
||||
|
||||
Build the gold module's state, formatting, and user-facing command handlers.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: `/gold_topup <amount>` credits VND and increments invested amount.
|
||||
- Functional: `/gold_buy <luong>` deducts VND at current VND/luong price and adds gold holding.
|
||||
- Functional: `/gold_sell <luong>` deducts gold holding and credits VND.
|
||||
- Functional: `/gold_stats` renders VND, gold luong, current price, gold value, total value, invested, P&L.
|
||||
- Functional: full sell after fractional buys must leave exact zero after dust normalization.
|
||||
- Non-functional: no command accepts a currency, ticker, or unit in v1.
|
||||
- Non-functional: floating quantities must reject NaN, Inf, overflow, zero, and negative values.
|
||||
|
||||
## Architecture
|
||||
|
||||
Use module-local state:
|
||||
|
||||
```go
|
||||
type Portfolio struct {
|
||||
VND float64 `json:"vnd"`
|
||||
Luong float64 `json:"luong"`
|
||||
Meta PortfolioMeta `json:"meta"`
|
||||
}
|
||||
```
|
||||
|
||||
This is simpler than trading's `Currency` and `Assets` maps because v1 gold has exactly one cash currency and one asset. Storage key remains `user:<telegramID>` inside the gold module namespace. Arithmetic uses a concrete dust threshold: after each balance mutation, values whose absolute value is `< 1e-9` are set to zero.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `internal/modules/gold/gold.go`
|
||||
- Create: `internal/modules/gold/handlers.go`
|
||||
- Create: `internal/modules/gold/portfolio.go`
|
||||
- Create: `internal/modules/gold/format.go`
|
||||
- Create: `internal/modules/gold/handlers_test.go`
|
||||
- Create: `internal/modules/gold/portfolio_test.go`
|
||||
- Read: `internal/modules/trading/handlers.go`
|
||||
- Read: `internal/modules/trading/portfolio.go`
|
||||
- Read: `internal/modules/trading/format.go`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add `state` with `kv`, `prices`, `locks`, and `nowFn`.
|
||||
2. Copy `senderInfo` and `argsAfterCommand` locally or extract only if another module already has a shared helper. Do not refactor trading unless necessary.
|
||||
3. Add a shared local parser for positive finite floats. It must reject `NaN`, `Inf`, `+Inf`, `-Inf`, overflow, zero, and negative values.
|
||||
4. Implement `LoadPortfolio`, `SavePortfolio`, `AddVND`, `DeductVND`, `AddLuong`, and `DeductLuong`.
|
||||
5. Apply dust cleanup after each mutation using `const goldDustEpsilon = 1e-9`.
|
||||
6. Implement `FormatLuong`, keeping up to 4 decimals and trimming trailing zeros.
|
||||
7. Implement `handleTopup`:
|
||||
- usage: `Usage: /gold_topup <amount>`
|
||||
- parse amount as positive finite float
|
||||
- add VND, increment invested
|
||||
8. Implement `handleBuy`:
|
||||
- usage: `Usage: /gold_buy <luong>`
|
||||
- fetch VND/luong before lock
|
||||
- cost = qty * price
|
||||
- deduct VND, add luong
|
||||
9. Implement `handleSell`:
|
||||
- fetch price before lock
|
||||
- deduct luong, add VND
|
||||
10. Implement `handleStats`:
|
||||
- fetch price; if unavailable, show holdings with `(no price)` and cash balance
|
||||
- include total value and P&L when price exists
|
||||
11. Keep replies plain text unless future Telegram formatting needs HTML.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] Fresh user can top up, buy, sell, and view stats.
|
||||
- [x] Insufficient VND and insufficient gold return clear messages.
|
||||
- [x] Price errors do not mutate portfolio.
|
||||
- [x] Portfolio load repairs zero-value/missing fields safely.
|
||||
- [x] Fractional buy/sell round trips leave no dust above `1e-9`.
|
||||
- [x] Special float strings and overflow inputs are rejected before mutation.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Using float for `luong` can produce tiny rounding artifacts. Mitigation: use one explicit epsilon, normalize dust to zero after arithmetic, test full fractional sell scenarios, and keep display precision bounded.
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
phase: 4
|
||||
title: Module registration and docs
|
||||
status: completed
|
||||
priority: P2
|
||||
effort: 1h
|
||||
dependencies:
|
||||
- 3
|
||||
---
|
||||
|
||||
# Phase 4: Module registration and docs
|
||||
|
||||
## Overview
|
||||
|
||||
Wire the gold module into the bot catalog and docs as an opt-in module for first deploy.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: `gold` is a first-class module selectable by `MODULES`.
|
||||
- Functional: first implementation keeps `gold` opt-in; do not add it to the default `template.yaml` `ModulesCSV` until the operator explicitly promotes the spot-priced module after opt-in smoke.
|
||||
- Functional: optional gold price endpoint env vars are documented if implemented.
|
||||
- Non-functional: docs must state price source limitation clearly.
|
||||
|
||||
## Architecture
|
||||
|
||||
Registration follows the current composition-root pattern:
|
||||
|
||||
- import `internal/modules/gold` in `cmd/server/main.go`
|
||||
- add `"gold": gold.New` to `factories()`
|
||||
- leave `template.yaml` default `ModulesCSV` unchanged for first deploy
|
||||
- optionally pass `GOLD_PRICE_API_URL` and `GOLD_FX_API_URL` through Lambda env if runtime overrides are implemented through config
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `cmd/server/main.go`
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/deploy-aws.md`
|
||||
- Maybe modify: `template.yaml` only for override env pass-through, not default module enablement
|
||||
- Maybe modify: `cmd/server/main_test.go` or equivalent to test real catalog wiring
|
||||
- Maybe modify: `internal/modules/registry_test.go` only if existing tests assert full known list
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add `gold` import and factory entry.
|
||||
2. Keep `gold` opt-in for first deploy:
|
||||
- do not add `gold` to default `ModulesCSV`
|
||||
- document `MODULES=...,gold` enablement
|
||||
3. Update README module table with `gold` and mark it opt-in if defaults remain unchanged.
|
||||
4. Add docs section:
|
||||
- default commands
|
||||
- price source: world spot XAU converted to VND, not SJC local retail
|
||||
- no secrets required for default source
|
||||
- endpoint override env vars and HTTPS validation rules if implemented
|
||||
- ExchangeRate-API attribution note if displayed/required
|
||||
5. Add or update tests for real composition-root wiring:
|
||||
- `factories()["gold"]` exists
|
||||
- `modules.Build([]string{"gold"}, factories(), ...)` succeeds
|
||||
6. Add namespace-isolation coverage for `trading` and `gold` both using `user:<id>` under different module prefixes.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] `MODULES=gold` starts without unknown-module error through the real `cmd/server` factory catalog.
|
||||
- [x] `/help` lists gold commands when module is enabled.
|
||||
- [x] README and deploy docs accurately describe gold's price source, opt-in status, and default units.
|
||||
- [x] `template.yaml` default modules remain unchanged unless user explicitly accepts default production enablement.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Adding `gold` to default modules would expose a spot-price product decision immediately after deploy. Mitigation: keep it opt-in for first deploy and promote to default only after a separate operator decision.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
phase: 5
|
||||
title: Tests and verification
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: 1.5h
|
||||
dependencies:
|
||||
- 4
|
||||
---
|
||||
|
||||
# Phase 5: Tests and verification
|
||||
|
||||
## Overview
|
||||
|
||||
Add focused coverage and run compile/test commands required for a safe module addition.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: test all user-visible command paths.
|
||||
- Functional: test real module catalog wiring and module namespace isolation.
|
||||
- Non-functional: no live network dependency in unit tests.
|
||||
- Non-functional: no syntax errors; Go tests pass.
|
||||
|
||||
## Architecture
|
||||
|
||||
Tests should mirror `internal/modules/trading/*_test.go` and use:
|
||||
|
||||
- `httptest.Server` for price and FX clients.
|
||||
- `internal/storage.NewMemoryKV()` or equivalent existing memory KV.
|
||||
- `internal/testutil/recording_bot.go` for Telegram replies.
|
||||
- Injected `nowFn` for deterministic metadata.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create/modify: `internal/modules/gold/*_test.go`
|
||||
- Modify: `cmd/server/*_test.go` if needed for real `factories()` coverage
|
||||
- Maybe modify: `internal/modules/validate_test.go` only if command validation expectations list commands explicitly
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Test price conversion and invalid upstream responses.
|
||||
2. Test FX cache behavior, 429 handling, HTTPS override validation, and localhost override exception.
|
||||
3. Test parser rejection for `NaN`, `Inf`, `+Inf`, `-Inf`, overflow inputs like `1e9999`, zero, and negatives.
|
||||
4. Test portfolio first-load defaults, add/deduct, insufficient balance, dust cleanup, and save/load round trip.
|
||||
5. Test full fractional sell after fractional buys leaves zero after dust normalization.
|
||||
6. Test handlers:
|
||||
- topup usage and success
|
||||
- buy usage, success, insufficient VND, price failure
|
||||
- sell usage, success, insufficient luong, price failure
|
||||
- stats with holdings and stats with no price
|
||||
7. Test module factory registers exact commands:
|
||||
- `gold_topup`
|
||||
- `gold_buy`
|
||||
- `gold_sell`
|
||||
- `gold_stats`
|
||||
8. Test real composition-root wiring:
|
||||
- `factories()["gold"]` exists
|
||||
- `modules.Build([]string{"gold"}, factories(), ...)` succeeds
|
||||
9. Test namespace isolation by enabling both `trading` and `gold` and verifying their `user:<id>` portfolio keys do not collide under module-prefixed KV storage.
|
||||
10. Run:
|
||||
- `gofmt` on new/modified Go files
|
||||
- `go test ./internal/modules/gold`
|
||||
- `go test ./internal/modules ./cmd/server`
|
||||
- `go test ./...` before push
|
||||
11. Do a self-review for file size; split handlers if any new code file exceeds 200 lines and logical extraction is clean.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] All gold unit tests pass without network.
|
||||
- [x] Existing module registry and server catalog tests pass.
|
||||
- [x] Parser tests reject special float and overflow inputs.
|
||||
- [x] Namespace isolation test proves trading and gold portfolios do not collide.
|
||||
- [x] `go test ./...` passes locally.
|
||||
- [x] Manual smoke syntax documented: `/gold_topup 10000000`, `/gold_buy 1`, `/gold_stats`, `/gold_sell 0.5`.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Stats depends on external price source at runtime. Tests must verify graceful degradation so users still see cash/holding state if upstream is temporarily unavailable.
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: Gold module matching trading workflow
|
||||
description: >-
|
||||
Add a standalone gold paper-trading module that mirrors trading UX, defaults
|
||||
topups to VND, defaults buys/sells to Vietnamese luong, and uses a free/no-key
|
||||
spot price source for v1.
|
||||
status: completed
|
||||
priority: P2
|
||||
branch: main
|
||||
tags:
|
||||
- gold
|
||||
- trading
|
||||
- telegram
|
||||
- price-api
|
||||
- free-tier
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
created: '2026-06-11T07:35:05.803Z'
|
||||
createdBy: 'ck:plan'
|
||||
source: skill
|
||||
---
|
||||
|
||||
# Gold module matching trading workflow
|
||||
|
||||
## Overview
|
||||
|
||||
Add `internal/modules/gold` as a separate module, not an extension of `trading`. Keep user behavior parallel to `/trade_*` commands, but gold-only:
|
||||
|
||||
- `/gold_topup <amount>` credits VND only. No currency argument.
|
||||
- `/gold_buy <luong>` buys gold in `luong` by default. No symbol or unit argument.
|
||||
- `/gold_sell <luong>` sells gold in `luong` by default. No symbol or unit argument.
|
||||
- `/gold_stats` shows VND balance, gold holding, current price, total value, invested amount, and P&L.
|
||||
|
||||
V1 pricing is explicitly **world spot XAU converted to VND per `luong`**, not Vietnamese SJC retail buy/sell price. Default price path: no-key GoldPrice.org spot XAU USD JSON plus no-key ExchangeRate-API USD to VND conversion, converted to VND per `luong` (`1 luong = 37.5g = 37.5 / 31.1034768 troy oz`). This is free-tier friendly but must be isolated behind a provider interface because GoldPrice.org JSON is undocumented. Exact Vietnamese SJC retail pricing is out of v1 unless a separate, higher-maintenance source is approved.
|
||||
|
||||
## Current Code Context
|
||||
|
||||
- `internal/modules/trading/trading.go` registers `trade_topup`, `trade_buy`, `trade_sell`, `trade_stats`, plus income helpers.
|
||||
- `internal/modules/trading/handlers.go` already has the target workflow: parse command args, fetch price outside the per-user lock, mutate KV portfolio under `keylock.Map`, reply through `chathelper`.
|
||||
- `internal/modules/trading/portfolio.go` stores per-user `Currency`, `Assets`, and `Meta.Invested` under `user:<id>`.
|
||||
- `cmd/server/main.go` owns the module catalog; adding a module requires import + `"gold": gold.New`.
|
||||
- `template.yaml` default `MODULES` currently includes `trading` but not `gold`.
|
||||
|
||||
## Price API Research
|
||||
|
||||
| Candidate | Free shape | Fit | Decision |
|
||||
|---|---|---|---|
|
||||
| GoldPrice.org `https://data-asg.goldprice.org/dbXRates/USD` | No key; current JSON has `items[0].xauPrice`, `curr`, and timestamp fields. Undocumented endpoint, no stability/SLA claim. | Best zero-secret v1 source for spot XAU if treated as best-effort. | Use as default provider for v1 behind an isolated client and runtime URL override. |
|
||||
| ExchangeRate-API open endpoint `https://open.er-api.com/v6/latest/USD` | No key; docs require attribution, allow caching, note rate limiting, update once daily, and include `rates.VND`. | Good USD to VND conversion companion. | Use for USD/VND conversion; cache until `time_next_update_unix` when available and handle 429 explicitly. |
|
||||
| Frankfurter | No-key FX API. | Possible FX fallback if VND support is verified during implementation. | Fallback only. |
|
||||
| SJC official site | HTML price table, no public JSON API found. | Exact Vietnam local retail price would require scraping or another higher-maintenance source. | Out of v1. Do not plan default SJC JSON integration. |
|
||||
| API Ninjas `/v1/goldprice` | Requires `X-Api-Key`; free users receive delayed data, and current product pages gate some endpoints. | Less aligned with no-secret free-tier. | Do not default. Keep as optional future provider. |
|
||||
| Metals-API | Requires API key; current product is key-based and not a strict no-secret default. | Not free-tier enough for this bot. | Do not default. |
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- Standalone module/package named `gold`, commands prefixed `gold_`.
|
||||
- Separate KV namespace from `trading`; no cross-portfolio mixing.
|
||||
- Keep holdings as `float64` luong, with a concrete dust rule: balances whose absolute value is `< 1e-9` are normalized to zero after arithmetic.
|
||||
- Use VND as only cash currency. Do not accept `USD`, `VND`, symbols, or units in v1 commands.
|
||||
- Fetch price before locking user state, same as trading, to keep lock scope short.
|
||||
- Keep `gold` opt-in for first deploy. Do not add it to default `template.yaml` `MODULES` until the operator explicitly promotes the spot-priced module after opt-in smoke.
|
||||
- No real order execution, no SJC spread, no fees, no cron refresh in v1.
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [Research and existing trade pattern](./phase-01-research-and-existing-trade-pattern.md) | Completed |
|
||||
| 2 | [Gold price client](./phase-02-gold-price-client.md) | Completed |
|
||||
| 3 | [Gold portfolio commands](./phase-03-gold-portfolio-commands.md) | Completed |
|
||||
| 4 | [Module registration and docs](./phase-04-module-registration-and-docs.md) | Completed |
|
||||
| 5 | [Tests and verification](./phase-05-tests-and-verification.md) | Completed |
|
||||
|
||||
## Dependencies
|
||||
|
||||
No blocking unfinished plan. Related prior plans are complete or broader deploy work:
|
||||
|
||||
- `plans/260510-0234-pre-deploy-wrapup/` completed the trading module pattern this plan mirrors.
|
||||
- `plans/260605-0256-trade-income-events-command/` completed recent trading command additions; useful for test style only.
|
||||
|
||||
## Review Notes
|
||||
|
||||
Three read-only ClaudeKit agents reviewed this plan on 2026-06-11. Accepted changes:
|
||||
|
||||
- Resolved v1 pricing as world spot XAU converted to VND; SJC retail is out of scope.
|
||||
- Changed rollout stance from default-enabled to opt-in first deploy.
|
||||
- Added FX caching/rate-limit handling and GoldPrice best-effort caveat.
|
||||
- Added concrete fractional `luong` dust behavior and special-float tests.
|
||||
- Added real composition-root and cross-module namespace test requirements.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Gold module compiles and registers when enabled in `MODULES`.
|
||||
- `/gold_topup`, `/gold_buy`, `/gold_sell`, `/gold_stats` match trading behavior where applicable.
|
||||
- Buy/sell quantities are interpreted as `luong` by default without a unit argument.
|
||||
- Topup always credits VND without a currency argument.
|
||||
- Unit tests cover parsing, insufficient funds/holdings, price failures, stats rendering, module registration, and namespace isolation.
|
||||
- `go test ./internal/modules/gold ./internal/modules ./cmd/server` passes; run broader `go test ./...` before push.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Physical gold dealer/SJC buy/sell spread.
|
||||
- Multiple gold units in commands (`gram`, `chi`, `oz`).
|
||||
- Cross-module transfers between trading and gold.
|
||||
- Historical price charts, alerts, leaderboards, or cron refresh.
|
||||
- Default production enablement in `template.yaml` before spot-price semantics are accepted.
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
None for implementation. Product caveat: v1 uses world spot converted to VND, not Vietnam SJC retail price.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: "Gold module plan review"
|
||||
date: 2026-06-11
|
||||
status: completed
|
||||
reviewers: [planner, researcher, codebase-fit]
|
||||
---
|
||||
|
||||
# Gold module plan review
|
||||
|
||||
## Summary
|
||||
|
||||
Three ClaudeKit sub-agents reviewed `plans/260611-0735-gold-module-trading-parity/`. All completed with `DONE_WITH_CONCERNS`; no blocker, but plan needed tightening before implementation.
|
||||
|
||||
## Accepted Findings
|
||||
|
||||
- Pricing decision was inconsistent: plan used spot XAU but left spot-vs-SJC unresolved while considering default enablement.
|
||||
- GoldPrice.org endpoint works today but is undocumented; treat as best-effort soft dependency.
|
||||
- ExchangeRate-API open endpoint needs attribution awareness, cache handling, and 429 handling.
|
||||
- Free/no-key SJC JSON source is not credible for v1; exact SJC retail price is out of scope.
|
||||
- Fractional `luong` arithmetic needed explicit epsilon/dust behavior.
|
||||
- Parser tests must cover `NaN`, `Inf`, `+Inf`, `-Inf`, and overflow values accepted by `strconv.ParseFloat`.
|
||||
- Real `cmd/server` factory wiring and trading/gold namespace isolation needed tests.
|
||||
|
||||
## Plan Changes Applied
|
||||
|
||||
- V1 pricing locked to world spot XAU converted to VND per `luong`.
|
||||
- `gold` kept opt-in for first deploy; default `template.yaml` enablement deferred.
|
||||
- Runtime provider override and HTTPS/localhost validation added to price-client phase.
|
||||
- FX cache and 429 handling added.
|
||||
- Dust rule added: absolute balance below `1e-9` normalizes to zero.
|
||||
- Special-float, catalog wiring, and namespace-isolation tests added.
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
None.
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: "Gold module completion report"
|
||||
date: 2026-06-11
|
||||
status: completed
|
||||
---
|
||||
|
||||
# Gold module completion report
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented opt-in `gold` module per reviewed plan. Module mirrors trading workflow for a gold-only paper account: VND topup, buy/sell in `luong`, stats with P&L.
|
||||
|
||||
## Files Changed
|
||||
|
||||
| Area | Files |
|
||||
|---|---|
|
||||
| Gold module | `internal/modules/gold/*.go` |
|
||||
| Server wiring | `cmd/server/main.go`, `cmd/server/main_test.go` |
|
||||
| Deploy config | `template.yaml` |
|
||||
| Docs | `README.md`, `docs/deploy-aws.md` |
|
||||
| Plan/report | `plans/260611-0735-gold-module-trading-parity/` |
|
||||
|
||||
## Verification
|
||||
|
||||
- `go test -count=1 ./internal/modules/gold ./internal/modules ./cmd/server` passed.
|
||||
- `go test -count=1 ./...` passed.
|
||||
- `go test -race -count=1 ./internal/modules/gold` passed.
|
||||
- `go test -cover ./internal/modules/gold` passed at 83.4% statement coverage.
|
||||
- `make vet` passed.
|
||||
- `git diff --check` passed.
|
||||
- `sam validate` could not run because `sam` is not installed in this environment.
|
||||
- Tester subagent re-check: DONE, no blockers.
|
||||
- Reviewer subagent re-check: DONE, no blockers.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Gold module compiles and registers when enabled in `MODULES`.
|
||||
- [x] `/gold_topup` credits VND only.
|
||||
- [x] `/gold_buy` and `/gold_sell` require exactly one `luong` argument.
|
||||
- [x] Price client computes VND/luong from XAU USD and USD/VND.
|
||||
- [x] FX cache, 429 handling, URL validation, and localhost test exception covered.
|
||||
- [x] Special float, overflow, and too-large finite transaction inputs rejected.
|
||||
- [x] Trading/gold storage namespace isolation tested.
|
||||
- [x] `template.yaml` default `ModulesCSV` remains unchanged; `gold` is opt-in.
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
None.
|
||||
@@ -37,6 +37,16 @@ Parameters:
|
||||
Default: ""
|
||||
Description: Optional SSM SecureString parameter name containing bearer token for FireAnt REST API.
|
||||
|
||||
GoldPriceAPIURL:
|
||||
Type: String
|
||||
Default: ""
|
||||
Description: Optional gold spot price API URL override. Empty uses the built-in GoldPrice.org endpoint.
|
||||
|
||||
GoldFXAPIURL:
|
||||
Type: String
|
||||
Default: ""
|
||||
Description: Optional USD/VND FX API URL override. Empty uses the built-in ExchangeRate-API open 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
|
||||
@@ -142,6 +152,8 @@ Resources:
|
||||
ADMIN_USER_IDS: !Ref AdminUserIDs
|
||||
TRADING_INCOME_EVENTS_API_URL: !Ref TradingIncomeEventsAPIURL
|
||||
TRADING_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME: !Ref TradingIncomeEventsAPITokenParameterName
|
||||
GOLD_PRICE_API_URL: !Ref GoldPriceAPIURL
|
||||
GOLD_FX_API_URL: !Ref GoldFXAPIURL
|
||||
# ---- 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