From 254bf47dc18bb03a83e3df06c8f73c183db53c39 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 11 Jun 2026 17:13:02 +0700 Subject: [PATCH] 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. --- README.md | 1 + cmd/server/main.go | 8 + cmd/server/main_test.go | 24 +++ docs/deploy-aws.md | 18 ++ internal/modules/gold/format.go | 57 +++++ internal/modules/gold/gold.go | 37 ++++ internal/modules/gold/handlers.go | 161 +++++++++++++++ internal/modules/gold/handlers_test.go | 162 +++++++++++++++ .../modules/gold/handlers_validation_test.go | 58 ++++++ internal/modules/gold/helpers.go | 80 ++++++++ internal/modules/gold/portfolio.go | 105 ++++++++++ internal/modules/gold/portfolio_test.go | 99 +++++++++ internal/modules/gold/price_urls.go | 30 +++ internal/modules/gold/prices.go | 194 ++++++++++++++++++ internal/modules/gold/prices_test.go | 121 +++++++++++ ...-01-research-and-existing-trade-pattern.md | 65 ++++++ .../phase-02-gold-price-client.md | 87 ++++++++ .../phase-03-gold-portfolio-commands.md | 89 ++++++++ .../phase-04-module-registration-and-docs.md | 69 +++++++ .../phase-05-tests-and-verification.md | 78 +++++++ .../plan.md | 111 ++++++++++ .../reports/plan-review-260611-gold-module.md | 35 ++++ .../pm-260611-gold-module-completion.md | 48 +++++ template.yaml | 12 ++ 24 files changed, 1749 insertions(+) create mode 100644 cmd/server/main_test.go create mode 100644 internal/modules/gold/format.go create mode 100644 internal/modules/gold/gold.go create mode 100644 internal/modules/gold/handlers.go create mode 100644 internal/modules/gold/handlers_test.go create mode 100644 internal/modules/gold/handlers_validation_test.go create mode 100644 internal/modules/gold/helpers.go create mode 100644 internal/modules/gold/portfolio.go create mode 100644 internal/modules/gold/portfolio_test.go create mode 100644 internal/modules/gold/price_urls.go create mode 100644 internal/modules/gold/prices.go create mode 100644 internal/modules/gold/prices_test.go create mode 100644 plans/260611-0735-gold-module-trading-parity/phase-01-research-and-existing-trade-pattern.md create mode 100644 plans/260611-0735-gold-module-trading-parity/phase-02-gold-price-client.md create mode 100644 plans/260611-0735-gold-module-trading-parity/phase-03-gold-portfolio-commands.md create mode 100644 plans/260611-0735-gold-module-trading-parity/phase-04-module-registration-and-docs.md create mode 100644 plans/260611-0735-gold-module-trading-parity/phase-05-tests-and-verification.md create mode 100644 plans/260611-0735-gold-module-trading-parity/plan.md create mode 100644 plans/260611-0735-gold-module-trading-parity/reports/plan-review-260611-gold-module.md create mode 100644 plans/260611-0735-gold-module-trading-parity/reports/pm-260611-gold-module-completion.md diff --git a/README.md b/README.md index 7ba3377..efb9019 100644 --- a/README.md +++ b/README.md @@ -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 `, `/stats cmd ` | Disable any module by editing `MODULES` in `template.yaml`. diff --git a/cmd/server/main.go b/cmd/server/main.go index 0a0dc3c..28eed85 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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"]), diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 0000000..446ab62 --- /dev/null +++ b/cmd/server/main_test.go @@ -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) + } + } +} diff --git a/docs/deploy-aws.md b/docs/deploy-aws.md index 81585e2..7de7b3d 100644 --- a/docs/deploy-aws.md +++ b/docs/deploy-aws.md @@ -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 ` credits VND. No currency argument is accepted. +- `/gold_buy ` buys gold in Vietnamese `luong`. No symbol or unit argument is accepted. +- `/gold_sell ` 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 diff --git a/internal/modules/gold/format.go b/internal/modules/gold/format.go new file mode 100644 index 0000000..3976811 --- /dev/null +++ b/internal/modules/gold/format.go @@ -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 +} diff --git a/internal/modules/gold/gold.go b/internal/modules/gold/gold.go new file mode 100644 index 0000000..4ec29c9 --- /dev/null +++ b/internal/modules/gold/gold.go @@ -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, + }, + }, + } +} diff --git a/internal/modules/gold/handlers.go b/internal/modules/gold/handlers.go new file mode 100644 index 0000000..6918554 --- /dev/null +++ b/internal/modules/gold/handlers.go @@ -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 \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 \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 \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")) +} diff --git a/internal/modules/gold/handlers_test.go b/internal/modules/gold/handlers_test.go new file mode 100644 index 0000000..b81ed62 --- /dev/null +++ b/internal/modules/gold/handlers_test.go @@ -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()} +} diff --git a/internal/modules/gold/handlers_validation_test.go b/internal/modules/gold/handlers_validation_test.go new file mode 100644 index 0000000..29b835b --- /dev/null +++ b/internal/modules/gold/handlers_validation_test.go @@ -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 "}, + {name: "buy unit", text: "/gold_buy 1 oz", want: "Usage: /gold_buy "}, + {name: "sell symbol", text: "/gold_sell 1 SJC", want: "Usage: /gold_sell "}, + } + 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") +} diff --git a/internal/modules/gold/helpers.go b/internal/modules/gold/helpers.go new file mode 100644 index 0000000..99378c5 --- /dev/null +++ b/internal/modules/gold/helpers.go @@ -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.") +} diff --git a/internal/modules/gold/portfolio.go b/internal/modules/gold/portfolio.go new file mode 100644 index 0000000..4f0b0c0 --- /dev/null +++ b/internal/modules/gold/portfolio.go @@ -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 +} diff --git a/internal/modules/gold/portfolio_test.go b/internal/modules/gold/portfolio_test.go new file mode 100644 index 0000000..cc0eb75 --- /dev/null +++ b/internal/modules/gold/portfolio_test.go @@ -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) + } + } +} diff --git a/internal/modules/gold/price_urls.go b/internal/modules/gold/price_urls.go new file mode 100644 index 0000000..f3b9f82 --- /dev/null +++ b/internal/modules/gold/price_urls.go @@ -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() +} diff --git a/internal/modules/gold/prices.go b/internal/modules/gold/prices.go new file mode 100644 index 0000000..8408320 --- /dev/null +++ b/internal/modules/gold/prices.go @@ -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) +} diff --git a/internal/modules/gold/prices_test.go b/internal/modules/gold/prices_test.go new file mode 100644 index 0000000..326b2e9 --- /dev/null +++ b/internal/modules/gold/prices_test.go @@ -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") + } +} diff --git a/plans/260611-0735-gold-module-trading-parity/phase-01-research-and-existing-trade-pattern.md b/plans/260611-0735-gold-module-trading-parity/phase-01-research-and-existing-trade-pattern.md new file mode 100644 index 0000000..970d87f --- /dev/null +++ b/plans/260611-0735-gold-module-trading-parity/phase-01-research-and-existing-trade-pattern.md @@ -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 `, `/gold_buy `, `/gold_sell `, `/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. diff --git a/plans/260611-0735-gold-module-trading-parity/phase-02-gold-price-client.md b/plans/260611-0735-gold-module-trading-parity/phase-02-gold-price-client.md new file mode 100644 index 0000000..7ef6d55 --- /dev/null +++ b/plans/260611-0735-gold-module-trading-parity/phase-02-gold-price-client.md @@ -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. diff --git a/plans/260611-0735-gold-module-trading-parity/phase-03-gold-portfolio-commands.md b/plans/260611-0735-gold-module-trading-parity/phase-03-gold-portfolio-commands.md new file mode 100644 index 0000000..f4a9074 --- /dev/null +++ b/plans/260611-0735-gold-module-trading-parity/phase-03-gold-portfolio-commands.md @@ -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 ` credits VND and increments invested amount. +- Functional: `/gold_buy ` deducts VND at current VND/luong price and adds gold holding. +- Functional: `/gold_sell ` 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:` 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 ` + - parse amount as positive finite float + - add VND, increment invested +8. Implement `handleBuy`: + - usage: `Usage: /gold_buy ` + - 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. diff --git a/plans/260611-0735-gold-module-trading-parity/phase-04-module-registration-and-docs.md b/plans/260611-0735-gold-module-trading-parity/phase-04-module-registration-and-docs.md new file mode 100644 index 0000000..60af599 --- /dev/null +++ b/plans/260611-0735-gold-module-trading-parity/phase-04-module-registration-and-docs.md @@ -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:` 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. diff --git a/plans/260611-0735-gold-module-trading-parity/phase-05-tests-and-verification.md b/plans/260611-0735-gold-module-trading-parity/phase-05-tests-and-verification.md new file mode 100644 index 0000000..40153a6 --- /dev/null +++ b/plans/260611-0735-gold-module-trading-parity/phase-05-tests-and-verification.md @@ -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:` 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. diff --git a/plans/260611-0735-gold-module-trading-parity/plan.md b/plans/260611-0735-gold-module-trading-parity/plan.md new file mode 100644 index 0000000..08d1005 --- /dev/null +++ b/plans/260611-0735-gold-module-trading-parity/plan.md @@ -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 ` credits VND only. No currency argument. +- `/gold_buy ` buys gold in `luong` by default. No symbol or unit argument. +- `/gold_sell ` 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:`. +- `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. diff --git a/plans/260611-0735-gold-module-trading-parity/reports/plan-review-260611-gold-module.md b/plans/260611-0735-gold-module-trading-parity/reports/plan-review-260611-gold-module.md new file mode 100644 index 0000000..7efb904 --- /dev/null +++ b/plans/260611-0735-gold-module-trading-parity/reports/plan-review-260611-gold-module.md @@ -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. diff --git a/plans/260611-0735-gold-module-trading-parity/reports/pm-260611-gold-module-completion.md b/plans/260611-0735-gold-module-trading-parity/reports/pm-260611-gold-module-completion.md new file mode 100644 index 0000000..51380d8 --- /dev/null +++ b/plans/260611-0735-gold-module-trading-parity/reports/pm-260611-gold-module-completion.md @@ -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. diff --git a/template.yaml b/template.yaml index 3d7aeb2..2913ff1 100644 --- a/template.yaml +++ b/template.yaml @@ -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"