diff --git a/cmd/server/main.go b/cmd/server/main.go index 15e6ea1..0a0dc3c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -82,6 +82,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) // Periodic metrics flush. Cancels with rootCtx and emits one final // flush on shutdown so the trailing window isn't lost. @@ -247,22 +249,25 @@ 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 - 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 + Port string + TelegramBotToken string + WebhookSecret string + CronSecret string + FirestoreProject string + FirestoreEmulatorHost string + GeminiAPIKey string + TradingIncomeEventsAPIURL string + TradingIncomeEventsAPIToken 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 } func loadConfig() config { @@ -283,22 +288,25 @@ 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"], - 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"]), + 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"], + 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"]), } } @@ -311,6 +319,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}, } targetsByName := map[string][]*string{} @@ -359,6 +368,15 @@ func resolveSSMSecrets(ctx context.Context, cfg *config) error { return nil } +func exportOptionalEnv(key, value string) { + if strings.TrimSpace(value) == "" { + return + } + if err := os.Setenv(key, value); err != nil { + log.Warn("could not export optional env", "key", key, "err", err) + } +} + func splitCSV(s string) []string { if s == "" { return nil diff --git a/docs/deploy-aws.md b/docs/deploy-aws.md index 03a28ac..81585e2 100644 --- a/docs/deploy-aws.md +++ b/docs/deploy-aws.md @@ -76,6 +76,15 @@ curl "https://api.telegram.org/bot$TOKEN/getWebhookInfo" | jq . ``` Expect: `url` matches Function URL, `pending_update_count` ≈ 0, `last_error_date` empty. +## Trading income events API + +`/trade_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`. + +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. + ## Rotate secrets ```sh diff --git a/internal/modules/trading/handlers.go b/internal/modules/trading/handlers.go index 1f558f6..dac4804 100644 --- a/internal/modules/trading/handlers.go +++ b/internal/modules/trading/handlers.go @@ -22,6 +22,7 @@ import ( type state struct { kv storage.KVStore prices *PriceClient + incomeEvents *IncomeEventClient locks keylock.Map nowFn func() time.Time comingSoonMessage string // exposed for tests / future i18n @@ -39,6 +40,7 @@ func newState(kv storage.KVStore) *state { return &state{ kv: kv, prices: &PriceClient{}, + incomeEvents: NewIncomeEventClientFromEnv(), comingSoonMessage: "Crypto, gold & currency exchange coming soon!", } } @@ -220,6 +222,104 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat "\nRemaining: "+FormatVND(p.Currency["VND"])) } +func (s *state) handleIncomeStock(ctx context.Context, b *bot.Bot, update *models.Update) error { + 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.") + } + 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") + } + qty, err := strconv.ParseInt(args[0], 10, 64) + if err != nil || qty <= 0 { + return chathelper.Reply(ctx, b, update.Message, "Quantity must be a positive whole number.") + } + + resolved, err := ResolveSymbol(ctx, s.kv, s.prices, args[1]) + if err != nil { + if errors.Is(err, ErrUnknownTicker) { + return chathelper.Reply(ctx, b, update.Message, + "Unknown stock ticker \""+strings.ToUpper(args[1])+"\".") + } + log.Error("trading_resolve_symbol", "ticker", args[1], "err", err) + return chathelper.Reply(ctx, b, update.Message, "Could not look up that ticker. Try again later.") + } + + defer s.locks.Acquire(strconv.FormatInt(userID, 10))() + + p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli()) + if err != nil { + log.Error("trading_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] + if held == 0 { + return chathelper.Reply(ctx, b, update.Message, + "You don't hold any "+resolved.Symbol+" to receive a stock dividend.") + } + p.AddAsset(resolved.Symbol, qty) + if err := SavePortfolio(ctx, s.kv, userID, p); err != nil { + log.Error("trading_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, + "Stock dividend: +"+FormatStock(float64(qty))+" "+resolved.Symbol+ + "\nHolding: "+FormatStock(float64(held))+" → "+FormatStock(float64(p.Assets[resolved.Symbol]))) +} + +func (s *state) handleIncomeVND(ctx context.Context, b *bot.Bot, update *models.Update) error { + userID, ok := senderInfo(update) + if !ok { + return chathelper.Reply(ctx, b, update.Message, + "Cannot identify user — trading 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") + } + amountPerShare, err := strconv.ParseFloat(args[0], 64) + if err != nil || amountPerShare <= 0 { + return chathelper.Reply(ctx, b, update.Message, "Amount per share must be a positive number.") + } + + resolved, err := ResolveSymbol(ctx, s.kv, s.prices, args[1]) + if err != nil { + if errors.Is(err, ErrUnknownTicker) { + return chathelper.Reply(ctx, b, update.Message, + "Unknown stock ticker \""+strings.ToUpper(args[1])+"\".") + } + log.Error("trading_resolve_symbol", "ticker", args[1], "err", err) + return chathelper.Reply(ctx, b, update.Message, "Could not look up that ticker. Try again later.") + } + + defer s.locks.Acquire(strconv.FormatInt(userID, 10))() + + p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli()) + if err != nil { + log.Error("trading_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] + if held == 0 { + return chathelper.Reply(ctx, b, update.Message, + "You don't hold any "+resolved.Symbol+" to receive a cash dividend.") + } + 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) + return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.") + } + return chathelper.Reply(ctx, b, update.Message, + "Cash dividend: "+FormatVND(amountPerShare)+" × "+FormatStock(float64(held))+" "+resolved.Symbol+ + " = "+FormatVND(total)+ + "\nRemaining: "+FormatVND(p.Currency["VND"])) +} + func (s *state) handleConvert(ctx context.Context, b *bot.Bot, update *models.Update) error { if update.Message == nil { return nil diff --git a/internal/modules/trading/income_events.go b/internal/modules/trading/income_events.go new file mode 100644 index 0000000..5a81602 --- /dev/null +++ b/internal/modules/trading/income_events.go @@ -0,0 +1,327 @@ +package trading + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "html" + "net/http" + "net/url" + "os" + "sort" + "strings" + "sync" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/log" + "github.com/tiennm99/miti99bot/internal/modules/util/chathelper" +) + +const ( + fireAntIncomeEventsDefaultURL = "https://restv2.fireant.vn" + incomeEventsHTTPTimeout = 10 * time.Second + incomeEventsLookback = 30 * 24 * time.Hour +) + +type IncomeEvent struct { + Symbol string + Title string + Subtitle string + DeployDate time.Time + Link string +} + +type IncomeEventClient struct { + HTTP *http.Client + URL string + Token string + + defaultOnce sync.Once + defaultClient *http.Client +} + +func NewIncomeEventClientFromEnv() *IncomeEventClient { + url := strings.TrimSpace(os.Getenv("TRADING_INCOME_EVENTS_API_URL")) + if url == "" { + url = fireAntIncomeEventsDefaultURL + } + return &IncomeEventClient{ + URL: url, + Token: strings.TrimSpace(os.Getenv("TRADING_INCOME_EVENTS_API_TOKEN")), + } +} + +func (c *IncomeEventClient) httpClient() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + c.defaultOnce.Do(func() { + c.defaultClient = &http.Client{Timeout: incomeEventsHTTPTimeout} + }) + return c.defaultClient +} + +type fireAntTimescaleMark struct { + ID string `json:"id"` + Label string `json:"label"` + Date string `json:"date"` + Title string `json:"title"` + Color string `json:"color"` +} + +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") +) + +func (c *IncomeEventClient) FetchRecent(ctx context.Context, ticker string, since, until time.Time) ([]IncomeEvent, error) { + ticker = strings.ToUpper(strings.TrimSpace(ticker)) + if !tickerRe.MatchString(ticker) { + return nil, ErrUnknownTicker + } + if strings.TrimSpace(c.URL) == "" { + return nil, ErrIncomeEventClientNotConfigured + } + + endpoint, err := url.Parse(c.URL) + if err != nil { + return nil, fmt.Errorf("trading: parse FireAnt URL: %w", err) + } + if endpoint.Scheme != "https" && !(endpoint.Scheme == "http" && strings.HasPrefix(endpoint.Host, "127.0.0.1:")) && !(endpoint.Scheme == "http" && strings.HasPrefix(endpoint.Host, "localhost:")) { + return nil, fmt.Errorf("trading: income events API URL must be https") + } + endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/symbols/" + url.PathEscape(ticker) + "/timescale-marks" + q := endpoint.Query() + q.Set("startDate", since.Format(time.RFC3339)) + q.Set("endDate", until.Format(time.RFC3339)) + endpoint.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("trading: build FireAnt request: %w", err) + } + req.Header.Set("User-Agent", "miti99bot") + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("trading: FireAnt request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return nil, ErrIncomeEventAuthRequired + } + if resp.StatusCode == http.StatusNotFound { + return nil, ErrNoIncomeEvents + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("trading: 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) + } + + var out []IncomeEvent + for _, mark := range marks { + date, ok := parseFireAntDate(mark.Date) + if !ok || date.Before(since) || date.After(until) || !isIncomeEventMark(mark) { + continue + } + title := cleanIncomeEventText(mark.Title) + label := cleanIncomeEventText(mark.Label) + if title == "" { + title = label + } + subtitle := "" + if label != "" && label != title { + subtitle = label + } + out = append(out, IncomeEvent{ + Symbol: ticker, + Title: title, + Subtitle: subtitle, + DeployDate: date, + }) + } + if len(out) == 0 { + return nil, ErrNoIncomeEvents + } + sort.Slice(out, func(i, j int) bool { + return out[i].DeployDate.After(out[j].DeployDate) + }) + return out, nil +} + +func parseFireAntDate(raw string) (time.Time, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, false + } + for _, layout := range []string{time.RFC3339, "2006-01-02", "02/01/2006"} { + parsed, err := time.Parse(layout, raw) + if err == nil { + return parsed.UTC(), true + } + } + return time.Time{}, false +} + +func isIncomeEventMark(mark fireAntTimescaleMark) bool { + text := normalizeIncomeEventSearchText(mark.Label + " " + mark.Title) + terms := []string{ + "co tuc", + "dividend", + "quyen mua", + "phat hanh co phieu", + "chia co phieu", + "bonus share", + "stock dividend", + "cash dividend", + } + for _, term := range terms { + if strings.Contains(text, term) { + return true + } + } + return false +} + +func normalizeIncomeEventSearchText(s string) string { + s = strings.ToLower(cleanIncomeEventText(s)) + replacer := strings.NewReplacer( + "à", "a", "á", "a", "ạ", "a", "ả", "a", "ã", "a", "â", "a", "ầ", "a", "ấ", "a", "ậ", "a", "ẩ", "a", "ẫ", "a", "ă", "a", "ằ", "a", "ắ", "a", "ặ", "a", "ẳ", "a", "ẵ", "a", + "è", "e", "é", "e", "ẹ", "e", "ẻ", "e", "ẽ", "e", "ê", "e", "ề", "e", "ế", "e", "ệ", "e", "ể", "e", "ễ", "e", + "ì", "i", "í", "i", "ị", "i", "ỉ", "i", "ĩ", "i", + "ò", "o", "ó", "o", "ọ", "o", "ỏ", "o", "õ", "o", "ô", "o", "ồ", "o", "ố", "o", "ộ", "o", "ổ", "o", "ỗ", "o", "ơ", "o", "ờ", "o", "ớ", "o", "ợ", "o", "ở", "o", "ỡ", "o", + "ù", "u", "ú", "u", "ụ", "u", "ủ", "u", "ũ", "u", "ư", "u", "ừ", "u", "ứ", "u", "ự", "u", "ử", "u", "ữ", "u", + "ỳ", "y", "ý", "y", "ỵ", "y", "ỷ", "y", "ỹ", "y", + "đ", "d", + ) + return replacer.Replace(s) +} + +func cleanIncomeEventText(s string) string { + return strings.Join(strings.Fields(html.UnescapeString(s)), " ") +} + +func RenderIncomeEvents(events []IncomeEvent, since, until time.Time) string { + if len(events) == 0 { + return "No recent income events from FireAnt in the last 30 days." + } + var lines []string + lines = append(lines, "Income events from FireAnt") + lines = append(lines, since.Format("02/01/2006")+" - "+until.Format("02/01/2006")) + + for _, event := range events { + line := event.Symbol + " - " + event.DeployDate.Format("02/01/2006") + ": " + event.Title + if event.Subtitle != "" { + line += "\n " + event.Subtitle + } + if event.Link != "" { + line += "\n " + event.Link + } + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} + +func (s *state) handleIncomeEvents(ctx context.Context, b *bot.Bot, update *models.Update) error { + userID, ok := senderInfo(update) + if !ok { + return chathelper.Reply(ctx, b, update.Message, + "Cannot identify user - /trade_income_events needs a sender.") + } + + args := argsAfterCommand(update.Message.Text) + symbols, err := s.incomeEventSymbols(ctx, userID, args) + if err != nil { + if errors.Is(err, ErrUnknownTicker) { + ticker := "" + if len(args) > 0 { + ticker = strings.ToUpper(args[0]) + } + return chathelper.Reply(ctx, b, update.Message, "Unknown stock ticker \""+ticker+"\".") + } + log.Error("trading_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 ") + } + + until := s.now().UTC() + since := until.Add(-incomeEventsLookback) + var all []IncomeEvent + var failed []string + var notConfigured bool + for _, symbol := range symbols { + events, err := s.incomeEvents.FetchRecent(ctx, symbol, since, until) + if err != nil { + if errors.Is(err, ErrIncomeEventClientNotConfigured) { + notConfigured = true + break + } + 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.") + } + if errors.Is(err, ErrNoIncomeEvents) { + continue + } + log.Error("trading_fetch_income_events", "ticker", symbol, "err", err) + failed = append(failed, symbol) + continue + } + all = append(all, events...) + } + 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.") + } + sort.Slice(all, func(i, j int) bool { + if all[i].DeployDate.Equal(all[j].DeployDate) { + return all[i].Symbol < all[j].Symbol + } + return all[i].DeployDate.After(all[j].DeployDate) + }) + + reply := RenderIncomeEvents(all, since, until) + if len(failed) > 0 { + reply += "\nCould not fetch: " + strings.Join(failed, ", ") + } + return chathelper.Reply(ctx, b, update.Message, reply) +} + +func (s *state) incomeEventSymbols(ctx context.Context, userID int64, args []string) ([]string, error) { + if len(args) > 0 { + resolved, err := ResolveSymbol(ctx, s.kv, s.prices, args[0]) + if err != nil { + return nil, err + } + return []string{resolved.Symbol}, nil + } + + p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli()) + if err != nil { + return nil, err + } + var symbols []string + for symbol, qty := range p.Assets { + if qty > 0 { + symbols = append(symbols, symbol) + } + } + sort.Strings(symbols) + return symbols, nil +} diff --git a/internal/modules/trading/income_events_test.go b/internal/modules/trading/income_events_test.go new file mode 100644 index 0000000..8714fb6 --- /dev/null +++ b/internal/modules/trading/income_events_test.go @@ -0,0 +1,173 @@ +package trading + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/storage" + "github.com/tiennm99/miti99bot/internal/testutil" +) + +func newTestIncomeEventClient(t *testing.T, handler http.HandlerFunc) (*IncomeEventClient, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return &IncomeEventClient{ + HTTP: srv.Client(), + URL: srv.URL, + }, srv +} + +func TestIncomeEventClient_FetchRecentUsesFireAntTimescaleMarks(t *testing.T) { + now := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC) + c, _ := newTestIncomeEventClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/symbols/TCX/timescale-marks" { + t.Errorf("path = %q, want /symbols/TCX/timescale-marks", r.URL.Path) + } + if got := r.URL.Query().Get("startDate"); got != "2026-05-06T00:00:00Z" { + t.Errorf("startDate query = %q, want 2026-05-06T00:00:00Z", got) + } + if got := r.URL.Query().Get("endDate"); got != "2026-06-05T00:00:00Z" { + t.Errorf("endDate query = %q, want 2026-06-05T00:00:00Z", got) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Errorf("authorization header = %q, want bearer token", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"1","label":"Cổ tức","date":"2026-05-25T00:00:00Z","title":"TCX: dividend"},{"id":"2","label":"BCTC","date":"2026-05-24T00:00:00Z","title":"Financial report"},{"id":"3","label":"Cổ tức","date":"2025-06-12T00:00:00Z","title":"Old dividend"}]`)) + }) + c.Token = "test-token" + + got, err := c.FetchRecent(context.Background(), "tcx", now.Add(-incomeEventsLookback), now) + if err != nil { + t.Fatalf("FetchRecent: %v", err) + } + if len(got) != 1 { + t.Fatalf("events = %d, want 1: %+v", len(got), got) + } + if got[0].Symbol != "TCX" { + t.Errorf("symbol = %q, want TCX", got[0].Symbol) + } + if got[0].Subtitle != "Cổ tức" { + t.Errorf("subtitle = %q, want Cổ tức", got[0].Subtitle) + } +} + +func TestIncomeEventClient_FiltersNonIncomeMarks(t *testing.T) { + now := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC) + c, _ := newTestIncomeEventClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"1","label":"BCTC","date":"2026-05-25T00:00:00Z","title":"Financial report"},{"id":"2","label":"GDKHQ","date":"2026-05-24T00:00:00Z","title":"Ngày đăng ký cuối cùng trả cổ tức"},{"id":"3","label":"GDKHQ","date":"2026-05-23T00:00:00Z","title":"Ngày đăng ký cuối cùng tham dự Đại hội đồng cổ đông"}]`)) + }) + + got, err := c.FetchRecent(context.Background(), "TCX", now.Add(-incomeEventsLookback), now) + if err != nil { + t.Fatalf("FetchRecent: %v", err) + } + if len(got) != 1 || got[0].Title != "Ngày đăng ký cuối cùng trả cổ tức" { + t.Fatalf("events = %+v, want only income mark", got) + } +} + +func TestIncomeEventClient_RejectsNonHTTPSRemoteURL(t *testing.T) { + c := &IncomeEventClient{URL: "http://official.example/events", Token: "secret"} + _, err := c.FetchRecent(context.Background(), "TCX", time.Now().Add(-incomeEventsLookback), time.Now()) + if err == nil || !strings.Contains(err.Error(), "must be https") { + t.Fatalf("FetchRecent error = %v, want https requirement", err) + } +} + +func TestRenderIncomeEvents(t *testing.T) { + now := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC) + got := RenderIncomeEvents([]IncomeEvent{ + { + Symbol: "TCX", + Title: "TCX: dividend", + Subtitle: "stock dividend", + DeployDate: time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC), + Link: "", + }, + }, now.Add(-incomeEventsLookback), now) + for _, want := range []string{ + "Income events from FireAnt", + "06/05/2026 - 05/06/2026", + "TCX - 25/05/2026: TCX: dividend", + "stock dividend", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } +} + +func installTradingIncomeEvents(t *testing.T, eventBody string, now time.Time) (*testutil.RecordingBot, storage.KVStore) { + t.Helper() + eventsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(eventBody)) + })) + t.Cleanup(eventsSrv.Close) + + priceSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data_day":[{"c":24500}]}`)) + })) + t.Cleanup(priceSrv.Close) + + rb := testutil.NewRecordingBot(t) + kv := storage.NewMemoryKVStore() + s := &state{ + kv: kv, + prices: &PriceClient{HTTP: priceSrv.Client(), URL: priceSrv.URL}, + incomeEvents: &IncomeEventClient{HTTP: eventsSrv.Client(), URL: eventsSrv.URL}, + nowFn: func() time.Time { return now }, + } + cmd := modules.Command{ + Name: "trade_income_events", + Visibility: modules.VisibilityPublic, + Description: "x", + Handler: s.handleIncomeEvents, + } + reg := &modules.Registry{ + Modules: []modules.Module{{Name: "trading", Commands: []modules.Command{cmd}}}, + AllCommands: map[string]modules.Command{cmd.Name: cmd}, + } + modules.Install(rb.Bot, reg, modules.Auth{}) + return rb, kv +} + +func TestHandleIncomeEvents_WithTicker(t *testing.T) { + now := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC) + 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")) + 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) { + t.Errorf("missing %q in:\n%s", want, got) + } + } +} + +func TestHandleIncomeEvents_UsesHoldingsWhenTickerMissing(t *testing.T) { + now := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC) + body := `[{"id":"1","label":"Cổ tức","date":"2026-05-25T00:00:00Z","title":"Holding event"}]` + rb, kv := installTradingIncomeEvents(t, body, now) + p := NewPortfolio(now.UnixMilli()) + p.AddAsset("TCX", 100) + if err := SavePortfolio(context.Background(), kv, 7, p); err != nil { + t.Fatalf("SavePortfolio: %v", err) + } + + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/trade_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/trading.go b/internal/modules/trading/trading.go index 2068cb5..764b33e 100644 --- a/internal/modules/trading/trading.go +++ b/internal/modules/trading/trading.go @@ -29,6 +29,24 @@ func New(deps modules.Deps) modules.Module { Description: "Sell VN stock back to VND (qty TICKER)", Handler: s.handleSell, }, + { + Name: "trade_income_stock", + Visibility: modules.VisibilityPublic, + Description: "Record stock dividend (bonus shares)", + Handler: s.handleIncomeStock, + }, + { + Name: "trade_income_vnd", + Visibility: modules.VisibilityPublic, + Description: "Record cash dividend (VND per share)", + Handler: s.handleIncomeVND, + }, + { + Name: "trade_income_events", + Visibility: modules.VisibilityPublic, + Description: "Check recent income events from FireAnt", + Handler: s.handleIncomeEvents, + }, { Name: "trade_convert", Visibility: modules.VisibilityPublic, diff --git a/plans/260605-0256-trade-income-events-command/plan.md b/plans/260605-0256-trade-income-events-command/plan.md new file mode 100644 index 0000000..877e0d0 --- /dev/null +++ b/plans/260605-0256-trade-income-events-command/plan.md @@ -0,0 +1,37 @@ +--- +title: "Trade Income Events Command" +status: completed +created: 2026-06-05 +--- + +# Trade Income Events Command + +## Context +- Trading commands are registered in `internal/modules/trading/trading.go`. +- Trading state is per-user KV portfolio in `internal/modules/trading/portfolio.go`. +- Current market data client style is stdlib HTTP with injectable URL/client. +- FireAnt is the default income-event provider; the bot calls `/symbols/{symbol}/timescale-marks` with `startDate` and `endDate`. + +## Requirements +- Add `/trade_income_events [TICKER]`. +- With ticker: show recent income/right events for that stock. +- Without ticker: check all non-zero stock holdings in current user's portfolio. +- Recent means last 30 days by FireAnt mark date. +- Do not mutate portfolio. + +## Implementation +1. Add FireAnt timescale-mark client and renderer. +2. Register command in trading module. +3. Add read-only handler. +4. Add focused unit tests. +5. Run `gofmt` and `go test ./internal/modules/trading`. + +## Status +- [x] Scout existing trading module. +- [x] Select FireAnt configuration path. +- [x] Implement command. +- [x] Test command support. +- [x] Review changes. + +## Unresolved Questions +- None. diff --git a/template.yaml b/template.yaml index 7cda82c..3d7aeb2 100644 --- a/template.yaml +++ b/template.yaml @@ -27,6 +27,16 @@ Parameters: Default: "" Description: Comma-separated Telegram user IDs allowed to use admin commands. + TradingIncomeEventsAPIURL: + Type: String + Default: "https://restv2.fireant.vn" + Description: FireAnt REST API base URL. Defaults to https://restv2.fireant.vn when omitted. + + TradingIncomeEventsAPITokenParameterName: + Type: String + Default: "" + Description: Optional SSM SecureString parameter name containing bearer token for FireAnt REST API. + # AWS Lambda Web Adapter ARM64 layer ARN. Pin a specific version so deploys # are reproducible. Bump by checking the latest at: # https://github.com/awslabs/aws-lambda-web-adapter/releases @@ -130,6 +140,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 # ---- Secrets (fetched from Parameter Store at Lambda cold start) ---- TELEGRAM_BOT_TOKEN_PARAMETER_NAME: !Sub "/miti99bot/${StackEnv}/telegram-bot-token" TELEGRAM_WEBHOOK_SECRET_PARAMETER_NAME: !Sub "/miti99bot/${StackEnv}/telegram-webhook-secret"