refactor(stock): rename trading module to stock

Rename the `trading` module to `stock` across code, config, and docs for
naming consistency with the sibling coin/gold modules.

- Module: internal/modules/trading -> internal/modules/stock (package,
  factory, registry key in cmd/server)
- Commands: trade_* -> stock_* (telegram-commands.json)
- Env/SAM params: TRADING_INCOME_EVENTS_* -> STOCK_INCOME_EVENTS_*
- ModulesCSV updated in template.yaml, samconfig.toml, deploy.yml
- Add cmd/migrate-stock-key: idempotent, non-destructive DynamoDB
  partition copy (pk=trading -> pk=stock) with dry-run default

Production DynamoDB data already migrated (8 keys: 2 portfolios + 6
symbol-cache rows); old trading partition retained as rollback snapshot.
This commit is contained in:
tiennm99 committed 2026-06-12 14:52:12 +07:00
1 parent 8b4690df6a
commit e6ad2891dd
24 files changed
+362 -168

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@ 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.
// and keeps its portfolio state separate from the stock module.
func New(deps modules.Deps) modules.Module {
s := newState(deps.KV)
return modules.Module{
+7 -7
View File
@@ -8,7 +8,7 @@ import (
"sync/atomic"
"testing"
stocktrading "github.com/tiennm99/miti99bot/internal/modules/trading"
stockmod "github.com/tiennm99/miti99bot/internal/modules/stock"
"github.com/tiennm99/miti99bot/internal/storage"
)
@@ -241,7 +241,7 @@ func TestUpdatePortfolioFailsFastWhenCASUnsupported(t *testing.T) {
}
}
func TestTradingAndGoldPortfolioKeysDoNotCollide(t *testing.T) {
func TestStockAndGoldPortfolioKeysDoNotCollide(t *testing.T) {
ctx := context.Background()
provider := storage.NewMemoryProvider()
goldPortfolio := NewPortfolio(1)
@@ -249,16 +249,16 @@ func TestTradingAndGoldPortfolioKeysDoNotCollide(t *testing.T) {
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)
stockPortfolio := stockmod.NewPortfolio(1)
stockPortfolio.AddAsset("TCB", 100)
if err := stockmod.SavePortfolio(ctx, provider.For("stock"), 7, stockPortfolio); err != nil {
t.Fatalf("save stock: %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}
want := map[string]bool{"gold:user:7": false, "stock:user:7": false}
for _, key := range keys {
if _, ok := want[key]; ok {
want[key] = true
+1 -1
View File
@@ -87,7 +87,7 @@ func mstatsCommand(deps modules.Deps) modules.Command {
text = fmt.Sprintf("last ping: %s",
time.UnixMilli(last.At).UTC().Format(time.RFC3339))
case err != nil && !errors.Is(err, storage.ErrNotFound):
// User-visible reply mirrors how trading/wordle/loldle handle
// User-visible reply mirrors how stock/wordle/loldle handle
// transient KV failures — returning the error here would leave
// the user with no reply at all.
log.Error("kv get failed", "module", "misc", "command", "mstats", "key", lastPingKey, "err", err)
@@ -1,8 +1,8 @@
// Package trading is a paper-trading module for VN stocks. Per-user
// Package stock is a paper-stock module for VN stocks. Per-user
// portfolio + buy/sell at market price + stats with P&L. SQL-based trade
// history and a retention cron are out of scope today; the current
// implementation keeps only the live portfolio in KV.
package trading
package stock
import (
"math"
@@ -1,4 +1,4 @@
package trading
package stock
import "testing"
@@ -1,4 +1,4 @@
package trading
package stock
import (
"context"
@@ -63,7 +63,7 @@ func senderInfo(update *models.Update) (userID int64, ok bool) {
}
// argsAfterCommand splits the command body into whitespace-separated args.
// "/trade_buy 100 TCB" → ["100", "TCB"]; "/trade_topup" → []
// "/stock_buy 100 TCB" → ["100", "TCB"]; "/stock_topup" → []
func argsAfterCommand(text string) []string {
parts := strings.Fields(text)
if len(parts) <= 1 {
@@ -76,11 +76,11 @@ func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Upda
userID, ok := senderInfo(update)
if !ok {
return chathelper.Reply(ctx, b, update.Message,
"Cannot identify user — trading only works in private/group chats with a sender.")
"Cannot identify user — stock 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: /trade_topup <amount>\nExample: /trade_topup 5000000")
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_topup <amount>\nExample: /stock_topup 5000000")
}
amount, err := strconv.ParseFloat(args[0], 64)
if err != nil || amount <= 0 {
@@ -91,13 +91,13 @@ func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Upda
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
if err != nil {
log.Error("trading_load_portfolio", "user", userID, "err", err)
log.Error("stock_load_portfolio", "user", userID, "err", err)
return chathelper.Reply(ctx, b, update.Message, "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)
log.Error("stock_save_portfolio", "user", userID, "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.")
}
return chathelper.Reply(ctx, b, update.Message,
@@ -108,11 +108,11 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
userID, ok := senderInfo(update)
if !ok {
return chathelper.Reply(ctx, b, update.Message,
"Cannot identify user — trading only works in private/group chats with a sender.")
"Cannot identify user — stock only works in private/group chats with a sender.")
}
args := argsAfterCommand(update.Message.Text)
if len(args) < 2 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /trade_buy <qty> <TICKER>\nExample: /trade_buy 100 TCB")
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_buy <qty> <TICKER>\nExample: /stock_buy 100 TCB")
}
qty, err := strconv.ParseInt(args[0], 10, 64)
if err != nil || qty <= 0 {
@@ -125,7 +125,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
return chathelper.Reply(ctx, b, update.Message,
"Unknown stock ticker \""+strings.ToUpper(args[1])+"\".\n"+s.comingSoonMessage)
}
log.Error("trading_resolve_symbol", "ticker", args[1], "err", err)
log.Error("stock_resolve_symbol", "ticker", args[1], "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not look up that ticker. Try again later.")
}
@@ -134,7 +134,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
if errors.Is(err, ErrNoPrice) {
return chathelper.Reply(ctx, b, update.Message, "No price available for "+resolved.Symbol+".")
}
log.Error("trading_fetch_price", "ticker", resolved.Symbol, "err", err)
log.Error("stock_fetch_price", "ticker", resolved.Symbol, "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not fetch price. Try again later.")
}
cost := float64(qty) * price
@@ -143,7 +143,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
if err != nil {
log.Error("trading_load_portfolio", "user", userID, "err", err)
log.Error("stock_load_portfolio", "user", userID, "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.")
}
ok, balance := p.DeductCurrency("VND", cost)
@@ -153,7 +153,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
}
p.AddAsset(resolved.Symbol, qty)
if err := SavePortfolio(ctx, s.kv, userID, p); err != nil {
log.Error("trading_save_portfolio", "user", userID, "err", err)
log.Error("stock_save_portfolio", "user", userID, "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.")
}
return chathelper.Reply(ctx, b, update.Message,
@@ -166,11 +166,11 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
userID, ok := senderInfo(update)
if !ok {
return chathelper.Reply(ctx, b, update.Message,
"Cannot identify user — trading only works in private/group chats with a sender.")
"Cannot identify user — stock only works in private/group chats with a sender.")
}
args := argsAfterCommand(update.Message.Text)
if len(args) < 2 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /trade_sell <qty> <TICKER>\nExample: /trade_sell 100 TCB")
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_sell <qty> <TICKER>\nExample: /stock_sell 100 TCB")
}
qty, err := strconv.ParseInt(args[0], 10, 64)
if err != nil || qty <= 0 {
@@ -186,7 +186,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
return chathelper.Reply(ctx, b, update.Message,
"Unknown stock ticker \""+strings.ToUpper(args[1])+"\".")
}
log.Error("trading_resolve_symbol", "ticker", args[1], "err", err)
log.Error("stock_resolve_symbol", "ticker", args[1], "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not look up that ticker. Try again later.")
}
price, err := s.prices.FetchPrice(ctx, resolved.Symbol)
@@ -194,7 +194,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
if errors.Is(err, ErrNoPrice) {
return chathelper.Reply(ctx, b, update.Message, "No price available for "+resolved.Symbol+".")
}
log.Error("trading_fetch_price", "ticker", resolved.Symbol, "err", err)
log.Error("stock_fetch_price", "ticker", resolved.Symbol, "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not fetch price. Try again later.")
}
@@ -202,7 +202,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
if err != nil {
log.Error("trading_load_portfolio", "user", userID, "err", err)
log.Error("stock_load_portfolio", "user", userID, "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.")
}
ok, held := p.DeductAsset(resolved.Symbol, qty)
@@ -213,7 +213,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
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)
log.Error("stock_save_portfolio", "user", userID, "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.")
}
return chathelper.Reply(ctx, b, update.Message,
@@ -226,12 +226,12 @@ func (s *state) handleIncomeStock(ctx context.Context, b *bot.Bot, update *model
userID, ok := senderInfo(update)
if !ok {
return chathelper.Reply(ctx, b, update.Message,
"Cannot identify user — trading only works in private/group chats with a sender.")
"Cannot identify user — stock only works in private/group chats with a sender.")
}
args := argsAfterCommand(update.Message.Text)
if len(args) < 2 {
return chathelper.Reply(ctx, b, update.Message,
"Usage: /trade_income_stock <qty> <TICKER>\nExample: /trade_income_stock 200 TCX")
"Usage: /stock_income_stock <qty> <TICKER>\nExample: /stock_income_stock 200 TCX")
}
qty, err := strconv.ParseInt(args[0], 10, 64)
if err != nil || qty <= 0 {
@@ -244,7 +244,7 @@ func (s *state) handleIncomeStock(ctx context.Context, b *bot.Bot, update *model
return chathelper.Reply(ctx, b, update.Message,
"Unknown stock ticker \""+strings.ToUpper(args[1])+"\".")
}
log.Error("trading_resolve_symbol", "ticker", args[1], "err", err)
log.Error("stock_resolve_symbol", "ticker", args[1], "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not look up that ticker. Try again later.")
}
@@ -252,7 +252,7 @@ func (s *state) handleIncomeStock(ctx context.Context, b *bot.Bot, update *model
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
if err != nil {
log.Error("trading_load_portfolio", "user", userID, "err", err)
log.Error("stock_load_portfolio", "user", userID, "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.")
}
held := p.Assets[resolved.Symbol]
@@ -262,7 +262,7 @@ func (s *state) handleIncomeStock(ctx context.Context, b *bot.Bot, update *model
}
p.AddAsset(resolved.Symbol, qty)
if err := SavePortfolio(ctx, s.kv, userID, p); err != nil {
log.Error("trading_save_portfolio", "user", userID, "err", err)
log.Error("stock_save_portfolio", "user", userID, "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.")
}
return chathelper.Reply(ctx, b, update.Message,
@@ -274,12 +274,12 @@ func (s *state) handleIncomeVND(ctx context.Context, b *bot.Bot, update *models.
userID, ok := senderInfo(update)
if !ok {
return chathelper.Reply(ctx, b, update.Message,
"Cannot identify user — trading only works in private/group chats with a sender.")
"Cannot identify user — stock only works in private/group chats with a sender.")
}
args := argsAfterCommand(update.Message.Text)
if len(args) < 2 {
return chathelper.Reply(ctx, b, update.Message,
"Usage: /trade_income_vnd <amount_per_share> <TICKER>\nExample: /trade_income_vnd 1500 TCX")
"Usage: /stock_income_vnd <amount_per_share> <TICKER>\nExample: /stock_income_vnd 1500 TCX")
}
amountPerShare, err := strconv.ParseFloat(args[0], 64)
if err != nil || amountPerShare <= 0 {
@@ -292,7 +292,7 @@ func (s *state) handleIncomeVND(ctx context.Context, b *bot.Bot, update *models.
return chathelper.Reply(ctx, b, update.Message,
"Unknown stock ticker \""+strings.ToUpper(args[1])+"\".")
}
log.Error("trading_resolve_symbol", "ticker", args[1], "err", err)
log.Error("stock_resolve_symbol", "ticker", args[1], "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not look up that ticker. Try again later.")
}
@@ -300,7 +300,7 @@ func (s *state) handleIncomeVND(ctx context.Context, b *bot.Bot, update *models.
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
if err != nil {
log.Error("trading_load_portfolio", "user", userID, "err", err)
log.Error("stock_load_portfolio", "user", userID, "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.")
}
held := p.Assets[resolved.Symbol]
@@ -311,7 +311,7 @@ func (s *state) handleIncomeVND(ctx context.Context, b *bot.Bot, update *models.
total := amountPerShare * float64(held)
p.AddCurrency("VND", total)
if err := SavePortfolio(ctx, s.kv, userID, p); err != nil {
log.Error("trading_save_portfolio", "user", userID, "err", err)
log.Error("stock_save_portfolio", "user", userID, "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.")
}
return chathelper.Reply(ctx, b, update.Message,
@@ -334,11 +334,11 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
userID, ok := senderInfo(update)
if !ok {
return chathelper.Reply(ctx, b, update.Message,
"Cannot identify user — /trade_stats needs a sender.")
"Cannot identify user — /stock_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)
log.Error("stock_load_portfolio", "user", userID, "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.")
}
@@ -1,4 +1,4 @@
package trading
package stock
import (
"context"
@@ -45,13 +45,13 @@ type IncomeEventClient struct {
}
func NewIncomeEventClientFromEnv() *IncomeEventClient {
url := strings.TrimSpace(os.Getenv("TRADING_INCOME_EVENTS_API_URL"))
url := strings.TrimSpace(os.Getenv("STOCK_INCOME_EVENTS_API_URL"))
if url == "" {
url = fireAntIncomeEventsDefaultURL
}
return &IncomeEventClient{
URL: url,
Token: strings.TrimSpace(os.Getenv("TRADING_INCOME_EVENTS_API_TOKEN")),
Token: strings.TrimSpace(os.Getenv("STOCK_INCOME_EVENTS_API_TOKEN")),
}
}
@@ -74,9 +74,9 @@ type fireAntTimescaleMark struct {
}
var (
ErrNoIncomeEvents = errors.New("trading: no income events")
ErrIncomeEventClientNotConfigured = errors.New("trading: income events API not configured")
ErrIncomeEventAuthRequired = errors.New("trading: income events API authentication required")
ErrNoIncomeEvents = errors.New("stock: no income events")
ErrIncomeEventClientNotConfigured = errors.New("stock: income events API not configured")
ErrIncomeEventAuthRequired = errors.New("stock: income events API authentication required")
)
func (c *IncomeEventClient) FetchRecent(ctx context.Context, ticker string, since, until time.Time) ([]IncomeEvent, error) {
@@ -99,7 +99,7 @@ func (c *IncomeEventClient) FetchRecent(ctx context.Context, ticker string, sinc
resp, err := c.httpClient().Do(req)
if err != nil {
return nil, fmt.Errorf("trading: FireAnt request: %w", err)
return nil, fmt.Errorf("stock: FireAnt request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
@@ -117,10 +117,10 @@ func (c *IncomeEventClient) FetchRecent(ctx context.Context, ticker string, sinc
func fireAntMarksURL(baseURL, ticker string, since, until time.Time) (string, error) {
endpoint, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("trading: parse FireAnt URL: %w", err)
return "", fmt.Errorf("stock: parse FireAnt URL: %w", err)
}
if !isSafeFireAntEndpoint(endpoint) {
return "", fmt.Errorf("trading: income events API URL must be https")
return "", fmt.Errorf("stock: income events API URL must be https")
}
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/symbols/" + url.PathEscape(ticker) + "/timescale-marks"
q := endpoint.Query()
@@ -143,7 +143,7 @@ func isSafeFireAntEndpoint(endpoint *url.URL) bool {
func fireAntRequest(ctx context.Context, fullURL, token string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
return nil, fmt.Errorf("trading: build FireAnt request: %w", err)
return nil, fmt.Errorf("stock: build FireAnt request: %w", err)
}
req.Header.Set("User-Agent", "miti99bot")
if token != "" {
@@ -160,12 +160,12 @@ func decodeFireAntMarks(resp *http.Response) ([]fireAntTimescaleMark, error) {
return nil, ErrNoIncomeEvents
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("trading: FireAnt status %d", resp.StatusCode)
return nil, fmt.Errorf("stock: FireAnt status %d", resp.StatusCode)
}
var marks []fireAntTimescaleMark
if err := json.NewDecoder(resp.Body).Decode(&marks); err != nil {
return nil, fmt.Errorf("trading: FireAnt decode: %w", err)
return nil, fmt.Errorf("stock: FireAnt decode: %w", err)
}
return marks, nil
}
@@ -275,7 +275,7 @@ func (s *state) handleIncomeEvents(ctx context.Context, b *bot.Bot, update *mode
userID, ok := senderInfo(update)
if !ok {
return chathelper.Reply(ctx, b, update.Message,
"Cannot identify user - /trade_income_events needs a sender.")
"Cannot identify user - /stock_income_events needs a sender.")
}
args := argsAfterCommand(update.Message.Text)
@@ -288,12 +288,12 @@ func (s *state) handleIncomeEvents(ctx context.Context, b *bot.Bot, update *mode
}
return chathelper.Reply(ctx, b, update.Message, "Unknown stock ticker \""+ticker+"\".")
}
log.Error("trading_income_events_symbols", "user", userID, "err", err)
log.Error("stock_income_events_symbols", "user", userID, "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not load holdings. Try again later.")
}
if len(symbols) == 0 {
return chathelper.Reply(ctx, b, update.Message,
"You don't hold any stocks yet. Usage: /trade_income_events <TICKER>")
"You don't hold any stocks yet. Usage: /stock_income_events <TICKER>")
}
until := s.now().UTC()
@@ -310,12 +310,12 @@ func (s *state) handleIncomeEvents(ctx context.Context, b *bot.Bot, update *mode
}
if errors.Is(err, ErrIncomeEventAuthRequired) {
return chathelper.Reply(ctx, b, update.Message,
"FireAnt income events API requires authentication. Set TRADING_INCOME_EVENTS_API_TOKEN or TRADING_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME.")
"FireAnt income events API requires authentication. Set STOCK_INCOME_EVENTS_API_TOKEN or STOCK_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME.")
}
if errors.Is(err, ErrNoIncomeEvents) {
continue
}
log.Error("trading_fetch_income_events", "ticker", symbol, "err", err)
log.Error("stock_fetch_income_events", "ticker", symbol, "err", err)
failed = append(failed, symbol)
continue
}
@@ -323,7 +323,7 @@ func (s *state) handleIncomeEvents(ctx context.Context, b *bot.Bot, update *mode
}
if notConfigured {
return chathelper.Reply(ctx, b, update.Message,
"Income events API is not configured. Set TRADING_INCOME_EVENTS_API_URL or use the FireAnt default.")
"Income events API is not configured. Set STOCK_INCOME_EVENTS_API_URL or use the FireAnt default.")
}
sort.Slice(all, func(i, j int) bool {
if all[i].DeployDate.Equal(all[j].DeployDate) {
@@ -1,4 +1,4 @@
package trading
package stock
import (
"context"
@@ -128,13 +128,13 @@ func installTradingIncomeEvents(t *testing.T, eventBody string, now time.Time) (
nowFn: func() time.Time { return now },
}
cmd := modules.Command{
Name: "trade_income_events",
Name: "stock_income_events",
Visibility: modules.VisibilityPublic,
Description: "x",
Handler: s.handleIncomeEvents,
}
reg := &modules.Registry{
Modules: []modules.Module{{Name: "trading", Commands: []modules.Command{cmd}}},
Modules: []modules.Module{{Name: "stock", Commands: []modules.Command{cmd}}},
AllCommands: map[string]modules.Command{cmd.Name: cmd},
}
modules.Install(rb.Bot, reg, modules.Auth{})
@@ -146,7 +146,7 @@ func TestHandleIncomeEvents_WithTicker(t *testing.T) {
body := `[{"id":"1","label":"GDKHQ","date":"2026-05-25T00:00:00Z","title":"TCX: 25.5.2026, ngày GDKHQ trả cổ tức bằng cổ phiếu năm 2024 (tỷ lệ 5:1)"}]`
rb, _ := installTradingIncomeEvents(t, body, now)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/trade_income_events TCX"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/stock_income_events TCX"))
got := rb.LastSent().Text()
for _, want := range []string{"Income events from FireAnt", "TCX - 25/05/2026", "trả cổ tức"} {
if !strings.Contains(got, want) {
@@ -165,7 +165,7 @@ func TestHandleIncomeEvents_UsesHoldingsWhenTickerMissing(t *testing.T) {
t.Fatalf("SavePortfolio: %v", err)
}
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/trade_income_events"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/stock_income_events"))
got := rb.LastSent().Text()
if !strings.Contains(got, "TCX - 25/05/2026: Holding event") {
t.Errorf("expected holding event reply; got:\n%s", got)
@@ -1,4 +1,4 @@
package trading
package stock
import (
"context"
@@ -9,7 +9,7 @@ import (
"github.com/tiennm99/miti99bot/internal/storage"
)
// Portfolio is the per-user trading state. Currency is a map for forward-
// Portfolio is the per-user stock 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 {
@@ -60,14 +60,14 @@ func LoadPortfolio(ctx context.Context, kv storage.KVStore, userID int64, now in
case errors.Is(err, storage.ErrNotFound):
return NewPortfolio(now), nil
default:
return Portfolio{}, fmt.Errorf("trading: load portfolio %d: %w", userID, err)
return Portfolio{}, fmt.Errorf("stock: 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 fmt.Errorf("stock: save portfolio %d: %w", userID, err)
}
return nil
}
@@ -1,4 +1,4 @@
package trading
package stock
import (
"context"
@@ -1,4 +1,4 @@
package trading
package stock
import (
"context"
@@ -29,7 +29,7 @@ type PriceClient struct {
URL string
// defaultClient memoises the zero-value HTTP fallback so the transport's
// connection pool survives across FetchPrice calls — /trade_stats fans
// connection pool survives across FetchPrice calls — /stock_stats fans
// out per held ticker, and a fresh client per call means a fresh TLS
// handshake per ticker.
defaultOnce sync.Once
@@ -76,7 +76,7 @@ func kbsFormatDate(t time.Time) string {
// 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")
return 0, errors.New("stock: ticker is empty")
}
now := time.Now().UTC()
edate := kbsFormatDate(now)
@@ -90,13 +90,13 @@ func (c *PriceClient) FetchPrice(ctx context.Context, ticker string) (float64, e
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
if err != nil {
return 0, fmt.Errorf("trading: build KBS request: %w", err)
return 0, fmt.Errorf("stock: build KBS request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (miti99bot)")
resp, err := c.httpClient().Do(req)
if err != nil {
return 0, fmt.Errorf("trading: KBS request: %w", err)
return 0, fmt.Errorf("stock: KBS request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
@@ -106,7 +106,7 @@ func (c *PriceClient) FetchPrice(ctx context.Context, ticker string) (float64, e
var body kbsResponse
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return 0, fmt.Errorf("trading: KBS decode: %w", err)
return 0, fmt.Errorf("stock: KBS decode: %w", err)
}
if len(body.DataDay) == 0 {
return 0, ErrNoPrice
@@ -121,4 +121,4 @@ func (c *PriceClient) FetchPrice(ctx context.Context, ticker string) (float64, e
// 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")
var ErrNoPrice = errors.New("stock: no price available")
@@ -1,4 +1,4 @@
package trading
package stock
import (
"context"
@@ -1,10 +1,10 @@
package trading
package stock
import (
"github.com/tiennm99/miti99bot/internal/modules"
)
// New is the trading module Factory. Five user-facing commands; no crons.
// New is the stock 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 {
@@ -12,49 +12,49 @@ func New(deps modules.Deps) modules.Module {
return modules.Module{
Commands: []modules.Command{
{
Name: "trade_topup",
Name: "stock_topup",
Visibility: modules.VisibilityPublic,
Description: "Top up VND to your trading account",
Description: "Top up VND to your stock account",
Handler: s.handleTopup,
},
{
Name: "trade_buy",
Name: "stock_buy",
Visibility: modules.VisibilityPublic,
Description: "Buy VN stock at market price (qty TICKER)",
Handler: s.handleBuy,
},
{
Name: "trade_sell",
Name: "stock_sell",
Visibility: modules.VisibilityPublic,
Description: "Sell VN stock back to VND (qty TICKER)",
Handler: s.handleSell,
},
{
Name: "trade_income_stock",
Name: "stock_income_stock",
Visibility: modules.VisibilityPublic,
Description: "Record stock dividend (bonus shares)",
Handler: s.handleIncomeStock,
},
{
Name: "trade_income_vnd",
Name: "stock_income_vnd",
Visibility: modules.VisibilityPublic,
Description: "Record cash dividend (VND per share)",
Handler: s.handleIncomeVND,
},
{
Name: "trade_income_events",
Name: "stock_income_events",
Visibility: modules.VisibilityPublic,
Description: "Check recent income events from FireAnt",
Handler: s.handleIncomeEvents,
},
{
Name: "trade_convert",
Name: "stock_convert",
Visibility: modules.VisibilityPublic,
Description: "Currency exchange (coming soon)",
Handler: s.handleConvert,
},
{
Name: "trade_stats",
Name: "stock_stats",
Visibility: modules.VisibilityPublic,
Description: "Show portfolio summary with P&L",
Handler: s.handleStats,
@@ -1,4 +1,4 @@
package trading
package stock
import (
"context"
@@ -25,7 +25,7 @@ type ResolvedSymbol struct {
// 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")
var ErrUnknownTicker = errors.New("stock: unknown ticker")
// ResolveSymbol returns the cached ResolvedSymbol if any, otherwise queries
// KBS to validate the ticker and caches the result permanently. Tickers
@@ -44,7 +44,7 @@ func ResolveSymbol(ctx context.Context, kv storage.KVStore, prices *PriceClient,
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)
return ResolvedSymbol{}, fmt.Errorf("stock: cache read %s: %w", ticker, err)
}
// Cache miss → validate against KBS by attempting a price fetch.
@@ -1,4 +1,4 @@
package trading
package stock
import (
"context"