feat(trading): VN stocks paper trading module — 5 commands + price engine

- 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
This commit is contained in:
tiennm99 committed 2026-05-10 03:06:13 +07:00
1 parent c0e179b9b8
commit b9b44bc7b2
10 files changed
+1019

No files matched your search

+59
View File
@@ -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
}
+61
View File
@@ -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)
}
}
}
+282
View File
@@ -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 <amount>\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 <qty> <TICKER>\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 <qty> <TICKER>\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"))
}
+123
View File
@@ -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
}
@@ -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")
}
}
+113
View File
@@ -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")
+95
View File
@@ -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)
}
}
}
+62
View File
@@ -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:<TICKER>) from oddities.
var tickerRe = regexp.MustCompile(`^[A-Z0-9]{1,16}$`)
// ResolvedSymbol is the cached entry written under "sym:<TICKER>". 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
}
+87
View File
@@ -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)
}
}
+46
View File
@@ -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,
},
},
}
}