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:
2026-06-11 17:13:02 +07:00
parent 2bf3cb2eee
commit 254bf47dc1
24 changed files with 1749 additions and 0 deletions
+1
View File
@@ -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`.
+8
View File
@@ -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"]),
+24
View File
@@ -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)
}
}
}
+18
View File
@@ -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
+57
View File
@@ -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
}
+37
View File
@@ -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,
},
},
}
}
+161
View File
@@ -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"))
}
+162
View File
@@ -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")
}
+80
View File
@@ -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.")
}
+105
View File
@@ -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
}
+99
View File
@@ -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)
}
}
}
+30
View File
@@ -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()
}
+194
View File
@@ -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)
}
+121
View File
@@ -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")
}
}
@@ -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.
@@ -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.
@@ -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.
+12
View File
@@ -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"