mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-10 04:20:01 +00:00
feat(commands): simplify portfolio command names
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user