mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-07 02:25:45 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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>` |
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
+18
-18
@@ -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) {
|
||||
+5
-5
@@ -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
-1
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user