feat(commands): simplify portfolio command names

This commit is contained in:
2026-07-01 10:53:52 +07:00
parent b86aca6b1d
commit d90af97d55
14 changed files with 165 additions and 78 deletions
+2 -2
View File
@@ -60,8 +60,8 @@ func TestFactoriesIncludesExpectedModules(t *testing.T) {
t.Fatalf("Build selected modules: %v", err)
}
for _, name := range []string{
"gold_price", "gold_topup", "gold_buy", "gold_sell", "gold_stats",
"coin_price", "coin_topup", "coin_buy", "coin_sell", "coin_stats",
"gold_price", "gold_topup", "gold_buy", "gold_sell", "gold_portfolio",
"coin_price", "coin_topup", "coin_buy", "coin_sell", "coin_portfolio",
"wc", "wc_this_week", "wc_subscribe", "wc_unsubscribe",
} {
if _, ok := reg.AllCommands[name]; !ok {
+3 -3
View File
@@ -32,13 +32,13 @@ func New(deps modules.Deps) modules.Module {
{
Name: "coin_sell",
Visibility: modules.VisibilityPublic,
Description: "Sell coin back to USD amount",
Description: "Sell coin for a USD amount",
Handler: s.handleSell,
},
{
Name: "coin_stats",
Name: "coin_portfolio",
Visibility: modules.VisibilityPublic,
Description: "Show coin account summary with P&L",
Description: "Show coin portfolio with P&L",
Handler: s.handleStats,
},
},
+3 -3
View File
@@ -72,7 +72,7 @@ func TestModuleRegistersExpectedCommands(t *testing.T) {
for _, cmd := range mod.Commands {
got[cmd.Name] = true
}
for _, name := range []string{"coin_price", "coin_topup", "coin_buy", "coin_sell", "coin_stats"} {
for _, name := range []string{"coin_price", "coin_topup", "coin_buy", "coin_sell", "coin_portfolio"} {
if !got[name] {
t.Fatalf("missing command %s", name)
}
@@ -297,7 +297,7 @@ func TestStatsWithAndWithoutPrice(t *testing.T) {
_ = s.handleTopup(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_topup 1000"))
_ = s.handleBuy(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_buy 500 BTC"))
rb.Reset()
if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_stats")); err != nil {
if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_portfolio")); err != nil {
t.Fatalf("handleStats: %v", err)
}
text := rb.LastSent().Text()
@@ -308,7 +308,7 @@ func TestStatsWithAndWithoutPrice(t *testing.T) {
}
s.prices = fakePriceFetcher{err: ErrNoCoinPrice}
rb.Reset()
if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_stats")); err != nil {
if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_portfolio")); err != nil {
t.Fatalf("handleStats no price: %v", err)
}
rb.AssertSentText(t, "price unavailable")
+1 -1
View File
@@ -15,7 +15,7 @@ import (
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
userID, ok := senderInfo(update)
if !ok {
return chathelper.Reply(ctx, b, update.Message, "Cannot identify user - /coin_stats needs a sender.")
return chathelper.Reply(ctx, b, update.Message, "Cannot identify user - /coin_portfolio needs a sender.")
}
p, err := LoadPortfolio(ctx, s.store, userID, s.now().UnixMilli())
if err != nil {
@@ -11,7 +11,7 @@ import (
// blockingPriceFetcher simulates an upstream that never responds: it blocks
// until the fetch context is cancelled, then returns its error. This is the
// exact failure that made /coin_stats (and /stock_stats) time out — the fetch
// exact failure that made /coin_portfolio (and /stock_portfolio) time out — the fetch
// must not be allowed to consume the budget the reply needs.
type blockingPriceFetcher struct{}
@@ -44,7 +44,7 @@ func TestHandleStatsDeliversReplyWhenUpstreamHangs(t *testing.T) {
statsCtx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
start := time.Now()
if err := s.handleStats(statsCtx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_stats")); err != nil {
if err := s.handleStats(statsCtx, rb.Bot, testutil.NewPrivateMessage(7, "/coin_portfolio")); err != nil {
t.Fatalf("handleStats returned error (reply not delivered): %v", err)
}
elapsed := time.Since(start)
+3 -3
View File
@@ -13,7 +13,7 @@ func New(deps modules.Deps) modules.Module {
{
Name: "gold_price",
Visibility: modules.VisibilityPublic,
Description: "Show current gold spot price (USD & VND)",
Description: "Show current SJC gold buy/sell price",
Handler: s.handlePrice,
},
{
@@ -35,9 +35,9 @@ func New(deps modules.Deps) modules.Module {
Handler: s.handleSell,
},
{
Name: "gold_stats",
Name: "gold_portfolio",
Visibility: modules.VisibilityPublic,
Description: "Show gold account summary with P&L",
Description: "Show gold portfolio with P&L",
Handler: s.handleStats,
},
},
+1 -1
View File
@@ -171,7 +171,7 @@ 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 - /gold_stats needs a sender.")
"Cannot identify user - /gold_portfolio needs a sender.")
}
p, err := LoadPortfolio(ctx, s.store, userID, s.now().UnixMilli())
if err != nil {
+3 -3
View File
@@ -62,7 +62,7 @@ func TestModuleRegistersExpectedCommands(t *testing.T) {
for _, cmd := range mod.Commands {
got[cmd.Name] = true
}
for _, name := range []string{"gold_price", "gold_topup", "gold_buy", "gold_sell", "gold_stats"} {
for _, name := range []string{"gold_price", "gold_topup", "gold_buy", "gold_sell", "gold_portfolio"} {
if !got[name] {
t.Fatalf("missing command %s", name)
}
@@ -155,7 +155,7 @@ func TestStatsWithAndWithoutPrice(t *testing.T) {
_ = s.handleTopup(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_topup 5000000"))
_ = s.handleBuy(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_buy 1"))
rb.Reset()
if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_stats")); err != nil {
if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_portfolio")); err != nil {
t.Fatalf("stats: %v", err)
}
text := rb.LastSent().Text()
@@ -166,7 +166,7 @@ func TestStatsWithAndWithoutPrice(t *testing.T) {
}
s.prices = fakePriceFetcher{err: ErrNoGoldPrice}
rb.Reset()
if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_stats")); err != nil {
if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_portfolio")); err != nil {
t.Fatalf("stats no price: %v", err)
}
rb.AssertSentText(t, "Price: no price")
+2 -4
View File
@@ -1,7 +1,5 @@
// 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 stock is a paper-stock module for VN stocks. It keeps a per-user
// live portfolio in KV and prices positions at the current market price.
package stock
import (
+39 -24
View File
@@ -19,11 +19,10 @@ import (
// prefixes/partitions). PriceClient is reused across calls; nowFn allows
// tests to inject a deterministic clock for portfolio CreatedAt.
type state struct {
store Store
prices *PriceClient
locks keylock.Map
nowFn func() time.Time
comingSoonMessage string // exposed for tests / future i18n
store Store
prices *PriceClient
locks keylock.Map
nowFn func() time.Time
}
func (s *state) now() time.Time {
@@ -36,9 +35,8 @@ func (s *state) now() time.Time {
// newState builds the default state used by the module factory.
func newState(store Store) *state {
return &state{
store: store,
prices: &PriceClient{},
comingSoonMessage: "Crypto, gold & currency exchange coming soon!",
store: store,
prices: &PriceClient{},
}
}
@@ -69,6 +67,31 @@ func argsAfterCommand(text string) []string {
return parts[1:]
}
func (s *state) handlePrice(ctx context.Context, b *bot.Bot, update *models.Update) error {
args := argsAfterCommand(update.Message.Text)
if len(args) != 1 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_price <TICKER>\nExample: /stock_price TCB")
}
symbol, err := normalizeStockSymbol(args[0])
if err != nil {
if errors.Is(err, ErrUnknownTicker) {
return chathelper.Reply(ctx, b, update.Message, "Unknown stock ticker \""+strings.ToUpper(args[0])+"\".")
}
return chathelper.Reply(ctx, b, update.Message, "Could not parse that ticker. Try again later.")
}
fetchCtx, cancel := chathelper.FetchContext(ctx)
defer cancel()
price, err := s.prices.FetchPrice(fetchCtx, symbol)
if err != nil {
if errors.Is(err, ErrNoPrice) {
return chathelper.Reply(ctx, b, update.Message, "No price available for "+symbol+".")
}
log.Error("stock_fetch_price", "ticker", symbol, "err", err)
return chathelper.Reply(ctx, b, update.Message, "Could not fetch price. Try again later.")
}
return chathelper.Reply(ctx, b, update.Message, symbol+" price: "+FormatVND(price))
}
func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Update) error {
userID, ok := senderInfo(update)
if !ok {
@@ -120,7 +143,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
if err != nil {
if errors.Is(err, ErrUnknownTicker) {
return chathelper.Reply(ctx, b, update.Message,
"Unknown stock ticker \""+strings.ToUpper(args[1])+"\".\n"+s.comingSoonMessage)
"Unknown stock ticker \""+strings.ToUpper(args[1])+"\".")
}
return chathelper.Reply(ctx, b, update.Message, "Could not parse that ticker. Try again later.")
}
@@ -217,7 +240,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
"\nRemaining: "+FormatVND(p.Currency["VND"]))
}
func (s *state) handleIncomeStock(ctx context.Context, b *bot.Bot, update *models.Update) error {
func (s *state) handleBonus(ctx context.Context, b *bot.Bot, update *models.Update) error {
userID, ok := senderInfo(update)
if !ok {
return chathelper.Reply(ctx, b, update.Message,
@@ -226,7 +249,7 @@ func (s *state) handleIncomeStock(ctx context.Context, b *bot.Bot, update *model
args := argsAfterCommand(update.Message.Text)
if len(args) < 2 {
return chathelper.Reply(ctx, b, update.Message,
"Usage: /stock_income_stock <qty> <TICKER>\nExample: /stock_income_stock 200 TCX")
"Usage: /stock_bonus <qty> <TICKER>\nExample: /stock_bonus 200 TCX")
}
qty, err := strconv.ParseInt(args[0], 10, 64)
if err != nil || qty <= 0 {
@@ -252,7 +275,7 @@ func (s *state) handleIncomeStock(ctx context.Context, b *bot.Bot, update *model
held := p.Assets[symbol]
if held == 0 {
return chathelper.Reply(ctx, b, update.Message,
"You don't hold any "+symbol+" to receive a stock dividend.")
"You don't hold any "+symbol+" to receive bonus shares.")
}
p.AddAsset(symbol, qty)
if err := SavePortfolio(ctx, s.store, userID, p); err != nil {
@@ -260,11 +283,11 @@ func (s *state) handleIncomeStock(ctx context.Context, b *bot.Bot, update *model
return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.")
}
return chathelper.Reply(ctx, b, update.Message,
"Stock dividend: +"+FormatStock(float64(qty))+" "+symbol+
"Bonus shares: +"+FormatStock(float64(qty))+" "+symbol+
"\nHolding: "+FormatStock(float64(held))+" → "+FormatStock(float64(p.Assets[symbol])))
}
func (s *state) handleIncomeVND(ctx context.Context, b *bot.Bot, update *models.Update) error {
func (s *state) handleDividend(ctx context.Context, b *bot.Bot, update *models.Update) error {
userID, ok := senderInfo(update)
if !ok {
return chathelper.Reply(ctx, b, update.Message,
@@ -273,7 +296,7 @@ func (s *state) handleIncomeVND(ctx context.Context, b *bot.Bot, update *models.
args := argsAfterCommand(update.Message.Text)
if len(args) < 2 {
return chathelper.Reply(ctx, b, update.Message,
"Usage: /stock_income_vnd <amount_per_share> <TICKER>\nExample: /stock_income_vnd 1500 TCX")
"Usage: /stock_dividend <amount_per_share> <TICKER>\nExample: /stock_dividend 1500 TCX")
}
amountPerShare, err := strconv.ParseFloat(args[0], 64)
if err != nil || amountPerShare <= 0 {
@@ -313,21 +336,13 @@ func (s *state) handleIncomeVND(ctx context.Context, b *bot.Bot, update *models.
"\nRemaining: "+FormatVND(p.Currency["VND"]))
}
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,
"Currency exchange is not available yet.\n"+s.comingSoonMessage)
}
// handleStats fetches current prices for held tickers 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, ok := senderInfo(update)
if !ok {
return chathelper.Reply(ctx, b, update.Message,
"Cannot identify user — /stock_stats needs a sender.")
"Cannot identify user — /stock_portfolio needs a sender.")
}
p, err := LoadPortfolio(ctx, s.store, userID, s.now().UnixMilli())
if err != nil {
+76
View File
@@ -0,0 +1,76 @@
package stock
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/testutil"
)
func TestModuleRegistersExpectedCommands(t *testing.T) {
mod := New(modDepsForTest())
got := map[string]bool{}
for _, cmd := range mod.Commands {
got[cmd.Name] = true
}
for _, name := range []string{
"stock_price",
"stock_topup",
"stock_buy",
"stock_sell",
"stock_bonus",
"stock_dividend",
"stock_portfolio",
} {
if !got[name] {
t.Fatalf("missing command %s", name)
}
}
}
func TestHandlePrice(t *testing.T) {
priceSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
if r.URL.Path != "/stock/TCB" {
t.Errorf("path = %q, want /stock/TCB", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"matchedPrice":30000}}`))
}))
t.Cleanup(priceSrv.Close)
s := &state{
store: newStockStore(),
prices: &PriceClient{HTTP: priceSrv.Client(), URL: priceSrv.URL},
nowFn: func() time.Time { return time.UnixMilli(123) },
}
rb := testutil.NewRecordingBot(t)
if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_price tcb")); err != nil {
t.Fatalf("handlePrice: %v", err)
}
if got := rb.LastSent().Text(); !strings.Contains(got, "TCB price: 30.000 VND") {
t.Fatalf("price reply = %q", got)
}
}
func TestHandlePriceUsage(t *testing.T) {
s := &state{prices: &PriceClient{}}
rb := testutil.NewRecordingBot(t)
if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_price")); err != nil {
t.Fatalf("handlePrice: %v", err)
}
rb.AssertSentText(t, "Usage: /stock_price <TICKER>")
}
func modDepsForTest() modules.Deps {
return modules.Deps{Store: storage.NewMemoryProvider().Collection("stock")}
}
+1 -1
View File
@@ -53,7 +53,7 @@ func TestHandleStats_UsesSSIBatchPrices(t *testing.T) {
nowFn: func() time.Time { return now },
}
rb := testutil.NewRecordingBot(t)
if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_stats")); err != nil {
if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_portfolio")); err != nil {
t.Fatalf("handleStats: %v", err)
}
+14 -16
View File
@@ -5,13 +5,17 @@ import (
"github.com/tiennm99/miti99bot/internal/storage"
)
// 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.)
// New is the stock module Factory. Seven user-facing commands.
func New(deps modules.Deps) modules.Module {
s := newState(storage.Typed[Portfolio](deps.Store))
return modules.Module{
Commands: []modules.Command{
{
Name: "stock_price",
Visibility: modules.VisibilityPublic,
Description: "Show current VN stock price",
Handler: s.handlePrice,
},
{
Name: "stock_topup",
Visibility: modules.VisibilityPublic,
@@ -31,27 +35,21 @@ func New(deps modules.Deps) modules.Module {
Handler: s.handleSell,
},
{
Name: "stock_income_stock",
Name: "stock_bonus",
Visibility: modules.VisibilityPublic,
Description: "Record stock dividend (bonus shares)",
Handler: s.handleIncomeStock,
Description: "Record bonus shares",
Handler: s.handleBonus,
},
{
Name: "stock_income_vnd",
Name: "stock_dividend",
Visibility: modules.VisibilityPublic,
Description: "Record cash dividend (VND per share)",
Handler: s.handleIncomeVND,
Handler: s.handleDividend,
},
{
Name: "stock_convert",
Name: "stock_portfolio",
Visibility: modules.VisibilityPublic,
Description: "Currency exchange (coming soon)",
Handler: s.handleConvert,
},
{
Name: "stock_stats",
Visibility: modules.VisibilityPublic,
Description: "Show portfolio summary with P&L",
Description: "Show stock portfolio with P&L",
Handler: s.handleStats,
},
},
+15 -15
View File
@@ -72,6 +72,10 @@
"command": "wc_unsubscribe",
"description": "Stop the daily World Cup schedule digest"
},
{
"command": "stock_price",
"description": "Show current VN stock price"
},
{
"command": "stock_topup",
"description": "Top up VND to your stock account"
@@ -85,24 +89,20 @@
"description": "Sell VN stock back to VND"
},
{
"command": "stock_income_stock",
"description": "Record stock dividend bonus shares"
"command": "stock_bonus",
"description": "Record bonus shares"
},
{
"command": "stock_income_vnd",
"command": "stock_dividend",
"description": "Record cash dividend per share"
},
{
"command": "stock_convert",
"description": "Currency exchange"
},
{
"command": "stock_stats",
"description": "Show portfolio summary with P&L"
"command": "stock_portfolio",
"description": "Show stock portfolio with P&L"
},
{
"command": "gold_price",
"description": "Show current gold spot price (USD & VND)"
"description": "Show current SJC gold buy/sell price"
},
{
"command": "gold_topup",
@@ -117,8 +117,8 @@
"description": "Sell gold back to VND (luong)"
},
{
"command": "gold_stats",
"description": "Show gold account summary with P&L"
"command": "gold_portfolio",
"description": "Show gold portfolio with P&L"
},
{
"command": "coin_price",
@@ -134,11 +134,11 @@
},
{
"command": "coin_sell",
"description": "Sell coin back to USD amount"
"description": "Sell coin for a USD amount"
},
{
"command": "coin_stats",
"description": "Show coin account summary with P&L"
"command": "coin_portfolio",
"description": "Show coin portfolio with P&L"
},
{
"command": "stats",