From b9b44bc7b23d0247226090c76d86c1138e19e42a Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 10 May 2026 03:06:13 +0700 Subject: [PATCH] =?UTF-8?q?feat(trading):=20VN=20stocks=20paper=20trading?= =?UTF-8?q?=20module=20=E2=80=94=205=20commands=20+=20price=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Portfolio, holdings, prices, symbols packages with Telegram command handlers - Handlers: trade_topup (add credits), trade_buy, trade_sell, trade_convert (coming-soon), trade_stats - Per-user keylock for concurrency safety - KBS (VN) as price source; ticker validation regex ^[A-Z0-9]{1,16}$ - Scope-trimmed: no SQL history, no retention cron, no daily refresh, no leaderboard, no forex (matches upstream miti99bot's current VN-stocks-only state) - Full unit test coverage for format, portfolio, prices, symbols, handlers --- internal/modules/trading/format.go | 59 +++++ internal/modules/trading/format_test.go | 61 +++++ internal/modules/trading/handlers.go | 282 +++++++++++++++++++++ internal/modules/trading/portfolio.go | 123 +++++++++ internal/modules/trading/portfolio_test.go | 91 +++++++ internal/modules/trading/prices.go | 113 +++++++++ internal/modules/trading/prices_test.go | 95 +++++++ internal/modules/trading/symbols.go | 62 +++++ internal/modules/trading/symbols_test.go | 87 +++++++ internal/modules/trading/trading.go | 46 ++++ 10 files changed, 1019 insertions(+) create mode 100644 internal/modules/trading/format.go create mode 100644 internal/modules/trading/format_test.go create mode 100644 internal/modules/trading/handlers.go create mode 100644 internal/modules/trading/portfolio.go create mode 100644 internal/modules/trading/portfolio_test.go create mode 100644 internal/modules/trading/prices.go create mode 100644 internal/modules/trading/prices_test.go create mode 100644 internal/modules/trading/symbols.go create mode 100644 internal/modules/trading/symbols_test.go create mode 100644 internal/modules/trading/trading.go diff --git a/internal/modules/trading/format.go b/internal/modules/trading/format.go new file mode 100644 index 0000000..718b8ac --- /dev/null +++ b/internal/modules/trading/format.go @@ -0,0 +1,59 @@ +// Package trading is a paper-trading module for VN stocks. Per-user +// portfolio + buy/sell at market price + stats with P&L. Ported from the +// JS trading module in miti99bot; SQL-based history + retention cron are +// out of scope for this v1 port. +package trading + +import ( + "math" + "strconv" + "strings" +) + +// FormatVND renders an integer-rounded amount with dot-thousands separators +// and a "VND" suffix (e.g. 15000000 -> "15.000.000 VND"). Manual to avoid +// locale-dependent formatting. +func FormatVND(n float64) string { + 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() +} + +// FormatStock renders an integer share quantity (always whole shares). +func FormatStock(n float64) string { + return strconv.FormatInt(int64(math.Floor(n)), 10) +} + +// FormatPnL renders a signed VND delta + percentage line, e.g. +// "+1.234 VND (+12.34%)" or "-500.000 VND (-5.00%)". When invested is zero +// the percentage is reported as 0.00 to avoid division-by-zero — matches JS. +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/trading/format_test.go b/internal/modules/trading/format_test.go new file mode 100644 index 0000000..13f3135 --- /dev/null +++ b/internal/modules/trading/format_test.go @@ -0,0 +1,61 @@ +package trading + +import "testing" + +func TestFormatVND(t *testing.T) { + cases := []struct { + in float64 + want string + }{ + {0, "0 VND"}, + {1, "1 VND"}, + {100, "100 VND"}, + {1000, "1.000 VND"}, + {15_000_000, "15.000.000 VND"}, + {1_234_567_890, "1.234.567.890 VND"}, + {-500, "-500 VND"}, + {-1_500_000, "-1.500.000 VND"}, + // rounding: half away from zero (math.Round) + {1.5, "2 VND"}, + {-1.5, "-2 VND"}, + } + for _, c := range cases { + if got := FormatVND(c.in); got != c.want { + t.Errorf("FormatVND(%v): got %q, want %q", c.in, got, c.want) + } + } +} + +func TestFormatStock(t *testing.T) { + cases := []struct { + in float64 + want string + }{ + {0, "0"}, + {1, "1"}, + {100, "100"}, + {99.9, "99"}, // floor for safety; we never store fractional shares anyway + } + for _, c := range cases { + if got := FormatStock(c.in); got != c.want { + t.Errorf("FormatStock(%v): got %q, want %q", c.in, got, c.want) + } + } +} + +func TestFormatPnL(t *testing.T) { + cases := []struct { + current, invested float64 + want string + }{ + {1_500_000, 1_000_000, "+500.000 VND (+50.00%)"}, + {800_000, 1_000_000, "-200.000 VND (-20.00%)"}, + {1_000_000, 1_000_000, "+0 VND (+0.00%)"}, + {500_000, 0, "+500.000 VND (+0.00%)"}, // invested=0 → pct=0, no NaN + } + for _, c := range cases { + if got := FormatPnL(c.current, c.invested); got != c.want { + t.Errorf("FormatPnL(%v,%v): got %q, want %q", c.current, c.invested, got, c.want) + } + } +} diff --git a/internal/modules/trading/handlers.go b/internal/modules/trading/handlers.go new file mode 100644 index 0000000..3c8478f --- /dev/null +++ b/internal/modules/trading/handlers.go @@ -0,0 +1,282 @@ +package trading + +import ( + "context" + "errors" + "strconv" + "strings" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot-go/internal/keylock" + "github.com/tiennm99/miti99bot-go/internal/log" + "github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper" + "github.com/tiennm99/miti99bot-go/internal/storage" +) + +// state is the per-module runtime. KV is module-scoped (the framework +// prefixes/partitions). PriceClient is reused across calls; nowFn allows +// tests to inject a deterministic clock for portfolio CreatedAt. +type state struct { + kv storage.KVStore + prices *PriceClient + locks keylock.Map + nowFn func() time.Time + comingSoonMessage string // exposed for tests / future i18n +} + +func (s *state) now() time.Time { + if s.nowFn != nil { + return s.nowFn() + } + return time.Now() +} + +// newState builds the default state used by the module factory. +func newState(kv storage.KVStore) *state { + return &state{ + kv: kv, + prices: &PriceClient{}, + comingSoonMessage: "Crypto, gold & currency exchange coming soon!", + } +} + +// senderInfo extracts the Telegram user ID for state-keying. Channel posts +// and inline queries lack a from-user; we refuse to operate without one +// because state under "user:0" would collide across all such updates. +// Defensive against From.ID == 0 (anonymized senders / future Telegram +// schema drift) for the same reason. +func senderInfo(update *models.Update) (userID int64, chatID int64, ok bool) { + msg := update.Message + if msg == nil || msg.From == nil || msg.From.ID == 0 { + return 0, 0, false + } + return msg.From.ID, msg.Chat.ID, true +} + +// argsAfterCommand splits the command body into whitespace-separated args. +// "/trade_buy 100 TCB" → ["100", "TCB"]; "/trade_topup" → [] +func argsAfterCommand(text string) []string { + parts := strings.Fields(text) + if len(parts) <= 1 { + return nil + } + return parts[1:] +} + +func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Update) error { + userID, chatID, ok := senderInfo(update) + if !ok { + return chathelper.Reply(ctx, b, update.Message.Chat.ID, + "Cannot identify user — trading only works in private/group chats with a sender.") + } + args := argsAfterCommand(update.Message.Text) + if len(args) < 1 { + return chathelper.Reply(ctx, b, chatID, "Usage: /trade_topup \nExample: /trade_topup 5000000") + } + amount, err := strconv.ParseFloat(args[0], 64) + if err != nil || amount <= 0 { + return chathelper.Reply(ctx, b, chatID, "Amount must be a positive number.") + } + + defer s.locks.Acquire(strconv.FormatInt(userID, 10))() + + p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli()) + if err != nil { + log.Error("trading_load_portfolio", "user", userID, "err", err) + return chathelper.Reply(ctx, b, chatID, "Could not load portfolio. Try again later.") + } + p.AddCurrency("VND", amount) + p.Meta.Invested += amount + if err := SavePortfolio(ctx, s.kv, userID, p); err != nil { + log.Error("trading_save_portfolio", "user", userID, "err", err) + return chathelper.Reply(ctx, b, chatID, "Could not save portfolio. Try again later.") + } + return chathelper.Reply(ctx, b, chatID, + "Topped up "+FormatVND(amount)+".\nBalance: "+FormatVND(p.Currency["VND"])) +} + +func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update) error { + userID, chatID, ok := senderInfo(update) + if !ok { + return chathelper.Reply(ctx, b, update.Message.Chat.ID, + "Cannot identify user — trading only works in private/group chats with a sender.") + } + args := argsAfterCommand(update.Message.Text) + if len(args) < 2 { + return chathelper.Reply(ctx, b, chatID, "Usage: /trade_buy \nExample: /trade_buy 100 TCB") + } + qty, err := strconv.ParseInt(args[0], 10, 64) + if err != nil || qty <= 0 { + return chathelper.Reply(ctx, b, chatID, "Quantity must be a positive whole number.") + } + + resolved, err := ResolveSymbol(ctx, s.kv, s.prices, args[1]) + if err != nil { + if errors.Is(err, ErrUnknownTicker) { + return chathelper.Reply(ctx, b, chatID, + "Unknown stock ticker \""+strings.ToUpper(args[1])+"\".\n"+s.comingSoonMessage) + } + log.Error("trading_resolve_symbol", "ticker", args[1], "err", err) + return chathelper.Reply(ctx, b, chatID, "Could not look up that ticker. Try again later.") + } + + price, err := s.prices.FetchPrice(ctx, resolved.Symbol) + if err != nil { + if errors.Is(err, ErrNoPrice) { + return chathelper.Reply(ctx, b, chatID, "No price available for "+resolved.Symbol+".") + } + log.Error("trading_fetch_price", "ticker", resolved.Symbol, "err", err) + return chathelper.Reply(ctx, b, chatID, "Could not fetch price. Try again later.") + } + cost := float64(qty) * price + + defer s.locks.Acquire(strconv.FormatInt(userID, 10))() + + p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli()) + if err != nil { + log.Error("trading_load_portfolio", "user", userID, "err", err) + return chathelper.Reply(ctx, b, chatID, "Could not load portfolio. Try again later.") + } + ok, balance := p.DeductCurrency("VND", cost) + if !ok { + return chathelper.Reply(ctx, b, chatID, + "Insufficient VND. Need "+FormatVND(cost)+", have "+FormatVND(balance)+".") + } + p.AddAsset(resolved.Symbol, qty) + if err := SavePortfolio(ctx, s.kv, userID, p); err != nil { + log.Error("trading_save_portfolio", "user", userID, "err", err) + return chathelper.Reply(ctx, b, chatID, "Could not save portfolio. Try again later.") + } + return chathelper.Reply(ctx, b, chatID, + "Bought "+FormatStock(float64(qty))+" "+resolved.Symbol+ + " @ "+FormatVND(price)+"\nCost: "+FormatVND(cost)) +} + +func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Update) error { + userID, chatID, ok := senderInfo(update) + if !ok { + return chathelper.Reply(ctx, b, update.Message.Chat.ID, + "Cannot identify user — trading only works in private/group chats with a sender.") + } + args := argsAfterCommand(update.Message.Text) + if len(args) < 2 { + return chathelper.Reply(ctx, b, chatID, "Usage: /trade_sell \nExample: /trade_sell 100 TCB") + } + qty, err := strconv.ParseInt(args[0], 10, 64) + if err != nil || qty <= 0 { + return chathelper.Reply(ctx, b, chatID, "Quantity must be a positive whole number.") + } + + // Resolve + fetch price BEFORE taking the per-user lock. Mirrors handleBuy: + // keeps the critical section to a fast Get→mutate→Put, and removes any need + // for a rollback path (no in-memory mutation precedes the network call). + resolved, err := ResolveSymbol(ctx, s.kv, s.prices, args[1]) + if err != nil { + if errors.Is(err, ErrUnknownTicker) { + return chathelper.Reply(ctx, b, chatID, + "Unknown stock ticker \""+strings.ToUpper(args[1])+"\".") + } + log.Error("trading_resolve_symbol", "ticker", args[1], "err", err) + return chathelper.Reply(ctx, b, chatID, "Could not look up that ticker. Try again later.") + } + price, err := s.prices.FetchPrice(ctx, resolved.Symbol) + if err != nil { + if errors.Is(err, ErrNoPrice) { + return chathelper.Reply(ctx, b, chatID, "No price available for "+resolved.Symbol+".") + } + log.Error("trading_fetch_price", "ticker", resolved.Symbol, "err", err) + return chathelper.Reply(ctx, b, chatID, "Could not fetch price. Try again later.") + } + + defer s.locks.Acquire(strconv.FormatInt(userID, 10))() + + p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli()) + if err != nil { + log.Error("trading_load_portfolio", "user", userID, "err", err) + return chathelper.Reply(ctx, b, chatID, "Could not load portfolio. Try again later.") + } + ok, held := p.DeductAsset(resolved.Symbol, qty) + if !ok { + return chathelper.Reply(ctx, b, chatID, + "Insufficient "+resolved.Symbol+". You have: "+FormatStock(float64(held))) + } + revenue := float64(qty) * price + p.AddCurrency("VND", revenue) + if err := SavePortfolio(ctx, s.kv, userID, p); err != nil { + log.Error("trading_save_portfolio", "user", userID, "err", err) + return chathelper.Reply(ctx, b, chatID, "Could not save portfolio. Try again later.") + } + return chathelper.Reply(ctx, b, chatID, + "Sold "+FormatStock(float64(qty))+" "+resolved.Symbol+ + " @ "+FormatVND(price)+"\nRevenue: "+FormatVND(revenue)) +} + +func (s *state) handleConvert(ctx context.Context, b *bot.Bot, update *models.Update) error { + if update.Message == nil { + return nil + } + return chathelper.Reply(ctx, b, update.Message.Chat.ID, + "Currency exchange is not available yet.\n"+s.comingSoonMessage) +} + +// handleStats fetches every held ticker's current price (in parallel) and +// renders the portfolio. Read-only — no portfolio mutation, so no keylock. +func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error { + userID, chatID, ok := senderInfo(update) + if !ok { + return chathelper.Reply(ctx, b, update.Message.Chat.ID, + "Cannot identify user — /trade_stats needs a sender.") + } + p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli()) + if err != nil { + log.Error("trading_load_portfolio", "user", userID, "err", err) + return chathelper.Reply(ctx, b, chatID, "Could not load portfolio. Try again later.") + } + + var lines []string + lines = append(lines, "📊 Portfolio Summary\n") + totalValue := 0.0 + + if vnd := p.Currency["VND"]; vnd > 0 { + totalValue += vnd + lines = append(lines, "VND: "+FormatVND(vnd)) + } + + // Filter out zero-balance assets (DeductAsset removes them, but defensive). + type held struct { + symbol string + qty int64 + } + var heldList []held + for sym, qty := range p.Assets { + if qty != 0 { + heldList = append(heldList, held{sym, qty}) + } + } + + if len(heldList) > 0 { + lines = append(lines, "\nStocks:") + // Sequential price fetch (Lambda has no concurrency benefit at small N + // and goroutines complicate test seams). For typical <10 holdings, + // total latency is bounded by sum-of-fetches; KBS responds in <500ms. + for _, h := range heldList { + price, err := s.prices.FetchPrice(ctx, h.symbol) + if err != nil { + lines = append(lines, " "+h.symbol+" x"+FormatStock(float64(h.qty))+" (no price)") + continue + } + val := float64(h.qty) * price + totalValue += val + lines = append(lines, " "+h.symbol+" x"+FormatStock(float64(h.qty))+ + " @ "+FormatVND(price)+" = "+FormatVND(val)) + } + } + + lines = append(lines, "\nTotal value: "+FormatVND(totalValue)) + lines = append(lines, "Invested: "+FormatVND(p.Meta.Invested)) + lines = append(lines, "P&L: "+FormatPnL(totalValue, p.Meta.Invested)) + return chathelper.Reply(ctx, b, chatID, strings.Join(lines, "\n")) +} diff --git a/internal/modules/trading/portfolio.go b/internal/modules/trading/portfolio.go new file mode 100644 index 0000000..2639b3d --- /dev/null +++ b/internal/modules/trading/portfolio.go @@ -0,0 +1,123 @@ +package trading + +import ( + "context" + "errors" + "fmt" + "strconv" + + "github.com/tiennm99/miti99bot-go/internal/storage" +) + +// Portfolio is the per-user trading state. Currency is a map for forward- +// compat with USD/EUR (currently VND-only). Assets is a flat ticker→qty map +// — category lives in the symbol cache, not the portfolio. +type Portfolio struct { + Currency map[string]float64 `json:"currency"` + Assets map[string]int64 `json:"assets"` + Meta PortfolioMeta `json:"meta"` +} + +// PortfolioMeta tracks invested cost basis for P&L. CreatedAt is purely +// informational; carried for parity with JS schema. +type PortfolioMeta struct { + Invested float64 `json:"invested"` + CreatedAt int64 `json:"createdAt"` +} + +// NewPortfolio returns an empty starting state. Currency map seeded with VND=0 +// so deductCurrency on a fresh user reports "0 balance" cleanly instead of +// nil-map panics. +func NewPortfolio(now int64) Portfolio { + return Portfolio{ + Currency: map[string]float64{"VND": 0}, + Assets: map[string]int64{}, + Meta: PortfolioMeta{Invested: 0, CreatedAt: now}, + } +} + +func portfolioKey(userID int64) string { + return "user:" + strconv.FormatInt(userID, 10) +} + +// LoadPortfolio reads from KV; returns an empty portfolio on first-time use. +// Defensively initialises nil maps so callers never need a nil check. +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: + // Repair any nils from older / partial saves — defence in depth. + if p.Currency == nil { + p.Currency = map[string]float64{"VND": 0} + } else if _, ok := p.Currency["VND"]; !ok { + p.Currency["VND"] = 0 + } + if p.Assets == nil { + p.Assets = map[string]int64{} + } + return p, nil + case errors.Is(err, storage.ErrNotFound): + return NewPortfolio(now), nil + default: + return Portfolio{}, fmt.Errorf("trading: load portfolio %d: %w", userID, err) + } +} + +// SavePortfolio persists the portfolio. +func SavePortfolio(ctx context.Context, kv storage.KVStore, userID int64, p Portfolio) error { + if err := kv.PutJSON(ctx, portfolioKey(userID), p); err != nil { + return fmt.Errorf("trading: save portfolio %d: %w", userID, err) + } + return nil +} + +// AddCurrency credits the currency balance. +func (p *Portfolio) AddCurrency(currency string, amount float64) { + if p.Currency == nil { + p.Currency = map[string]float64{} + } + p.Currency[currency] += amount +} + +// DeductCurrency debits the currency balance. Returns false + the current +// balance when insufficient — caller renders the user-facing error. +func (p *Portfolio) DeductCurrency(currency string, amount float64) (ok bool, balance float64) { + if p.Currency == nil { + p.Currency = map[string]float64{} + } + balance = p.Currency[currency] + if balance < amount { + return false, balance + } + p.Currency[currency] = balance - amount + return true, p.Currency[currency] +} + +// AddAsset credits the share holding. +func (p *Portfolio) AddAsset(symbol string, qty int64) { + if p.Assets == nil { + p.Assets = map[string]int64{} + } + p.Assets[symbol] += qty +} + +// DeductAsset debits the share holding. Returns false + held when caller asks +// for more than they own. Removes the key when balance hits zero so the +// portfolio doesn't accumulate empty entries. +func (p *Portfolio) DeductAsset(symbol string, qty int64) (ok bool, held int64) { + if p.Assets == nil { + p.Assets = map[string]int64{} + } + held = p.Assets[symbol] + if held < qty { + return false, held + } + remaining := held - qty + if remaining == 0 { + delete(p.Assets, symbol) + } else { + p.Assets[symbol] = remaining + } + return true, remaining +} diff --git a/internal/modules/trading/portfolio_test.go b/internal/modules/trading/portfolio_test.go new file mode 100644 index 0000000..7a0f38c --- /dev/null +++ b/internal/modules/trading/portfolio_test.go @@ -0,0 +1,91 @@ +package trading + +import ( + "context" + "testing" + + "github.com/tiennm99/miti99bot-go/internal/storage" +) + +func TestLoadPortfolio_FirstTimeUser(t *testing.T) { + kv := storage.NewMemoryKVStore() + p, err := LoadPortfolio(context.Background(), kv, 42, 1234567890) + if err != nil { + t.Fatalf("LoadPortfolio: %v", err) + } + if p.Currency["VND"] != 0 { + t.Errorf("VND seeded: got %v, want 0", p.Currency["VND"]) + } + if p.Assets == nil { + t.Error("Assets is nil") + } + if p.Meta.CreatedAt != 1234567890 { + t.Errorf("CreatedAt: got %d, want 1234567890", p.Meta.CreatedAt) + } +} + +func TestSaveAndLoadRoundTrip(t *testing.T) { + kv := storage.NewMemoryKVStore() + p, _ := LoadPortfolio(context.Background(), kv, 42, 1) + p.AddCurrency("VND", 5_000_000) + p.AddAsset("TCB", 100) + 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) // CreatedAt should NOT be reset + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.Currency["VND"] != 5_000_000 { + t.Errorf("VND: got %v, want 5000000", got.Currency["VND"]) + } + if got.Assets["TCB"] != 100 { + t.Errorf("TCB: got %d, want 100", got.Assets["TCB"]) + } + if got.Meta.Invested != 5_000_000 { + t.Errorf("Invested: got %v, want 5000000", got.Meta.Invested) + } + if got.Meta.CreatedAt != 1 { + t.Errorf("CreatedAt: got %d, want 1 (load must NOT overwrite existing)", got.Meta.CreatedAt) + } +} + +func TestAddDeductCurrency(t *testing.T) { + p := NewPortfolio(0) + p.AddCurrency("VND", 1000) + p.AddCurrency("VND", 500) + if p.Currency["VND"] != 1500 { + t.Errorf("after add: got %v, want 1500", p.Currency["VND"]) + } + ok, bal := p.DeductCurrency("VND", 600) + if !ok || bal != 900 { + t.Errorf("deduct 600: ok=%v bal=%v, want ok=true bal=900", ok, bal) + } + ok, bal = p.DeductCurrency("VND", 9999) + if ok || bal != 900 { + t.Errorf("deduct over balance: ok=%v bal=%v, want ok=false bal=900 (unchanged)", ok, bal) + } +} + +func TestAddDeductAsset(t *testing.T) { + p := NewPortfolio(0) + p.AddAsset("TCB", 10) + p.AddAsset("TCB", 5) + if p.Assets["TCB"] != 15 { + t.Errorf("TCB after add: got %d, want 15", p.Assets["TCB"]) + } + ok, held := p.DeductAsset("TCB", 3) + if !ok || held != 12 { + t.Errorf("deduct 3: ok=%v held=%v, want ok=true held=12", ok, held) + } + ok, _ = p.DeductAsset("TCB", 999) + if ok { + t.Error("deduct over holdings: should fail") + } + // Final deduction removes key entirely. + p.DeductAsset("TCB", 12) + if _, present := p.Assets["TCB"]; present { + t.Error("zero-balance asset should be removed from map") + } +} diff --git a/internal/modules/trading/prices.go b/internal/modules/trading/prices.go new file mode 100644 index 0000000..c69290a --- /dev/null +++ b/internal/modules/trading/prices.go @@ -0,0 +1,113 @@ +package trading + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "time" +) + +// kbsDefaultURL is the KBS public stock data endpoint base. +const kbsDefaultURL = "https://kbbuddywts.kbsec.com.vn/iis-server/investment/stocks" + +// kbsLookbackDays widens the requested window to absorb weekends and Vietnam +// market holidays — KBS returns the latest bar within the window in [0]. +const kbsLookbackDays = 14 + +// kbsHTTPTimeout caps the price fetch. KBS is generally fast; 10s leaves +// headroom for TLS + DNS on a Lambda cold start. +const kbsHTTPTimeout = 10 * time.Second + +// PriceClient is the KBS price fetcher. Zero value uses the default URL + +// `&{Timeout: kbsHTTPTimeout}` HTTP client; tests inject HTTP + URL. +type PriceClient struct { + HTTP *http.Client + URL string +} + +func (c *PriceClient) httpClient() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return &http.Client{Timeout: kbsHTTPTimeout} +} + +func (c *PriceClient) baseURL() string { + if c.URL != "" { + return c.URL + } + return kbsDefaultURL +} + +// kbsResponse is the slice of the KBS payload we care about. We intentionally +// don't model the full response (open/high/low/volume) — only the latest +// close. The struct still names them so Json doesn't error on unknown fields +// (Go's json decoder ignores them by default). +type kbsResponse struct { + DataDay []kbsBar `json:"data_day"` +} + +type kbsBar struct { + C float64 `json:"c"` // close, already in VND, unscaled +} + +// kbsFormatDate formats t as "DD-MM-YYYY" — KBS's expected query date shape. +func kbsFormatDate(t time.Time) string { + t = t.UTC() + return fmt.Sprintf("%02d-%02d-%04d", t.Day(), int(t.Month()), t.Year()) +} + +// FetchPrice returns the latest VND close for ticker, or ErrNoPrice if KBS +// has no data (unknown ticker, suspended, holiday-only window). Network / +// decode errors are returned wrapped. +func (c *PriceClient) FetchPrice(ctx context.Context, ticker string) (float64, error) { + if ticker == "" { + return 0, errors.New("trading: ticker is empty") + } + now := time.Now().UTC() + edate := kbsFormatDate(now) + sdate := kbsFormatDate(now.Add(-time.Duration(kbsLookbackDays) * 24 * time.Hour)) + + endpoint := c.baseURL() + "/" + url.PathEscape(ticker) + "/data_day" + q := url.Values{} + q.Set("sdate", sdate) + q.Set("edate", edate) + full := endpoint + "?" + q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil) + if err != nil { + return 0, fmt.Errorf("trading: build KBS request: %w", err) + } + req.Header.Set("User-Agent", "Mozilla/5.0 (miti99bot-go)") + + resp, err := c.httpClient().Do(req) + if err != nil { + return 0, fmt.Errorf("trading: KBS request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return 0, ErrNoPrice + } + + var body kbsResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return 0, fmt.Errorf("trading: KBS decode: %w", err) + } + if len(body.DataDay) == 0 { + return 0, ErrNoPrice + } + close := body.DataDay[0].C + if close <= 0 { + return 0, ErrNoPrice + } + return close, nil +} + +// ErrNoPrice means KBS returned no usable price for the ticker — either the +// symbol is unknown, the market hasn't traded recently, or the data was +// invalid. Used by symbol resolution to detect "is this a real ticker". +var ErrNoPrice = errors.New("trading: no price available") diff --git a/internal/modules/trading/prices_test.go b/internal/modules/trading/prices_test.go new file mode 100644 index 0000000..fa82808 --- /dev/null +++ b/internal/modules/trading/prices_test.go @@ -0,0 +1,95 @@ +package trading + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func newTestPriceClient(t *testing.T, handler http.HandlerFunc) (*PriceClient, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return &PriceClient{ + HTTP: &http.Client{Timeout: 2 * time.Second}, + URL: srv.URL, + }, srv +} + +func TestPriceClient_HappyPath(t *testing.T) { + c, _ := newTestPriceClient(t, func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/TCB/data_day") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("sdate") == "" || r.URL.Query().Get("edate") == "" { + t.Errorf("missing sdate/edate: %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data_day":[{"c":24500}, {"c":24300}]}`)) + }) + got, err := c.FetchPrice(context.Background(), "TCB") + if err != nil { + t.Fatalf("FetchPrice: %v", err) + } + if got != 24500 { + t.Errorf("price: got %v, want 24500 (latest bar = data_day[0])", got) + } +} + +func TestPriceClient_NoData_ReturnsErrNoPrice(t *testing.T) { + c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data_day":[]}`)) + }) + _, err := c.FetchPrice(context.Background(), "NOPE") + if !errors.Is(err, ErrNoPrice) { + t.Errorf("got %v, want ErrNoPrice", err) + } +} + +func TestPriceClient_4xx_ReturnsErrNoPrice(t *testing.T) { + c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + _, err := c.FetchPrice(context.Background(), "BADTICKER") + if !errors.Is(err, ErrNoPrice) { + t.Errorf("got %v, want ErrNoPrice", err) + } +} + +func TestPriceClient_NegativeClose_ReturnsErrNoPrice(t *testing.T) { + c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data_day":[{"c":-1}]}`)) + }) + _, err := c.FetchPrice(context.Background(), "WEIRD") + if !errors.Is(err, ErrNoPrice) { + t.Errorf("got %v, want ErrNoPrice", err) + } +} + +func TestPriceClient_EmptyTicker(t *testing.T) { + c := &PriceClient{} + _, err := c.FetchPrice(context.Background(), "") + if err == nil { + t.Error("empty ticker: expected error, got nil") + } +} + +func TestKBSFormatDate(t *testing.T) { + cases := []struct { + in time.Time + want string + }{ + {time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC), "10-05-2026"}, + {time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC), "05-01-2026"}, + {time.Date(2026, 12, 31, 23, 59, 0, 0, time.UTC), "31-12-2026"}, + } + for _, c := range cases { + if got := kbsFormatDate(c.in); got != c.want { + t.Errorf("kbsFormatDate(%v): got %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/modules/trading/symbols.go b/internal/modules/trading/symbols.go new file mode 100644 index 0000000..25fc6e7 --- /dev/null +++ b/internal/modules/trading/symbols.go @@ -0,0 +1,62 @@ +package trading + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/tiennm99/miti99bot-go/internal/storage" +) + +// tickerRe restricts tickers to ASCII alphanumeric, 1-16 chars. Stops +// Cyrillic / unicode-lookalike inputs from amplifying KBS lookups, and +// guards the cache key alphabet (sym:) from oddities. +var tickerRe = regexp.MustCompile(`^[A-Z0-9]{1,16}$`) + +// ResolvedSymbol is the cached entry written under "sym:". Category +// is currently always "stock" — crypto/gold/forex are upstream future-work. +type ResolvedSymbol struct { + Symbol string `json:"symbol"` + Category string `json:"category"` + Label string `json:"label"` +} + +// ErrUnknownTicker means KBS has no price data for the given ticker — i.e. +// the symbol is not a tradeable VN stock as far as our source is concerned. +var ErrUnknownTicker = errors.New("trading: unknown ticker") + +// ResolveSymbol returns the cached ResolvedSymbol if any, otherwise queries +// KBS to validate the ticker and caches the result permanently. Tickers +// don't change; permanent caching is correct. +// +// The empty-input case returns ErrUnknownTicker to keep the caller's branch +// shape simple (one error path covers both empty + unknown). +func ResolveSymbol(ctx context.Context, kv storage.KVStore, prices *PriceClient, ticker string) (ResolvedSymbol, error) { + ticker = strings.ToUpper(strings.TrimSpace(ticker)) + if !tickerRe.MatchString(ticker) { + return ResolvedSymbol{}, ErrUnknownTicker + } + cacheKey := "sym:" + ticker + + var cached ResolvedSymbol + if err := kv.GetJSON(ctx, cacheKey, &cached); err == nil { + return cached, nil + } else if !errors.Is(err, storage.ErrNotFound) { + return ResolvedSymbol{}, fmt.Errorf("trading: cache read %s: %w", ticker, err) + } + + // Cache miss → validate against KBS by attempting a price fetch. + if _, err := prices.FetchPrice(ctx, ticker); err != nil { + if errors.Is(err, ErrNoPrice) { + return ResolvedSymbol{}, ErrUnknownTicker + } + return ResolvedSymbol{}, err + } + + resolved := ResolvedSymbol{Symbol: ticker, Category: "stock", Label: ticker} + // Cache write failure is non-fatal — next call will resolve again. + _ = kv.PutJSON(ctx, cacheKey, resolved) + return resolved, nil +} diff --git a/internal/modules/trading/symbols_test.go b/internal/modules/trading/symbols_test.go new file mode 100644 index 0000000..0c391b8 --- /dev/null +++ b/internal/modules/trading/symbols_test.go @@ -0,0 +1,87 @@ +package trading + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/tiennm99/miti99bot-go/internal/storage" +) + +func TestResolveSymbol_FirstTime_QueriesAndCaches(t *testing.T) { + kv := storage.NewMemoryKVStore() + + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&hits, 1) + _, _ = w.Write([]byte(`{"data_day":[{"c":24500}]}`)) + })) + defer srv.Close() + prices := &PriceClient{URL: srv.URL} + + got, err := ResolveSymbol(context.Background(), kv, prices, "tcb") + if err != nil { + t.Fatalf("ResolveSymbol: %v", err) + } + if got.Symbol != "TCB" || got.Category != "stock" { + t.Errorf("resolved: got %+v, want {TCB stock TCB}", got) + } + if atomic.LoadInt32(&hits) != 1 { + t.Errorf("KBS hits: got %d, want 1", hits) + } + + // Second call should hit the cache, not KBS. + _, err = ResolveSymbol(context.Background(), kv, prices, "TCB") + if err != nil { + t.Fatalf("ResolveSymbol (cached): %v", err) + } + if atomic.LoadInt32(&hits) != 1 { + t.Errorf("KBS hits after cache: got %d, want 1 (cached)", hits) + } +} + +func TestResolveSymbol_Unknown(t *testing.T) { + kv := storage.NewMemoryKVStore() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data_day":[]}`)) + })) + defer srv.Close() + prices := &PriceClient{URL: srv.URL} + + _, err := ResolveSymbol(context.Background(), kv, prices, "NOPE") + if !errors.Is(err, ErrUnknownTicker) { + t.Errorf("got %v, want ErrUnknownTicker", err) + } +} + +func TestResolveSymbol_EmptyInput(t *testing.T) { + _, err := ResolveSymbol(context.Background(), storage.NewMemoryKVStore(), &PriceClient{}, " ") + if !errors.Is(err, ErrUnknownTicker) { + t.Errorf("got %v, want ErrUnknownTicker for empty input", err) + } +} + +func TestResolveSymbol_NormalizesCase(t *testing.T) { + kv := storage.NewMemoryKVStore() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // KBS endpoint should receive the upper-cased ticker. + if !strings.Contains(r.URL.Path, "/FPT/") { + t.Errorf("ticker not upper-cased in URL: %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"data_day":[{"c":120000}]}`)) + })) + defer srv.Close() + prices := &PriceClient{URL: srv.URL} + + got, err := ResolveSymbol(context.Background(), kv, prices, " fpt ") + if err != nil { + t.Fatalf("ResolveSymbol: %v", err) + } + if got.Symbol != "FPT" { + t.Errorf("normalised: got %q, want FPT", got.Symbol) + } +} diff --git a/internal/modules/trading/trading.go b/internal/modules/trading/trading.go new file mode 100644 index 0000000..6c418c9 --- /dev/null +++ b/internal/modules/trading/trading.go @@ -0,0 +1,46 @@ +package trading + +import ( + "github.com/tiennm99/miti99bot-go/internal/modules" +) + +// New is the trading module Factory. Five user-facing commands; no crons. +// (Original miti99bot only has a SQL retention cron, which our KV-only port +// does not implement — keeping commits paper-ledger-only is acceptable.) +func New(deps modules.Deps) modules.Module { + s := newState(deps.KV) + return modules.Module{ + Commands: []modules.Command{ + { + Name: "trade_topup", + Visibility: modules.VisibilityPublic, + Description: "Top up VND to your trading account", + Handler: s.handleTopup, + }, + { + Name: "trade_buy", + Visibility: modules.VisibilityPublic, + Description: "Buy VN stock at market price (qty TICKER)", + Handler: s.handleBuy, + }, + { + Name: "trade_sell", + Visibility: modules.VisibilityPublic, + Description: "Sell VN stock back to VND (qty TICKER)", + Handler: s.handleSell, + }, + { + Name: "trade_convert", + Visibility: modules.VisibilityPublic, + Description: "Currency exchange (coming soon)", + Handler: s.handleConvert, + }, + { + Name: "trade_stats", + Visibility: modules.VisibilityPublic, + Description: "Show portfolio summary with P&L", + Handler: s.handleStats, + }, + }, + } +}