mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-18 10:23:34 +00:00
feat: add trade income events check
This commit is contained in:
+50
-32
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <qty> <TICKER>\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 <amount_per_share> <TICKER>\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
|
||||
|
||||
@@ -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 <TICKER>")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user