mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-10 04:22:19 +00:00
feat(stock): add stock events lookup
This commit is contained in:
@@ -37,6 +37,15 @@ Future commands must follow the
|
||||
[command parameter conventions](docs/command-parameter-conventions.md). Keep
|
||||
command metadata, handler usage text, tests, and documentation aligned.
|
||||
|
||||
### Stock corporate events
|
||||
|
||||
`/stock_events <ticker> [days]` lists SSI iBoard corporate actions for a VN
|
||||
stock without reading or changing a portfolio. The lookback defaults to 30
|
||||
days; `days` must be a whole number from 1 to 90. Results are returned in
|
||||
chronological order, split into Telegram-safe chunks when needed, and show the
|
||||
raw SSI corporate-action details. The feature is best-effort because SSI's API
|
||||
is undocumented.
|
||||
|
||||
### Stock dividend commands
|
||||
|
||||
Stock dividends are manual portfolio adjustments:
|
||||
|
||||
@@ -69,6 +69,7 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
|
||||
"loldle": "[champion]",
|
||||
"random": "<option,...>",
|
||||
"stats": "[users | user <username> | cmd <command_name>]",
|
||||
"stock_events": "<ticker> [days]",
|
||||
"stock_price": "<ticker>",
|
||||
"stock_topup": "<vnd_amount>",
|
||||
"stock_buy": "<quantity> <ticker>",
|
||||
|
||||
@@ -111,30 +111,100 @@ func (p *SSIDividendProvider) endpoint() string {
|
||||
// described share dividends. SSI is queried with a one-calendar-day overlap in
|
||||
// Asia/Saigon, then results are filtered by publication time to (after, through].
|
||||
func (p *SSIDividendProvider) FetchDividendEvents(ctx context.Context, symbol string, after, through time.Time) ([]DividendEvent, error) {
|
||||
symbol = strings.ToUpper(strings.TrimSpace(symbol))
|
||||
if symbol == "" {
|
||||
return nil, errors.New("stock: dividend symbol is empty")
|
||||
}
|
||||
if after.IsZero() || through.IsZero() || !through.After(after) {
|
||||
return nil, errors.New("stock: invalid dividend event range")
|
||||
symbol, from, to, err := prepareSSIEventFetch(
|
||||
symbol,
|
||||
after,
|
||||
through,
|
||||
"stock: dividend symbol is empty",
|
||||
"stock: invalid dividend event range",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
from := after.In(saigonLocation).AddDate(0, 0, -1)
|
||||
to := through.In(saigonLocation)
|
||||
rawEvents, err := p.fetchUniqueCorporateActions(ctx, symbol, from, to, "dividend")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lowerBound := startOfSaigonDay(after).AddDate(0, 0, -1)
|
||||
seen := make(map[string]struct{})
|
||||
events := make([]DividendEvent, 0)
|
||||
for _, raw := range rawEvents {
|
||||
event, ok := p.normalizeEvent(raw, symbol)
|
||||
if !ok || event.PublishedAt.Before(lowerBound) || event.PublishedAt.After(through) {
|
||||
continue
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
sort.Slice(events, func(i, j int) bool {
|
||||
if events[i].PublishedAt.Equal(events[j].PublishedAt) {
|
||||
return events[i].ProviderID < events[j].ProviderID
|
||||
}
|
||||
return events[i].PublishedAt.Before(events[j].PublishedAt)
|
||||
})
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// FetchStockEvents returns all displayable SSI corporate actions. It is
|
||||
// intentionally separate from FetchDividendEvents so read-only event listing
|
||||
// cannot relax portfolio dividend validation.
|
||||
func (p *SSIDividendProvider) FetchStockEvents(ctx context.Context, symbol string, after, through time.Time) ([]SSIStockEvent, error) {
|
||||
symbol, from, to, err := prepareSSIEventFetch(
|
||||
symbol,
|
||||
after,
|
||||
through,
|
||||
"stock: event symbol is empty",
|
||||
"stock: invalid stock event range",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawEvents, err := p.fetchUniqueCorporateActions(ctx, symbol, from, to, "event")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events := make([]SSIStockEvent, 0)
|
||||
for _, raw := range rawEvents {
|
||||
event, ok := p.copyStockEvent(raw, symbol)
|
||||
if !ok || !event.cursorAt.After(after) || event.cursorAt.After(through) {
|
||||
continue
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
sort.Slice(events, func(i, j int) bool {
|
||||
if events[i].cursorAt.Equal(events[j].cursorAt) {
|
||||
return events[i].CorID < events[j].CorID
|
||||
}
|
||||
return events[i].cursorAt.Before(events[j].cursorAt)
|
||||
})
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func prepareSSIEventFetch(symbol string, after, through time.Time, emptyErr, rangeErr string) (normalized string, from, to time.Time, err error) {
|
||||
normalized = strings.ToUpper(strings.TrimSpace(symbol))
|
||||
if normalized == "" {
|
||||
return "", time.Time{}, time.Time{}, errors.New(emptyErr)
|
||||
}
|
||||
if after.IsZero() || through.IsZero() || !through.After(after) {
|
||||
return "", time.Time{}, time.Time{}, errors.New(rangeErr)
|
||||
}
|
||||
return normalized, after.In(saigonLocation).AddDate(0, 0, -1), through.In(saigonLocation), nil
|
||||
}
|
||||
|
||||
func (p *SSIDividendProvider) fetchUniqueCorporateActions(ctx context.Context, symbol string, from, to time.Time, kind string) ([]ssiDividendEvent, error) {
|
||||
seen := make(map[string]struct{})
|
||||
events := make([]ssiDividendEvent, 0)
|
||||
totalPages := 1
|
||||
for page := 1; page <= totalPages; page++ {
|
||||
if page > ssiDividendMaxPages {
|
||||
return nil, fmt.Errorf("stock: SSI dividend response exceeds %d pages", ssiDividendMaxPages)
|
||||
return nil, fmt.Errorf("stock: SSI %s response exceeds %d pages", kind, ssiDividendMaxPages)
|
||||
}
|
||||
body, err := p.fetchPage(ctx, symbol, from, to, page)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stock: fetch SSI dividend page %d: %w", page, err)
|
||||
return nil, fmt.Errorf("stock: fetch SSI %s page %d: %w", kind, page, err)
|
||||
}
|
||||
if body.Paging.TotalPage > ssiDividendMaxPages {
|
||||
return nil, fmt.Errorf("stock: SSI dividend response has %d pages, maximum is %d", body.Paging.TotalPage, ssiDividendMaxPages)
|
||||
return nil, fmt.Errorf("stock: SSI %s response has %d pages, maximum is %d", kind, body.Paging.TotalPage, ssiDividendMaxPages)
|
||||
}
|
||||
if body.Paging.TotalPage > totalPages {
|
||||
totalPages = body.Paging.TotalPage
|
||||
@@ -148,22 +218,51 @@ func (p *SSIDividendProvider) FetchDividendEvents(ctx context.Context, symbol st
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
event, ok := p.normalizeEvent(raw, symbol)
|
||||
if !ok || event.PublishedAt.Before(lowerBound) || event.PublishedAt.After(through) {
|
||||
continue
|
||||
}
|
||||
events = append(events, event)
|
||||
events = append(events, raw)
|
||||
}
|
||||
}
|
||||
sort.Slice(events, func(i, j int) bool {
|
||||
if events[i].PublishedAt.Equal(events[j].PublishedAt) {
|
||||
return events[i].ProviderID < events[j].ProviderID
|
||||
}
|
||||
return events[i].PublishedAt.Before(events[j].PublishedAt)
|
||||
})
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (p *SSIDividendProvider) copyStockEvent(raw ssiDividendEvent, requestedSymbol string) (SSIStockEvent, bool) {
|
||||
symbol := strings.ToUpper(strings.TrimSpace(raw.Symbol))
|
||||
if symbol != requestedSymbol {
|
||||
return SSIStockEvent{}, false
|
||||
}
|
||||
cursorAt, ok := ssiStockEventCursor(raw)
|
||||
if !ok {
|
||||
return SSIStockEvent{}, false
|
||||
}
|
||||
return SSIStockEvent{
|
||||
CorID: strings.TrimSpace(raw.CorID),
|
||||
Symbol: symbol,
|
||||
EventListCode: strings.TrimSpace(raw.EventListCode),
|
||||
EventName: strings.TrimSpace(raw.EventName),
|
||||
EventTitle: strings.TrimSpace(raw.EventTitle),
|
||||
EventDescription: strings.TrimSpace(raw.EventDescription),
|
||||
PublicDate: strings.TrimSpace(raw.PublicDate),
|
||||
ExrightDate: strings.TrimSpace(raw.ExrightDate),
|
||||
RecordDate: strings.TrimSpace(raw.RecordDate),
|
||||
IssueDate: strings.TrimSpace(raw.IssueDate),
|
||||
Value: strings.TrimSpace(string(raw.Value)),
|
||||
Ratio: strings.TrimSpace(string(raw.Ratio)),
|
||||
SourceURL: p.eventSourceURL(symbol, raw.CorID),
|
||||
cursorAt: cursorAt,
|
||||
}, true
|
||||
}
|
||||
|
||||
func ssiStockEventCursor(raw ssiDividendEvent) (time.Time, bool) {
|
||||
if strings.TrimSpace(raw.PublicDate) != "" {
|
||||
return parseSSIDate(raw.PublicDate)
|
||||
}
|
||||
for _, candidate := range []string{raw.ExrightDate, raw.RecordDate, raw.IssueDate} {
|
||||
if parsed, ok := parseSSIDate(candidate); ok {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func startOfSaigonDay(value time.Time) time.Time {
|
||||
local := value.In(saigonLocation)
|
||||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, saigonLocation)
|
||||
|
||||
@@ -79,6 +79,90 @@ func TestSSIDividendProviderPaginatesDeduplicatesFiltersAndNormalizes(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSIStockEventProviderIncludesAllTypesPaginatesDeduplicatesFiltersAndOrders(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch page {
|
||||
case 1:
|
||||
_, _ = fmt.Fprint(w, `{"code":"SUCCESS","status":"ok","paging":{"totalPage":2,"page":1},"data":[
|
||||
{"CorId":"meeting","symbol":"TCB","eventListCode":"AGM","eventName":"Annual meeting","eventTitle":"Meeting title","eventDescription":"Meeting description","publicDate":"11/06/2026 12:00:00","recordDate":"malformed-raw-date","value":"123.45","ratio":"0.25"},
|
||||
{"CorId":"tie-b","symbol":"TCB","eventListCode":"OTHER","eventTitle":"Second by ID","publicDate":"12/06/2026 00:00:00"},
|
||||
{"CorId":"at-after","symbol":"TCB","eventListCode":"OTHER","publicDate":"10/06/2026 12:00:00"},
|
||||
{"CorId":"overlap-only","symbol":"TCB","eventListCode":"OTHER","publicDate":"10/06/2026 00:00:00"},
|
||||
{"CorId":"too-old","symbol":"TCB","eventListCode":"OTHER","publicDate":"08/06/2026 23:59:59"},
|
||||
{"CorId":"wrong-symbol","symbol":"ACB","eventListCode":"AGM","publicDate":"11/06/2026"}
|
||||
]}`)
|
||||
case 2:
|
||||
_, _ = fmt.Fprint(w, `{"code":"SUCCESS","status":"ok","paging":{"totalPage":2,"page":2},"data":[
|
||||
{"CorId":"meeting","symbol":"TCB","eventListCode":"AGM","eventTitle":"duplicate","publicDate":"11/06/2026 12:00:00"},
|
||||
{"CorId":"tie-a","symbol":"TCB","eventListCode":"ISS","eventName":"Rights issue","eventTitle":"First by ID","publicDate":"12/06/2026 00:00:00","exrightDate":"20/06/2026","issueDate":"30/06/2026"},
|
||||
{"CorId":"after-through","symbol":"TCB","eventListCode":"OTHER","publicDate":"12/06/2026 00:00:01"},
|
||||
{"CorId":"bad-date","symbol":"TCB","eventListCode":"OTHER","publicDate":"not-a-date","exrightDate":"11/06/2026"},
|
||||
{"CorId":"fallback-date","symbol":"TCB","eventListCode":"FALLBACK","eventDescription":"raw fallback event","exrightDate":"bad-optional","recordDate":"11/06/2026","issueDate":"also-bad"}
|
||||
]}`)
|
||||
default:
|
||||
t.Fatalf("unexpected page %d", page)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := &SSIDividendProvider{HTTP: server.Client(), BaseURL: server.URL}
|
||||
events, err := provider.FetchStockEvents(context.Background(), " tcb ", saigonTime(t, "10/06/2026 12:00:00"), saigonTime(t, "12/06/2026 00:00:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("FetchStockEvents: %v", err)
|
||||
}
|
||||
if len(events) != 4 {
|
||||
t.Fatalf("events = %+v, want fallback, meeting, and two tie events", events)
|
||||
}
|
||||
if events[0].CorID != "fallback-date" || events[0].PublicDate != "" || events[0].ExrightDate != "bad-optional" || events[0].RecordDate != "11/06/2026" {
|
||||
t.Fatalf("fallback cursor/raw fields = %+v", events[0])
|
||||
}
|
||||
if events[1].CorID != "meeting" || events[1].EventListCode != "AGM" || events[1].EventName != "Annual meeting" || events[1].EventTitle != "Meeting title" || events[1].EventDescription != "Meeting description" || events[1].RecordDate != "malformed-raw-date" || events[1].Value != "123.45" || events[1].Ratio != "0.25" {
|
||||
t.Fatalf("generic non-dividend raw event = %+v", events[1])
|
||||
}
|
||||
if events[2].CorID != "tie-a" || events[3].CorID != "tie-b" {
|
||||
t.Fatalf("tie order = %q, %q", events[2].CorID, events[3].CorID)
|
||||
}
|
||||
if events[2].ExrightDate != "20/06/2026" || events[2].IssueDate != "30/06/2026" || events[2].SourceURL == "" {
|
||||
t.Fatalf("raw event dates/source missing: %+v", events[2])
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.CorID == "bad-date" {
|
||||
t.Fatalf("malformed publicDate was accepted via optional fallback: %+v", event)
|
||||
}
|
||||
}
|
||||
|
||||
dividends, err := provider.FetchDividendEvents(context.Background(), "TCB", saigonTime(t, "10/06/2026 12:00:00"), saigonTime(t, "12/06/2026 00:00:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("FetchDividendEvents: %v", err)
|
||||
}
|
||||
if len(dividends) != 0 {
|
||||
t.Fatalf("generic events leaked into dividend path: %+v", dividends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSIStockEventProviderRequiresEveryPageAndValidRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("page") == "2" {
|
||||
http.Error(w, "upstream unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `{"code":"SUCCESS","status":"ok","paging":{"totalPage":2,"page":1},"data":[]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
provider := &SSIDividendProvider{HTTP: server.Client(), BaseURL: server.URL}
|
||||
now := saigonTime(t, "12/06/2026")
|
||||
if events, err := provider.FetchStockEvents(context.Background(), "TCB", now.Add(-24*time.Hour), now); err == nil || events != nil {
|
||||
t.Fatalf("partial events, err = %+v, %v", events, err)
|
||||
}
|
||||
if _, err := provider.FetchStockEvents(context.Background(), "TCB", now, now); err == nil {
|
||||
t.Fatal("equal range accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSIDividendProviderUsesDayOverlapAndDateFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := dividendTestServer(t, http.StatusOK, `{"code":"SUCCESS","status":"ok","paging":{"totalPage":1,"page":1},"data":[
|
||||
|
||||
@@ -25,6 +25,7 @@ type state struct {
|
||||
pending PendingDividendStore
|
||||
prices *PriceClient
|
||||
dividends DividendEventProvider
|
||||
events SSIStockEventProvider
|
||||
locks keylock.Map
|
||||
nowFn func() time.Time
|
||||
newDividendToken func() (string, error)
|
||||
@@ -39,11 +40,13 @@ func (s *state) now() time.Time {
|
||||
|
||||
// newState builds the default state used by the module factory.
|
||||
func newState(store Store, pending PendingDividendStore) *state {
|
||||
ssi := &SSIDividendProvider{}
|
||||
return &state{
|
||||
store: store,
|
||||
pending: pending,
|
||||
prices: &PriceClient{},
|
||||
dividends: &SSIDividendProvider{},
|
||||
dividends: ssi,
|
||||
events: ssi,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ func TestModuleRegistersExpectedCommands(t *testing.T) {
|
||||
}
|
||||
for _, name := range []string{
|
||||
"stock_price",
|
||||
"stock_events",
|
||||
"stock_topup",
|
||||
"stock_buy",
|
||||
"stock_sell",
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
// New is the stock module Factory. Seven user-facing commands.
|
||||
// New is the stock module Factory. Eight user-facing commands.
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
s := newState(
|
||||
storage.Typed[Portfolio](deps.Store),
|
||||
@@ -14,6 +14,13 @@ func New(deps modules.Deps) modules.Module {
|
||||
return modules.Module{
|
||||
Callbacks: []modules.Callback{{Prefix: dividendCallbackPrefix, Visibility: modules.VisibilityPublic, Handler: s.handleDividendCallback}},
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "stock_events",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show SSI corporate actions for a VN stock",
|
||||
Parameters: "<ticker> [days]",
|
||||
Handler: s.handleStockEvents,
|
||||
},
|
||||
{
|
||||
Name: "stock_price",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package stock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"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 (
|
||||
stockEventsDefaultDays = 30
|
||||
stockEventsMaxDays = 90
|
||||
stockEventsReplyLimit = 4000
|
||||
stockEventTitleLimit = 500
|
||||
)
|
||||
|
||||
// SSIStockEventProvider returns SSI corporate-action fields for display. It is
|
||||
// separate from DividendEventProvider and does not imply portfolio semantics.
|
||||
type SSIStockEventProvider interface {
|
||||
FetchStockEvents(ctx context.Context, symbol string, after, through time.Time) ([]SSIStockEvent, error)
|
||||
}
|
||||
|
||||
// SSIStockEvent preserves SSI's raw corporate-action fields. cursorAt is used
|
||||
// only for range filtering and deterministic ordering; it is not displayed in
|
||||
// place of the source strings.
|
||||
type SSIStockEvent struct {
|
||||
CorID string
|
||||
Symbol string
|
||||
EventListCode string
|
||||
EventName string
|
||||
EventTitle string
|
||||
EventDescription string
|
||||
PublicDate string
|
||||
ExrightDate string
|
||||
RecordDate string
|
||||
IssueDate string
|
||||
Value string
|
||||
Ratio string
|
||||
SourceURL string
|
||||
|
||||
cursorAt time.Time
|
||||
}
|
||||
|
||||
func (s *state) handleStockEvents(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
args := argsAfterCommand(update.Message.Text)
|
||||
if len(args) < 1 || len(args) > 2 {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_events <ticker> [days]")
|
||||
}
|
||||
|
||||
days := stockEventsDefaultDays
|
||||
if len(args) == 2 {
|
||||
parsed, err := strconv.Atoi(args[1])
|
||||
if err != nil || parsed < 1 || parsed > stockEventsMaxDays {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Days must be a whole number from 1 to 90.")
|
||||
}
|
||||
days = parsed
|
||||
}
|
||||
|
||||
symbol, err := normalizeStockSymbol(args[0])
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnknownTicker) {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Unknown stock ticker \""+strings.ToUpper(args[0])+"\".")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not parse that ticker. Try again later.")
|
||||
}
|
||||
|
||||
through := s.now()
|
||||
after := through.Add(-time.Duration(days) * 24 * time.Hour)
|
||||
fetchCtx, cancel := chathelper.FetchContext(ctx)
|
||||
defer cancel()
|
||||
events, err := s.events.FetchStockEvents(fetchCtx, symbol, after, through)
|
||||
if err != nil {
|
||||
log.Error("stock_fetch_events", "ticker", symbol, "days", days, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not fetch stock events for "+symbol+". Try again later.")
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return chathelper.Reply(ctx, b, update.Message, fmt.Sprintf("No stock events found for %s in the last %d days.", symbol, days))
|
||||
}
|
||||
|
||||
blocks := make([]string, 0, len(events))
|
||||
for _, event := range events {
|
||||
blocks = append(blocks, formatStockEvent(event))
|
||||
}
|
||||
for _, reply := range chunkStockEventReplies(symbol, blocks) {
|
||||
if err := chathelper.Reply(ctx, b, update.Message, reply); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatStockEvent(event SSIStockEvent) string {
|
||||
code := truncateRunes(strings.TrimSpace(event.EventListCode), 80)
|
||||
if code == "" {
|
||||
code = "Corporate action"
|
||||
}
|
||||
lines := []string{event.Symbol + " SSI event · " + code}
|
||||
if name := truncateRunes(strings.TrimSpace(event.EventName), 240); name != "" {
|
||||
lines = append(lines, "Name: "+name)
|
||||
}
|
||||
if title := truncateRunes(strings.TrimSpace(event.EventTitle), stockEventTitleLimit); title != "" {
|
||||
lines = append(lines, "Title: "+title)
|
||||
}
|
||||
if description := truncateRunes(strings.TrimSpace(event.EventDescription), 700); description != "" {
|
||||
lines = append(lines, "Description: "+description)
|
||||
}
|
||||
if value := truncateRunes(strings.TrimSpace(event.Value), 160); value != "" {
|
||||
lines = append(lines, "Value: "+value)
|
||||
}
|
||||
if ratio := truncateRunes(strings.TrimSpace(event.Ratio), 160); ratio != "" {
|
||||
lines = append(lines, "Ratio: "+ratio)
|
||||
}
|
||||
for _, field := range []struct {
|
||||
label string
|
||||
value string
|
||||
}{
|
||||
{"Published", event.PublicDate},
|
||||
{"Ex-right", event.ExrightDate},
|
||||
{"Record", event.RecordDate},
|
||||
{"Issue/payment", event.IssueDate},
|
||||
} {
|
||||
if value := truncateRunes(strings.TrimSpace(field.value), 100); value != "" {
|
||||
lines = append(lines, field.label+": "+value)
|
||||
}
|
||||
}
|
||||
lines = append(lines, "SSI event: "+event.CorID)
|
||||
if source := strings.TrimSpace(event.SourceURL); source != "" {
|
||||
lines = append(lines, "Source: "+source)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func chunkStockEventReplies(symbol string, blocks []string) []string {
|
||||
// Reserve enough space for the heading and part numbering so every final
|
||||
// reply remains below Telegram's 4000-character safety margin.
|
||||
const bodyLimit = stockEventsReplyLimit - 80
|
||||
chunks := make([]string, 0, 1)
|
||||
current := ""
|
||||
for _, block := range blocks {
|
||||
if utf8.RuneCountInString(block) > bodyLimit {
|
||||
block = truncateRunes(block, bodyLimit-16) + "\n…(truncated)"
|
||||
}
|
||||
candidate := block
|
||||
if current != "" {
|
||||
candidate = current + "\n\n" + block
|
||||
}
|
||||
if utf8.RuneCountInString(candidate) > bodyLimit && current != "" {
|
||||
chunks = append(chunks, current)
|
||||
current = block
|
||||
} else {
|
||||
current = candidate
|
||||
}
|
||||
}
|
||||
if current != "" {
|
||||
chunks = append(chunks, current)
|
||||
}
|
||||
|
||||
replies := make([]string, len(chunks))
|
||||
for i, chunk := range chunks {
|
||||
heading := symbol + " events"
|
||||
if len(chunks) > 1 {
|
||||
heading += fmt.Sprintf(" (%d/%d)", i+1, len(chunks))
|
||||
}
|
||||
replies[i] = heading + "\n\n" + chunk
|
||||
}
|
||||
return replies
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package stock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/testutil"
|
||||
)
|
||||
|
||||
type stockEventProviderFunc func(context.Context, string, time.Time, time.Time) ([]SSIStockEvent, error)
|
||||
|
||||
func (f stockEventProviderFunc) FetchStockEvents(ctx context.Context, symbol string, after, through time.Time) ([]SSIStockEvent, error) {
|
||||
return f(ctx, symbol, after, through)
|
||||
}
|
||||
|
||||
func TestStockEventsCommandRegistration(t *testing.T) {
|
||||
mod := New(modDepsForTest())
|
||||
for _, command := range mod.Commands {
|
||||
if command.Name != "stock_events" {
|
||||
continue
|
||||
}
|
||||
if command.Parameters != "<ticker> [days]" || command.Description != "Show SSI corporate actions for a VN stock" {
|
||||
t.Fatalf("stock_events metadata = params %q description %q", command.Parameters, command.Description)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("stock_events command is not registered")
|
||||
}
|
||||
|
||||
func TestHandleStockEventsDefaultWindowAndSenderless(t *testing.T) {
|
||||
now := time.Date(2026, 7, 23, 15, 30, 0, 0, saigonLocation)
|
||||
called := false
|
||||
s := &state{
|
||||
nowFn: func() time.Time { return now },
|
||||
events: stockEventProviderFunc(func(_ context.Context, symbol string, after, through time.Time) ([]SSIStockEvent, error) {
|
||||
called = true
|
||||
if symbol != "TCB" || !through.Equal(now) || !after.Equal(now.Add(-30*24*time.Hour)) {
|
||||
t.Errorf("provider args = %q, %v, %v", symbol, after, through)
|
||||
}
|
||||
return []SSIStockEvent{{
|
||||
CorID: "meeting-1", Symbol: "TCB", EventListCode: "AGM", EventName: "Annual meeting",
|
||||
EventTitle: "Shareholder meeting", PublicDate: "23/07/2026 14:30:00", SourceURL: "https://ssi.example/event",
|
||||
}}, nil
|
||||
}),
|
||||
}
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handleStockEvents(context.Background(), rb.Bot, testutil.NewChannelMessage(-100, "/stock_events tcb")); err != nil {
|
||||
t.Fatalf("handleStockEvents: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("provider was not called")
|
||||
}
|
||||
rb.AssertSentText(t, "TCB SSI event · AGM")
|
||||
rb.AssertSentText(t, "Name: Annual meeting")
|
||||
rb.AssertSentText(t, "SSI event: meeting-1")
|
||||
}
|
||||
|
||||
func TestHandleStockEventsExplicitWindowAndValidation(t *testing.T) {
|
||||
now := time.Date(2026, 7, 23, 15, 30, 0, 0, saigonLocation)
|
||||
s := &state{
|
||||
nowFn: func() time.Time { return now },
|
||||
events: stockEventProviderFunc(func(_ context.Context, _ string, after, through time.Time) ([]SSIStockEvent, error) {
|
||||
if !after.Equal(now.Add(-7*24*time.Hour)) || !through.Equal(now) {
|
||||
t.Fatalf("window = %v..%v", after, through)
|
||||
}
|
||||
return nil, nil
|
||||
}),
|
||||
}
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handleStockEvents(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_events TCB 7")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rb.AssertSentText(t, "No stock events found for TCB in the last 7 days.")
|
||||
|
||||
for _, tc := range []struct {
|
||||
command string
|
||||
want string
|
||||
}{
|
||||
{"/stock_events", "Usage: /stock_events <ticker> [days]"},
|
||||
{"/stock_events TCB 7 extra", "Usage: /stock_events <ticker> [days]"},
|
||||
{"/stock_events TCB 0", "Days must be a whole number from 1 to 90."},
|
||||
{"/stock_events TCB 91", "Days must be a whole number from 1 to 90."},
|
||||
{"/stock_events TCB 1.5", "Days must be a whole number from 1 to 90."},
|
||||
{"/stock_events $$$", "Unknown stock ticker \"$$$\"."},
|
||||
} {
|
||||
rb.Reset()
|
||||
if err := s.handleStockEvents(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, tc.command)); err != nil {
|
||||
t.Fatalf("%q: %v", tc.command, err)
|
||||
}
|
||||
rb.AssertSentText(t, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStockEventsProviderError(t *testing.T) {
|
||||
s := &state{
|
||||
events: stockEventProviderFunc(func(context.Context, string, time.Time, time.Time) ([]SSIStockEvent, error) {
|
||||
return nil, errors.New("upstream unavailable")
|
||||
}),
|
||||
}
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handleStockEvents(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_events TCB")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rb.AssertSentText(t, "Could not fetch stock events for TCB. Try again later.")
|
||||
}
|
||||
|
||||
func TestStockEventFormattingAndChunking(t *testing.T) {
|
||||
unicodeTitle := strings.Repeat("ổ", stockEventTitleLimit+50)
|
||||
block := formatStockEvent(SSIStockEvent{
|
||||
CorID: "event-1", Symbol: "TCB", EventListCode: "ISS", EventName: "Issue",
|
||||
EventTitle: unicodeTitle, EventDescription: "Raw API description", Value: "1500.25", Ratio: "0.125",
|
||||
PublicDate: "20/07/2026 10:00:00", ExrightDate: "malformed-but-displayed", RecordDate: "22/07/2026",
|
||||
IssueDate: "30/07/2026", SourceURL: "https://ssi.example/event-1",
|
||||
})
|
||||
if !strings.Contains(block, "Title: "+strings.Repeat("ổ", stockEventTitleLimit-1)+"…") {
|
||||
t.Fatal("title was not truncated rune-safely")
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"Description: Raw API description", "Value: 1500.25", "Ratio: 0.125",
|
||||
"Published: 20/07/2026 10:00:00", "Ex-right: malformed-but-displayed", "Record: 22/07/2026",
|
||||
"Issue/payment: 30/07/2026", "Source: https://ssi.example/event-1",
|
||||
} {
|
||||
if !strings.Contains(block, expected) {
|
||||
t.Errorf("block missing %q: %q", expected, block)
|
||||
}
|
||||
}
|
||||
|
||||
blocks := []string{strings.Repeat("a", 2500), strings.Repeat("b", 2500), "final-event"}
|
||||
replies := chunkStockEventReplies("TCB", blocks)
|
||||
if len(replies) != 2 {
|
||||
t.Fatalf("reply count = %d, want 2", len(replies))
|
||||
}
|
||||
for i, reply := range replies {
|
||||
if utf8.RuneCountInString(reply) >= stockEventsReplyLimit {
|
||||
t.Errorf("reply %d length = %d", i, utf8.RuneCountInString(reply))
|
||||
}
|
||||
}
|
||||
if !strings.Contains(replies[0], "(1/2)") || !strings.Contains(replies[1], "(2/2)") || !strings.HasSuffix(replies[1], "final-event") {
|
||||
t.Fatalf("chunk ordering/headings = %#v", replies)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user