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:
2026-06-12 14:52:12 +07:00
parent 53578d888b
commit dce5bcf12c
24 changed files with 362 additions and 168 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
# Telegram user IDs are public (visible to anyone the bot DMs), so
# they live in this committed workflow rather than a secret.
# Keep this module list in sync with samconfig.toml's ModulesCSV.
OVERRIDES="CronSharedSecret=$CRON_SECRET BotOwnerID=1064111334 AdminUserIDs=1064111334 ModulesCSV=util,misc,wordle,loldle,lolschedule,twentyq,trading,stats,gold,coin"
OVERRIDES="CronSharedSecret=$CRON_SECRET BotOwnerID=1064111334 AdminUserIDs=1064111334 ModulesCSV=util,misc,wordle,loldle,lolschedule,twentyq,stock,stats,gold,coin"
if [ -n "$ALERT_EMAIL" ]; then
OVERRIDES="$OVERRIDES AlertEmail=$ALERT_EMAIL"
fi
+1 -1
View File
@@ -12,7 +12,7 @@ Plug-n-play Telegram bot framework in Go. Runs on AWS Lambda + DynamoDB + EventB
| `loldle` | League-of-Legends "guess the champion" |
| `lolschedule` | Pro-match schedule + daily push |
| `twentyq` | 20-questions game (requires Gemini API key) |
| `trading` | VN-stocks paper trading |
| `stock` | VN-stocks paper trading |
| `gold` | Gold paper trading (opt-in; spot XAU converted to VND per luong) |
| `coin` | Crypto paper trading in USD (Binance -> Coinbase -> CoinGecko price fallback) |
| `stats` | `/stats` (top commands), `/stats users`, `/stats user <name>`, `/stats cmd <name>` |
+9 -9
View File
@@ -73,35 +73,35 @@
"description": "Show your twentyq stats"
},
{
"command": "trade_topup",
"description": "Top up VND to your trading account"
"command": "stock_topup",
"description": "Top up VND to your stock account"
},
{
"command": "trade_buy",
"command": "stock_buy",
"description": "Buy VN stock at market price"
},
{
"command": "trade_sell",
"command": "stock_sell",
"description": "Sell VN stock back to VND"
},
{
"command": "trade_income_stock",
"command": "stock_income_stock",
"description": "Record stock dividend bonus shares"
},
{
"command": "trade_income_vnd",
"command": "stock_income_vnd",
"description": "Record cash dividend per share"
},
{
"command": "trade_income_events",
"command": "stock_income_events",
"description": "Check recent income events"
},
{
"command": "trade_convert",
"command": "stock_convert",
"description": "Currency exchange"
},
{
"command": "trade_stats",
"command": "stock_stats",
"description": "Show portfolio summary with P&L"
},
{
+118
View File
@@ -0,0 +1,118 @@
// Command migrate-stock-key copies a module's DynamoDB partition to a new
// partition key. It exists for the one-time "trading" -> "stock" module
// rename: the framework derives the DynamoDB partition key (pk) from the
// module name, so renaming the module orphans every row written under the old
// pk. This tool re-homes those rows under the new pk.
//
// Behaviour:
// - Reuses the runtime KVStore (List -> Get -> Put), so the stored JSON
// bytes round-trip unchanged; only pk changes.
// - Idempotent: a key already present in the destination partition is left
// untouched, so a re-run never clobbers data the renamed module has
// written since the first migration.
// - Non-destructive: source rows are kept in place (free-tier cost is
// negligible and they double as a rollback snapshot).
//
// Defaults to a dry run; pass -apply to perform writes.
//
// Usage:
//
// DYNAMODB_TABLE=miti99bot-prod go run ./cmd/migrate-stock-key # dry run
// DYNAMODB_TABLE=miti99bot-prod go run ./cmd/migrate-stock-key -apply # migrate
// DYNAMODB_LOCAL_URL=http://localhost:8001 ... (point at DynamoDB Local)
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"github.com/tiennm99/miti99bot/internal/storage"
)
func main() {
from := flag.String("from", "trading", "source DynamoDB partition key (old module name)")
to := flag.String("to", "stock", "destination DynamoDB partition key (new module name)")
apply := flag.Bool("apply", false, "perform writes; without it the tool only reports what would change")
flag.Parse()
if err := run(context.Background(), *from, *to, *apply); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func run(ctx context.Context, from, to string, apply bool) error {
table := os.Getenv("DYNAMODB_TABLE")
if table == "" {
return errors.New("DYNAMODB_TABLE is required")
}
if from == to {
return fmt.Errorf("-from and -to must differ (both %q)", from)
}
client, err := storage.NewDynamoDBClient(ctx, storage.DynamoDBEndpointFromEnv())
if err != nil {
return err
}
provider := storage.NewDynamoDBProvider(client, table)
mode := "DRY RUN"
if apply {
mode = "APPLY"
}
fmt.Printf("[%s] table=%s %s -> %s\n", mode, table, from, to)
return migrate(ctx, provider.For(from), provider.For(to), from, to, apply, os.Stdout)
}
// migrate copies every key from src to dst, preserving the stored bytes.
// Keys already present in dst are skipped (idempotent re-runs never clobber
// fresher destination state). Source entries are never deleted. When apply is
// false it reports the planned copies without writing. Decoupled from
// DynamoDB/env so it is unit-testable against any KVStore pair (the in-memory
// provider prefixes keys by module name exactly like DynamoDB partitions).
func migrate(ctx context.Context, src, dst storage.KVStore, from, to string, apply bool, out io.Writer) error {
keys, err := src.List(ctx, "")
if err != nil {
return fmt.Errorf("list source partition %q: %w", from, err)
}
fmt.Fprintf(out, " %d source keys\n", len(keys))
var copied, skipped int
for _, key := range keys {
// Skip keys the destination already has — the renamed module may have
// written fresher state; the migration must never overwrite it.
if _, err := dst.Get(ctx, key); err == nil {
fmt.Fprintf(out, " skip %s (already in %s)\n", key, to)
skipped++
continue
} else if !errors.Is(err, storage.ErrNotFound) {
return fmt.Errorf("probe destination %s/%s: %w", to, key, err)
}
if !apply {
fmt.Fprintf(out, " copy %s (would migrate)\n", key)
copied++
continue
}
raw, err := src.Get(ctx, key)
if err != nil {
return fmt.Errorf("read source %s/%s: %w", from, key, err)
}
if err := dst.Put(ctx, key, raw); err != nil {
return fmt.Errorf("write destination %s/%s: %w", to, key, err)
}
fmt.Fprintf(out, " copy %s (%d bytes)\n", key, len(raw))
copied++
}
verb := "would copy"
if apply {
verb = "copied"
}
fmt.Fprintf(out, "done: %s %d, skipped %d (source rows left in place)\n", verb, copied, skipped)
return nil
}
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"bytes"
"context"
"errors"
"testing"
"github.com/tiennm99/miti99bot/internal/storage"
)
// The in-memory provider key-prefixes by module name, mirroring how the
// DynamoDB provider partitions by pk — so it faithfully exercises the
// cross-partition copy without needing a live table.
func TestMigrate_CopiesSkipsAndPreservesSource(t *testing.T) {
ctx := context.Background()
provider := storage.NewMemoryProvider()
src := provider.For("trading")
dst := provider.For("stock")
mustPut(t, ctx, src, "user:7", `{"currency":{"VND":1500000}}`)
mustPut(t, ctx, src, "user:42", `{"currency":{"VND":1}}`)
mustPut(t, ctx, src, "sym:TCB", `{"symbol":"TCB"}`)
// Destination already holds a *fresher* user:42 — migration must not clobber it.
mustPut(t, ctx, dst, "user:42", `{"currency":{"VND":99999}}`)
// Dry run writes nothing new.
var dry bytes.Buffer
if err := migrate(ctx, src, dst, "trading", "stock", false, &dry); err != nil {
t.Fatalf("dry run: %v", err)
}
if got := getString(t, ctx, dst, "user:42"); got != `{"currency":{"VND":99999}}` {
t.Fatalf("dry run mutated dst user:42: %q", got)
}
if _, err := dst.Get(ctx, "user:7"); !errors.Is(err, storage.ErrNotFound) {
t.Fatalf("dry run created dst user:7, want ErrNotFound, got %v", err)
}
// Apply.
var live bytes.Buffer
if err := migrate(ctx, src, dst, "trading", "stock", true, &live); err != nil {
t.Fatalf("apply: %v", err)
}
// New keys copied byte-for-byte.
if got := getString(t, ctx, dst, "user:7"); got != `{"currency":{"VND":1500000}}` {
t.Errorf("user:7 not copied correctly: %q", got)
}
if got := getString(t, ctx, dst, "sym:TCB"); got != `{"symbol":"TCB"}` {
t.Errorf("sym:TCB not copied correctly: %q", got)
}
// Pre-existing destination key left untouched (skip, not overwrite).
if got := getString(t, ctx, dst, "user:42"); got != `{"currency":{"VND":99999}}` {
t.Errorf("user:42 was clobbered: %q", got)
}
// Source rows remain in place (rollback safety).
if got := getString(t, ctx, src, "user:7"); got != `{"currency":{"VND":1500000}}` {
t.Errorf("source user:7 was modified/removed: %q", got)
}
}
func mustPut(t *testing.T, ctx context.Context, kv storage.KVStore, key, val string) {
t.Helper()
if err := kv.Put(ctx, key, []byte(val)); err != nil {
t.Fatalf("put %s: %v", key, err)
}
}
func getString(t *testing.T, ctx context.Context, kv storage.KVStore, key string) string {
t.Helper()
raw, err := kv.Get(ctx, key)
if err != nil {
t.Fatalf("get %s: %v", key, err)
}
return string(raw)
}
+53 -53
View File
@@ -26,7 +26,7 @@ import (
"github.com/tiennm99/miti99bot/internal/modules/lolschedule"
"github.com/tiennm99/miti99bot/internal/modules/misc"
"github.com/tiennm99/miti99bot/internal/modules/stats"
"github.com/tiennm99/miti99bot/internal/modules/trading"
"github.com/tiennm99/miti99bot/internal/modules/stock"
"github.com/tiennm99/miti99bot/internal/modules/twentyq"
"github.com/tiennm99/miti99bot/internal/modules/util"
"github.com/tiennm99/miti99bot/internal/modules/wordle"
@@ -53,7 +53,7 @@ func factories() map[string]modules.Factory {
"coin": coin.New,
"gold": gold.New,
"twentyq": twentyq.New,
"trading": trading.New,
"stock": stock.New,
"stats": stats.New,
}
}
@@ -86,8 +86,8 @@ func main() {
log.Fatal("missing required env", "key", "TELEGRAM_WEBHOOK_SECRET",
"why", "non-empty secret is the only auth on /webhook")
}
exportOptionalEnv("TRADING_INCOME_EVENTS_API_URL", cfg.TradingIncomeEventsAPIURL)
exportOptionalEnv("TRADING_INCOME_EVENTS_API_TOKEN", cfg.TradingIncomeEventsAPIToken)
exportOptionalEnv("STOCK_INCOME_EVENTS_API_URL", cfg.StockIncomeEventsAPIURL)
exportOptionalEnv("STOCK_INCOME_EVENTS_API_TOKEN", cfg.StockIncomeEventsAPIToken)
exportOptionalEnv("GOLD_PRICE_API_URL", cfg.GoldPriceAPIURL)
exportOptionalEnv("GOLD_FX_API_URL", cfg.GoldFXAPIURL)
exportOptionalEnv("COIN_BINANCE_API_URL", cfg.CoinBinanceAPIURL)
@@ -258,30 +258,30 @@ func buildProvider(ctx context.Context, cfg config) (storage.KVProvider, func(),
}
type config struct {
Port string
TelegramBotToken string
WebhookSecret string
CronSecret string
FirestoreProject string
FirestoreEmulatorHost string
GeminiAPIKey string
TradingIncomeEventsAPIURL string
TradingIncomeEventsAPIToken string
GoldPriceAPIURL string
GoldFXAPIURL string
CoinBinanceAPIURL string
CoinCoinbaseAPIURL string
CoinCoinGeckoAPIURL string
Modules []string
BotOwnerID int64
AdminUserIDs map[int64]bool
KVProvider string // empty = auto-detect; or "memory"|"firestore"|"dynamodb"
DynamoDBTable string // required when KVProvider=dynamodb
TelegramBotTokenParam string
WebhookSecretParam string
CronSecretParam string
GeminiAPIKeyParam string
TradingIncomeEventsAPITokenParam string
Port string
TelegramBotToken string
WebhookSecret string
CronSecret string
FirestoreProject string
FirestoreEmulatorHost string
GeminiAPIKey string
StockIncomeEventsAPIURL string
StockIncomeEventsAPIToken string
GoldPriceAPIURL string
GoldFXAPIURL string
CoinBinanceAPIURL string
CoinCoinbaseAPIURL string
CoinCoinGeckoAPIURL string
Modules []string
BotOwnerID int64
AdminUserIDs map[int64]bool
KVProvider string // empty = auto-detect; or "memory"|"firestore"|"dynamodb"
DynamoDBTable string // required when KVProvider=dynamodb
TelegramBotTokenParam string
WebhookSecretParam string
CronSecretParam string
GeminiAPIKeyParam string
StockIncomeEventsAPITokenParam string
}
func loadConfig() config {
@@ -302,30 +302,30 @@ func loadConfig() config {
log.Fatal("invalid PORT", "value", port)
}
return config{
Port: port,
TelegramBotToken: envMap["TELEGRAM_BOT_TOKEN"],
WebhookSecret: envMap["TELEGRAM_WEBHOOK_SECRET"],
CronSecret: envMap["CRON_SHARED_SECRET"],
FirestoreProject: envMap["GOOGLE_CLOUD_PROJECT"],
FirestoreEmulatorHost: envMap["FIRESTORE_EMULATOR_HOST"],
GeminiAPIKey: envMap["GEMINI_API_KEY"],
TradingIncomeEventsAPIURL: envMap["TRADING_INCOME_EVENTS_API_URL"],
TradingIncomeEventsAPIToken: envMap["TRADING_INCOME_EVENTS_API_TOKEN"],
GoldPriceAPIURL: envMap["GOLD_PRICE_API_URL"],
GoldFXAPIURL: envMap["GOLD_FX_API_URL"],
CoinBinanceAPIURL: envMap["COIN_BINANCE_API_URL"],
CoinCoinbaseAPIURL: envMap["COIN_COINBASE_API_URL"],
CoinCoinGeckoAPIURL: envMap["COIN_COINGECKO_API_URL"],
Modules: splitCSV(envMap["MODULES"]),
BotOwnerID: parseInt64(envMap["BOT_OWNER_ID"]),
AdminUserIDs: parseInt64Set(envMap["ADMIN_USER_IDS"]),
KVProvider: envMap["KV_PROVIDER"],
DynamoDBTable: envMap["DYNAMODB_TABLE"],
TelegramBotTokenParam: strings.TrimSpace(envMap["TELEGRAM_BOT_TOKEN_PARAMETER_NAME"]),
WebhookSecretParam: strings.TrimSpace(envMap["TELEGRAM_WEBHOOK_SECRET_PARAMETER_NAME"]),
CronSecretParam: strings.TrimSpace(envMap["CRON_SHARED_SECRET_PARAMETER_NAME"]),
GeminiAPIKeyParam: strings.TrimSpace(envMap["GEMINI_API_KEY_PARAMETER_NAME"]),
TradingIncomeEventsAPITokenParam: strings.TrimSpace(envMap["TRADING_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME"]),
Port: port,
TelegramBotToken: envMap["TELEGRAM_BOT_TOKEN"],
WebhookSecret: envMap["TELEGRAM_WEBHOOK_SECRET"],
CronSecret: envMap["CRON_SHARED_SECRET"],
FirestoreProject: envMap["GOOGLE_CLOUD_PROJECT"],
FirestoreEmulatorHost: envMap["FIRESTORE_EMULATOR_HOST"],
GeminiAPIKey: envMap["GEMINI_API_KEY"],
StockIncomeEventsAPIURL: envMap["STOCK_INCOME_EVENTS_API_URL"],
StockIncomeEventsAPIToken: envMap["STOCK_INCOME_EVENTS_API_TOKEN"],
GoldPriceAPIURL: envMap["GOLD_PRICE_API_URL"],
GoldFXAPIURL: envMap["GOLD_FX_API_URL"],
CoinBinanceAPIURL: envMap["COIN_BINANCE_API_URL"],
CoinCoinbaseAPIURL: envMap["COIN_COINBASE_API_URL"],
CoinCoinGeckoAPIURL: envMap["COIN_COINGECKO_API_URL"],
Modules: splitCSV(envMap["MODULES"]),
BotOwnerID: parseInt64(envMap["BOT_OWNER_ID"]),
AdminUserIDs: parseInt64Set(envMap["ADMIN_USER_IDS"]),
KVProvider: envMap["KV_PROVIDER"],
DynamoDBTable: envMap["DYNAMODB_TABLE"],
TelegramBotTokenParam: strings.TrimSpace(envMap["TELEGRAM_BOT_TOKEN_PARAMETER_NAME"]),
WebhookSecretParam: strings.TrimSpace(envMap["TELEGRAM_WEBHOOK_SECRET_PARAMETER_NAME"]),
CronSecretParam: strings.TrimSpace(envMap["CRON_SHARED_SECRET_PARAMETER_NAME"]),
GeminiAPIKeyParam: strings.TrimSpace(envMap["GEMINI_API_KEY_PARAMETER_NAME"]),
StockIncomeEventsAPITokenParam: strings.TrimSpace(envMap["STOCK_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME"]),
}
}
@@ -338,7 +338,7 @@ func resolveSSMSecrets(ctx context.Context, cfg *config) error {
{name: cfg.WebhookSecretParam, target: &cfg.WebhookSecret},
{name: cfg.CronSecretParam, target: &cfg.CronSecret},
{name: cfg.GeminiAPIKeyParam, target: &cfg.GeminiAPIKey},
{name: cfg.TradingIncomeEventsAPITokenParam, target: &cfg.TradingIncomeEventsAPIToken},
{name: cfg.StockIncomeEventsAPITokenParam, target: &cfg.StockIncomeEventsAPIToken},
}
targetsByName := map[string][]*string{}
+5 -5
View File
@@ -102,18 +102,18 @@ Only when a push introduces **new public commands** (`VisibilityPublic`) does th
need attention — re-confirm registration for those pushes; routine pushes (refactors,
fixes, non-public commands) need no menu action.
## Trading income events API
## Stock income events API
`/trade_income_events` uses a FireAnt REST API, configured at Lambda runtime:
`/stock_income_events` uses a FireAnt REST API, configured at Lambda runtime:
- `TRADING_INCOME_EVENTS_API_URL`: FireAnt base URL; defaults to `https://restv2.fireant.vn`. The bot calls `/symbols/{symbol}/timescale-marks` with `startDate` and `endDate`.
- `TRADING_INCOME_EVENTS_API_TOKEN`: bearer token for FireAnt. Store it directly only for local dev; in AWS prefer `TRADING_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME`.
- `STOCK_INCOME_EVENTS_API_URL`: FireAnt base URL; defaults to `https://restv2.fireant.vn`. The bot calls `/symbols/{symbol}/timescale-marks` with `startDate` and `endDate`.
- `STOCK_INCOME_EVENTS_API_TOKEN`: bearer token for FireAnt. Store it directly only for local dev; in AWS prefer `STOCK_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME`.
FireAnt response is an array of timescale marks with `id`, `label`, `date`, `title`, and `color`. The bot keeps marks whose label/title indicate dividends, ex-right dates, final registration dates, rights issues, or bonus/share dividends.
## Gold module
`gold` is opt-in for first deploy. Enable it by adding `gold` to the `ModulesCSV` parameter / `MODULES` env, for example `util,misc,wordle,loldle,lolschedule,twentyq,trading,stats,gold`.
`gold` is opt-in for first deploy. Enable it by adding `gold` to the `ModulesCSV` parameter / `MODULES` env, for example `util,misc,wordle,loldle,lolschedule,twentyq,stock,stats,gold`.
Commands:
+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"
+1 -1
View File
@@ -13,7 +13,7 @@ resolve_s3 = true
s3_prefix = "miti99bot"
# Secrets MUST live in SSM Parameter Store (see aws/README.md). Never put
# them here — this file is committed.
parameter_overrides = "StackEnv=\"prod\" ModulesCSV=\"util,misc,wordle,loldle,lolschedule,twentyq,trading,stats,gold,coin\" BotOwnerID=\"1064111334\" AdminUserIDs=\"1064111334\" LambdaAdapterLayerArn=\"arn:aws:lambda:ap-southeast-1:753240598075:layer:LambdaAdapterLayerArm64:25\" AlertEmail=\"minhtienit99@gmail.com\""
parameter_overrides = "StackEnv=\"prod\" ModulesCSV=\"util,misc,wordle,loldle,lolschedule,twentyq,stock,stats,gold,coin\" BotOwnerID=\"1064111334\" AdminUserIDs=\"1064111334\" LambdaAdapterLayerArn=\"arn:aws:lambda:ap-southeast-1:753240598075:layer:LambdaAdapterLayerArm64:25\" AlertEmail=\"minhtienit99@gmail.com\""
image_repositories = []
[default.validate.parameters]
+5 -5
View File
@@ -14,7 +14,7 @@ Parameters:
ModulesCSV:
Type: String
Default: util,misc,wordle,loldle,lolschedule,twentyq,trading,coin,stats
Default: util,misc,wordle,loldle,lolschedule,twentyq,stock,coin,stats
Description: Comma-separated module names enabled at runtime (matches MODULES env).
BotOwnerID:
@@ -27,12 +27,12 @@ Parameters:
Default: ""
Description: Comma-separated Telegram user IDs allowed to use admin commands.
TradingIncomeEventsAPIURL:
StockIncomeEventsAPIURL:
Type: String
Default: "https://restv2.fireant.vn"
Description: FireAnt REST API base URL. Defaults to https://restv2.fireant.vn when omitted.
TradingIncomeEventsAPITokenParameterName:
StockIncomeEventsAPITokenParameterName:
Type: String
Default: ""
Description: Optional SSM SecureString parameter name containing bearer token for FireAnt REST API.
@@ -165,8 +165,8 @@ Resources:
MODULES: !Ref ModulesCSV
BOT_OWNER_ID: !Ref BotOwnerID
ADMIN_USER_IDS: !Ref AdminUserIDs
TRADING_INCOME_EVENTS_API_URL: !Ref TradingIncomeEventsAPIURL
TRADING_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME: !Ref TradingIncomeEventsAPITokenParameterName
STOCK_INCOME_EVENTS_API_URL: !Ref StockIncomeEventsAPIURL
STOCK_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME: !Ref StockIncomeEventsAPITokenParameterName
GOLD_PRICE_API_URL: !Ref GoldPriceAPIURL
GOLD_FX_API_URL: !Ref GoldFXAPIURL
COIN_BINANCE_API_URL: !Ref CoinBinanceAPIURL