diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0bc39ce..9c3fcef 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/README.md b/README.md index b4c8607..1ddd047 100644 --- a/README.md +++ b/README.md @@ -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 `, `/stats cmd ` | diff --git a/aws/telegram-commands.json b/aws/telegram-commands.json index 8ca1e57..028c473 100644 --- a/aws/telegram-commands.json +++ b/aws/telegram-commands.json @@ -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" }, { diff --git a/cmd/migrate-stock-key/main.go b/cmd/migrate-stock-key/main.go new file mode 100644 index 0000000..90b01de --- /dev/null +++ b/cmd/migrate-stock-key/main.go @@ -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 +} diff --git a/cmd/migrate-stock-key/main_test.go b/cmd/migrate-stock-key/main_test.go new file mode 100644 index 0000000..1e0b7d6 --- /dev/null +++ b/cmd/migrate-stock-key/main_test.go @@ -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) +} diff --git a/cmd/server/main.go b/cmd/server/main.go index f3b3ed6..78d488f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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{} diff --git a/docs/deploy-aws.md b/docs/deploy-aws.md index f234137..c96de92 100644 --- a/docs/deploy-aws.md +++ b/docs/deploy-aws.md @@ -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: diff --git a/internal/modules/gold/gold.go b/internal/modules/gold/gold.go index 68b5726..15187e8 100644 --- a/internal/modules/gold/gold.go +++ b/internal/modules/gold/gold.go @@ -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{ diff --git a/internal/modules/gold/portfolio_test.go b/internal/modules/gold/portfolio_test.go index c3fd871..c2ccbc8 100644 --- a/internal/modules/gold/portfolio_test.go +++ b/internal/modules/gold/portfolio_test.go @@ -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 diff --git a/internal/modules/misc/misc.go b/internal/modules/misc/misc.go index d3c01e7..8081b89 100644 --- a/internal/modules/misc/misc.go +++ b/internal/modules/misc/misc.go @@ -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) diff --git a/internal/modules/trading/format.go b/internal/modules/stock/format.go similarity index 94% rename from internal/modules/trading/format.go rename to internal/modules/stock/format.go index bf3e0c3..24d3d8b 100644 --- a/internal/modules/trading/format.go +++ b/internal/modules/stock/format.go @@ -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" diff --git a/internal/modules/trading/format_test.go b/internal/modules/stock/format_test.go similarity index 98% rename from internal/modules/trading/format_test.go rename to internal/modules/stock/format_test.go index 13f3135..dc6b0dc 100644 --- a/internal/modules/trading/format_test.go +++ b/internal/modules/stock/format_test.go @@ -1,4 +1,4 @@ -package trading +package stock import "testing" diff --git a/internal/modules/trading/handlers.go b/internal/modules/stock/handlers.go similarity index 85% rename from internal/modules/trading/handlers.go rename to internal/modules/stock/handlers.go index dac4804..0029efc 100644 --- a/internal/modules/trading/handlers.go +++ b/internal/modules/stock/handlers.go @@ -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 \nExample: /trade_topup 5000000") + return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_topup \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 \nExample: /trade_buy 100 TCB") + return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_buy \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 \nExample: /trade_sell 100 TCB") + return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_sell \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 \nExample: /trade_income_stock 200 TCX") + "Usage: /stock_income_stock \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 \nExample: /trade_income_vnd 1500 TCX") + "Usage: /stock_income_vnd \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.") } diff --git a/internal/modules/trading/income_events.go b/internal/modules/stock/income_events.go similarity index 87% rename from internal/modules/trading/income_events.go rename to internal/modules/stock/income_events.go index 8d742a9..d35b855 100644 --- a/internal/modules/trading/income_events.go +++ b/internal/modules/stock/income_events.go @@ -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 ") + "You don't hold any stocks yet. Usage: /stock_income_events ") } 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) { diff --git a/internal/modules/trading/income_events_test.go b/internal/modules/stock/income_events_test.go similarity index 96% rename from internal/modules/trading/income_events_test.go rename to internal/modules/stock/income_events_test.go index 8714fb6..3a0058a 100644 --- a/internal/modules/trading/income_events_test.go +++ b/internal/modules/stock/income_events_test.go @@ -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) diff --git a/internal/modules/trading/portfolio.go b/internal/modules/stock/portfolio.go similarity index 93% rename from internal/modules/trading/portfolio.go rename to internal/modules/stock/portfolio.go index 6abab49..c72f3fe 100644 --- a/internal/modules/trading/portfolio.go +++ b/internal/modules/stock/portfolio.go @@ -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 } diff --git a/internal/modules/trading/portfolio_test.go b/internal/modules/stock/portfolio_test.go similarity index 99% rename from internal/modules/trading/portfolio_test.go rename to internal/modules/stock/portfolio_test.go index e6913d8..54119aa 100644 --- a/internal/modules/trading/portfolio_test.go +++ b/internal/modules/stock/portfolio_test.go @@ -1,4 +1,4 @@ -package trading +package stock import ( "context" diff --git a/internal/modules/trading/prices.go b/internal/modules/stock/prices.go similarity index 90% rename from internal/modules/trading/prices.go rename to internal/modules/stock/prices.go index 00e2e67..cc19a76 100644 --- a/internal/modules/trading/prices.go +++ b/internal/modules/stock/prices.go @@ -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") diff --git a/internal/modules/trading/prices_test.go b/internal/modules/stock/prices_test.go similarity index 99% rename from internal/modules/trading/prices_test.go rename to internal/modules/stock/prices_test.go index fa82808..c0e4632 100644 --- a/internal/modules/trading/prices_test.go +++ b/internal/modules/stock/prices_test.go @@ -1,4 +1,4 @@ -package trading +package stock import ( "context" diff --git a/internal/modules/trading/trading.go b/internal/modules/stock/stock.go similarity index 77% rename from internal/modules/trading/trading.go rename to internal/modules/stock/stock.go index 764b33e..e9e2f5c 100644 --- a/internal/modules/trading/trading.go +++ b/internal/modules/stock/stock.go @@ -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, diff --git a/internal/modules/trading/symbols.go b/internal/modules/stock/symbols.go similarity index 92% rename from internal/modules/trading/symbols.go rename to internal/modules/stock/symbols.go index 70ca342..05771d5 100644 --- a/internal/modules/trading/symbols.go +++ b/internal/modules/stock/symbols.go @@ -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. diff --git a/internal/modules/trading/symbols_test.go b/internal/modules/stock/symbols_test.go similarity index 99% rename from internal/modules/trading/symbols_test.go rename to internal/modules/stock/symbols_test.go index e9d5859..a0b91b8 100644 --- a/internal/modules/trading/symbols_test.go +++ b/internal/modules/stock/symbols_test.go @@ -1,4 +1,4 @@ -package trading +package stock import ( "context" diff --git a/samconfig.toml b/samconfig.toml index 771ded8..f4448d6 100644 --- a/samconfig.toml +++ b/samconfig.toml @@ -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] diff --git a/template.yaml b/template.yaml index 0d64005..2ab5655 100644 --- a/template.yaml +++ b/template.yaml @@ -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