mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-10-03 07:13:24 +00:00
refactor(stock): rename trading module to stock
Rename the `trading` module to `stock` across code, config, and docs for naming consistency with the sibling coin/gold modules. - Module: internal/modules/trading -> internal/modules/stock (package, factory, registry key in cmd/server) - Commands: trade_* -> stock_* (telegram-commands.json) - Env/SAM params: TRADING_INCOME_EVENTS_* -> STOCK_INCOME_EVENTS_* - ModulesCSV updated in template.yaml, samconfig.toml, deploy.yml - Add cmd/migrate-stock-key: idempotent, non-destructive DynamoDB partition copy (pk=trading -> pk=stock) with dry-run default Production DynamoDB data already migrated (8 keys: 2 portfolios + 6 symbol-cache rows); old trading partition retained as rollback snapshot.
This commit is contained in:
1 parent
8b4690df6a
commit
e6ad2891dd
24 files changed
+362
-168
No files matched your search
@@ -3,7 +3,7 @@ package gold
|
||||
import "github.com/tiennm99/miti99bot/internal/modules"
|
||||
|
||||
// New is the gold paper-trading module factory. It is opt-in through MODULES
|
||||
// and keeps its portfolio state separate from the VN-stocks trading module.
|
||||
// and keeps its portfolio state separate from the stock module.
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
s := newState(deps.KV)
|
||||
return modules.Module{
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
stocktrading "github.com/tiennm99/miti99bot/internal/modules/trading"
|
||||
stockmod "github.com/tiennm99/miti99bot/internal/modules/stock"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
@@ -241,7 +241,7 @@ func TestUpdatePortfolioFailsFastWhenCASUnsupported(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTradingAndGoldPortfolioKeysDoNotCollide(t *testing.T) {
|
||||
func TestStockAndGoldPortfolioKeysDoNotCollide(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
provider := storage.NewMemoryProvider()
|
||||
goldPortfolio := NewPortfolio(1)
|
||||
@@ -249,16 +249,16 @@ func TestTradingAndGoldPortfolioKeysDoNotCollide(t *testing.T) {
|
||||
if err := SavePortfolio(ctx, provider.For("gold"), 7, goldPortfolio); err != nil {
|
||||
t.Fatalf("save gold: %v", err)
|
||||
}
|
||||
tradingPortfolio := stocktrading.NewPortfolio(1)
|
||||
tradingPortfolio.AddAsset("TCB", 100)
|
||||
if err := stocktrading.SavePortfolio(ctx, provider.For("trading"), 7, tradingPortfolio); err != nil {
|
||||
t.Fatalf("save trading: %v", err)
|
||||
stockPortfolio := stockmod.NewPortfolio(1)
|
||||
stockPortfolio.AddAsset("TCB", 100)
|
||||
if err := stockmod.SavePortfolio(ctx, provider.For("stock"), 7, stockPortfolio); err != nil {
|
||||
t.Fatalf("save stock: %v", err)
|
||||
}
|
||||
keys, err := provider.Base().List(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
want := map[string]bool{"gold:user:7": false, "trading:user:7": false}
|
||||
want := map[string]bool{"gold:user:7": false, "stock:user:7": false}
|
||||
for _, key := range keys {
|
||||
if _, ok := want[key]; ok {
|
||||
want[key] = true
|
||||
|
||||
@@ -87,7 +87,7 @@ func mstatsCommand(deps modules.Deps) modules.Command {
|
||||
text = fmt.Sprintf("last ping: %s",
|
||||
time.UnixMilli(last.At).UTC().Format(time.RFC3339))
|
||||
case err != nil && !errors.Is(err, storage.ErrNotFound):
|
||||
// User-visible reply mirrors how trading/wordle/loldle handle
|
||||
// User-visible reply mirrors how stock/wordle/loldle handle
|
||||
// transient KV failures — returning the error here would leave
|
||||
// the user with no reply at all.
|
||||
log.Error("kv get failed", "module", "misc", "command", "mstats", "key", lastPingKey, "err", err)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Package trading is a paper-trading module for VN stocks. Per-user
|
||||
// Package stock is a paper-stock module for VN stocks. Per-user
|
||||
// portfolio + buy/sell at market price + stats with P&L. SQL-based trade
|
||||
// history and a retention cron are out of scope today; the current
|
||||
// implementation keeps only the live portfolio in KV.
|
||||
package trading
|
||||
package stock
|
||||
|
||||
import (
|
||||
"math"
|
||||
@@ -1,4 +1,4 @@
|
||||
package trading
|
||||
package stock
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package trading
|
||||
package stock
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -63,7 +63,7 @@ func senderInfo(update *models.Update) (userID int64, ok bool) {
|
||||
}
|
||||
|
||||
// argsAfterCommand splits the command body into whitespace-separated args.
|
||||
// "/trade_buy 100 TCB" → ["100", "TCB"]; "/trade_topup" → []
|
||||
// "/stock_buy 100 TCB" → ["100", "TCB"]; "/stock_topup" → []
|
||||
func argsAfterCommand(text string) []string {
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) <= 1 {
|
||||
@@ -76,11 +76,11 @@ func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Upda
|
||||
userID, ok := senderInfo(update)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Cannot identify user — trading only works in private/group chats with a sender.")
|
||||
"Cannot identify user — stock only works in private/group chats with a sender.")
|
||||
}
|
||||
args := argsAfterCommand(update.Message.Text)
|
||||
if len(args) < 1 {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Usage: /trade_topup <amount>\nExample: /trade_topup 5000000")
|
||||
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_topup <amount>\nExample: /stock_topup 5000000")
|
||||
}
|
||||
amount, err := strconv.ParseFloat(args[0], 64)
|
||||
if err != nil || amount <= 0 {
|
||||
@@ -91,13 +91,13 @@ func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Upda
|
||||
|
||||
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
|
||||
if err != nil {
|
||||
log.Error("trading_load_portfolio", "user", userID, "err", err)
|
||||
log.Error("stock_load_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.")
|
||||
}
|
||||
p.AddCurrency("VND", amount)
|
||||
p.Meta.Invested += amount
|
||||
if err := SavePortfolio(ctx, s.kv, userID, p); err != nil {
|
||||
log.Error("trading_save_portfolio", "user", userID, "err", err)
|
||||
log.Error("stock_save_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
@@ -108,11 +108,11 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
|
||||
userID, ok := senderInfo(update)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Cannot identify user — trading only works in private/group chats with a sender.")
|
||||
"Cannot identify user — stock only works in private/group chats with a sender.")
|
||||
}
|
||||
args := argsAfterCommand(update.Message.Text)
|
||||
if len(args) < 2 {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Usage: /trade_buy <qty> <TICKER>\nExample: /trade_buy 100 TCB")
|
||||
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_buy <qty> <TICKER>\nExample: /stock_buy 100 TCB")
|
||||
}
|
||||
qty, err := strconv.ParseInt(args[0], 10, 64)
|
||||
if err != nil || qty <= 0 {
|
||||
@@ -125,7 +125,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Unknown stock ticker \""+strings.ToUpper(args[1])+"\".\n"+s.comingSoonMessage)
|
||||
}
|
||||
log.Error("trading_resolve_symbol", "ticker", args[1], "err", err)
|
||||
log.Error("stock_resolve_symbol", "ticker", args[1], "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not look up that ticker. Try again later.")
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
|
||||
if errors.Is(err, ErrNoPrice) {
|
||||
return chathelper.Reply(ctx, b, update.Message, "No price available for "+resolved.Symbol+".")
|
||||
}
|
||||
log.Error("trading_fetch_price", "ticker", resolved.Symbol, "err", err)
|
||||
log.Error("stock_fetch_price", "ticker", resolved.Symbol, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not fetch price. Try again later.")
|
||||
}
|
||||
cost := float64(qty) * price
|
||||
@@ -143,7 +143,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
|
||||
|
||||
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
|
||||
if err != nil {
|
||||
log.Error("trading_load_portfolio", "user", userID, "err", err)
|
||||
log.Error("stock_load_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.")
|
||||
}
|
||||
ok, balance := p.DeductCurrency("VND", cost)
|
||||
@@ -153,7 +153,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
|
||||
}
|
||||
p.AddAsset(resolved.Symbol, qty)
|
||||
if err := SavePortfolio(ctx, s.kv, userID, p); err != nil {
|
||||
log.Error("trading_save_portfolio", "user", userID, "err", err)
|
||||
log.Error("stock_save_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
@@ -166,11 +166,11 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
|
||||
userID, ok := senderInfo(update)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Cannot identify user — trading only works in private/group chats with a sender.")
|
||||
"Cannot identify user — stock only works in private/group chats with a sender.")
|
||||
}
|
||||
args := argsAfterCommand(update.Message.Text)
|
||||
if len(args) < 2 {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Usage: /trade_sell <qty> <TICKER>\nExample: /trade_sell 100 TCB")
|
||||
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_sell <qty> <TICKER>\nExample: /stock_sell 100 TCB")
|
||||
}
|
||||
qty, err := strconv.ParseInt(args[0], 10, 64)
|
||||
if err != nil || qty <= 0 {
|
||||
@@ -186,7 +186,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Unknown stock ticker \""+strings.ToUpper(args[1])+"\".")
|
||||
}
|
||||
log.Error("trading_resolve_symbol", "ticker", args[1], "err", err)
|
||||
log.Error("stock_resolve_symbol", "ticker", args[1], "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not look up that ticker. Try again later.")
|
||||
}
|
||||
price, err := s.prices.FetchPrice(ctx, resolved.Symbol)
|
||||
@@ -194,7 +194,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
|
||||
if errors.Is(err, ErrNoPrice) {
|
||||
return chathelper.Reply(ctx, b, update.Message, "No price available for "+resolved.Symbol+".")
|
||||
}
|
||||
log.Error("trading_fetch_price", "ticker", resolved.Symbol, "err", err)
|
||||
log.Error("stock_fetch_price", "ticker", resolved.Symbol, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not fetch price. Try again later.")
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
|
||||
|
||||
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
|
||||
if err != nil {
|
||||
log.Error("trading_load_portfolio", "user", userID, "err", err)
|
||||
log.Error("stock_load_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.")
|
||||
}
|
||||
ok, held := p.DeductAsset(resolved.Symbol, qty)
|
||||
@@ -213,7 +213,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
|
||||
revenue := float64(qty) * price
|
||||
p.AddCurrency("VND", revenue)
|
||||
if err := SavePortfolio(ctx, s.kv, userID, p); err != nil {
|
||||
log.Error("trading_save_portfolio", "user", userID, "err", err)
|
||||
log.Error("stock_save_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
@@ -226,12 +226,12 @@ func (s *state) handleIncomeStock(ctx context.Context, b *bot.Bot, update *model
|
||||
userID, ok := senderInfo(update)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Cannot identify user — trading only works in private/group chats with a sender.")
|
||||
"Cannot identify user — stock only works in private/group chats with a sender.")
|
||||
}
|
||||
args := argsAfterCommand(update.Message.Text)
|
||||
if len(args) < 2 {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Usage: /trade_income_stock <qty> <TICKER>\nExample: /trade_income_stock 200 TCX")
|
||||
"Usage: /stock_income_stock <qty> <TICKER>\nExample: /stock_income_stock 200 TCX")
|
||||
}
|
||||
qty, err := strconv.ParseInt(args[0], 10, 64)
|
||||
if err != nil || qty <= 0 {
|
||||
@@ -244,7 +244,7 @@ func (s *state) handleIncomeStock(ctx context.Context, b *bot.Bot, update *model
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Unknown stock ticker \""+strings.ToUpper(args[1])+"\".")
|
||||
}
|
||||
log.Error("trading_resolve_symbol", "ticker", args[1], "err", err)
|
||||
log.Error("stock_resolve_symbol", "ticker", args[1], "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not look up that ticker. Try again later.")
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ func (s *state) handleIncomeStock(ctx context.Context, b *bot.Bot, update *model
|
||||
|
||||
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
|
||||
if err != nil {
|
||||
log.Error("trading_load_portfolio", "user", userID, "err", err)
|
||||
log.Error("stock_load_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.")
|
||||
}
|
||||
held := p.Assets[resolved.Symbol]
|
||||
@@ -262,7 +262,7 @@ func (s *state) handleIncomeStock(ctx context.Context, b *bot.Bot, update *model
|
||||
}
|
||||
p.AddAsset(resolved.Symbol, qty)
|
||||
if err := SavePortfolio(ctx, s.kv, userID, p); err != nil {
|
||||
log.Error("trading_save_portfolio", "user", userID, "err", err)
|
||||
log.Error("stock_save_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
@@ -274,12 +274,12 @@ func (s *state) handleIncomeVND(ctx context.Context, b *bot.Bot, update *models.
|
||||
userID, ok := senderInfo(update)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Cannot identify user — trading only works in private/group chats with a sender.")
|
||||
"Cannot identify user — stock only works in private/group chats with a sender.")
|
||||
}
|
||||
args := argsAfterCommand(update.Message.Text)
|
||||
if len(args) < 2 {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Usage: /trade_income_vnd <amount_per_share> <TICKER>\nExample: /trade_income_vnd 1500 TCX")
|
||||
"Usage: /stock_income_vnd <amount_per_share> <TICKER>\nExample: /stock_income_vnd 1500 TCX")
|
||||
}
|
||||
amountPerShare, err := strconv.ParseFloat(args[0], 64)
|
||||
if err != nil || amountPerShare <= 0 {
|
||||
@@ -292,7 +292,7 @@ func (s *state) handleIncomeVND(ctx context.Context, b *bot.Bot, update *models.
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Unknown stock ticker \""+strings.ToUpper(args[1])+"\".")
|
||||
}
|
||||
log.Error("trading_resolve_symbol", "ticker", args[1], "err", err)
|
||||
log.Error("stock_resolve_symbol", "ticker", args[1], "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not look up that ticker. Try again later.")
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ func (s *state) handleIncomeVND(ctx context.Context, b *bot.Bot, update *models.
|
||||
|
||||
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
|
||||
if err != nil {
|
||||
log.Error("trading_load_portfolio", "user", userID, "err", err)
|
||||
log.Error("stock_load_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.")
|
||||
}
|
||||
held := p.Assets[resolved.Symbol]
|
||||
@@ -311,7 +311,7 @@ func (s *state) handleIncomeVND(ctx context.Context, b *bot.Bot, update *models.
|
||||
total := amountPerShare * float64(held)
|
||||
p.AddCurrency("VND", total)
|
||||
if err := SavePortfolio(ctx, s.kv, userID, p); err != nil {
|
||||
log.Error("trading_save_portfolio", "user", userID, "err", err)
|
||||
log.Error("stock_save_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
@@ -334,11 +334,11 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
|
||||
userID, ok := senderInfo(update)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Cannot identify user — /trade_stats needs a sender.")
|
||||
"Cannot identify user — /stock_stats needs a sender.")
|
||||
}
|
||||
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
|
||||
if err != nil {
|
||||
log.Error("trading_load_portfolio", "user", userID, "err", err)
|
||||
log.Error("stock_load_portfolio", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.")
|
||||
}
|
||||
|
||||
+18
-18
@@ -1,4 +1,4 @@
|
||||
package trading
|
||||
package stock
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -45,13 +45,13 @@ type IncomeEventClient struct {
|
||||
}
|
||||
|
||||
func NewIncomeEventClientFromEnv() *IncomeEventClient {
|
||||
url := strings.TrimSpace(os.Getenv("TRADING_INCOME_EVENTS_API_URL"))
|
||||
url := strings.TrimSpace(os.Getenv("STOCK_INCOME_EVENTS_API_URL"))
|
||||
if url == "" {
|
||||
url = fireAntIncomeEventsDefaultURL
|
||||
}
|
||||
return &IncomeEventClient{
|
||||
URL: url,
|
||||
Token: strings.TrimSpace(os.Getenv("TRADING_INCOME_EVENTS_API_TOKEN")),
|
||||
Token: strings.TrimSpace(os.Getenv("STOCK_INCOME_EVENTS_API_TOKEN")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,9 +74,9 @@ type fireAntTimescaleMark struct {
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNoIncomeEvents = errors.New("trading: no income events")
|
||||
ErrIncomeEventClientNotConfigured = errors.New("trading: income events API not configured")
|
||||
ErrIncomeEventAuthRequired = errors.New("trading: income events API authentication required")
|
||||
ErrNoIncomeEvents = errors.New("stock: no income events")
|
||||
ErrIncomeEventClientNotConfigured = errors.New("stock: income events API not configured")
|
||||
ErrIncomeEventAuthRequired = errors.New("stock: income events API authentication required")
|
||||
)
|
||||
|
||||
func (c *IncomeEventClient) FetchRecent(ctx context.Context, ticker string, since, until time.Time) ([]IncomeEvent, error) {
|
||||
@@ -99,7 +99,7 @@ func (c *IncomeEventClient) FetchRecent(ctx context.Context, ticker string, sinc
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trading: FireAnt request: %w", err)
|
||||
return nil, fmt.Errorf("stock: FireAnt request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
@@ -117,10 +117,10 @@ func (c *IncomeEventClient) FetchRecent(ctx context.Context, ticker string, sinc
|
||||
func fireAntMarksURL(baseURL, ticker string, since, until time.Time) (string, error) {
|
||||
endpoint, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("trading: parse FireAnt URL: %w", err)
|
||||
return "", fmt.Errorf("stock: parse FireAnt URL: %w", err)
|
||||
}
|
||||
if !isSafeFireAntEndpoint(endpoint) {
|
||||
return "", fmt.Errorf("trading: income events API URL must be https")
|
||||
return "", fmt.Errorf("stock: income events API URL must be https")
|
||||
}
|
||||
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/symbols/" + url.PathEscape(ticker) + "/timescale-marks"
|
||||
q := endpoint.Query()
|
||||
@@ -143,7 +143,7 @@ func isSafeFireAntEndpoint(endpoint *url.URL) bool {
|
||||
func fireAntRequest(ctx context.Context, fullURL, token string) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trading: build FireAnt request: %w", err)
|
||||
return nil, fmt.Errorf("stock: build FireAnt request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "miti99bot")
|
||||
if token != "" {
|
||||
@@ -160,12 +160,12 @@ func decodeFireAntMarks(resp *http.Response) ([]fireAntTimescaleMark, error) {
|
||||
return nil, ErrNoIncomeEvents
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("trading: FireAnt status %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("stock: FireAnt status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var marks []fireAntTimescaleMark
|
||||
if err := json.NewDecoder(resp.Body).Decode(&marks); err != nil {
|
||||
return nil, fmt.Errorf("trading: FireAnt decode: %w", err)
|
||||
return nil, fmt.Errorf("stock: FireAnt decode: %w", err)
|
||||
}
|
||||
return marks, nil
|
||||
}
|
||||
@@ -275,7 +275,7 @@ func (s *state) handleIncomeEvents(ctx context.Context, b *bot.Bot, update *mode
|
||||
userID, ok := senderInfo(update)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Cannot identify user - /trade_income_events needs a sender.")
|
||||
"Cannot identify user - /stock_income_events needs a sender.")
|
||||
}
|
||||
|
||||
args := argsAfterCommand(update.Message.Text)
|
||||
@@ -288,12 +288,12 @@ func (s *state) handleIncomeEvents(ctx context.Context, b *bot.Bot, update *mode
|
||||
}
|
||||
return chathelper.Reply(ctx, b, update.Message, "Unknown stock ticker \""+ticker+"\".")
|
||||
}
|
||||
log.Error("trading_income_events_symbols", "user", userID, "err", err)
|
||||
log.Error("stock_income_events_symbols", "user", userID, "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, "Could not load holdings. Try again later.")
|
||||
}
|
||||
if len(symbols) == 0 {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"You don't hold any stocks yet. Usage: /trade_income_events <TICKER>")
|
||||
"You don't hold any stocks yet. Usage: /stock_income_events <TICKER>")
|
||||
}
|
||||
|
||||
until := s.now().UTC()
|
||||
@@ -310,12 +310,12 @@ func (s *state) handleIncomeEvents(ctx context.Context, b *bot.Bot, update *mode
|
||||
}
|
||||
if errors.Is(err, ErrIncomeEventAuthRequired) {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"FireAnt income events API requires authentication. Set TRADING_INCOME_EVENTS_API_TOKEN or TRADING_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME.")
|
||||
"FireAnt income events API requires authentication. Set STOCK_INCOME_EVENTS_API_TOKEN or STOCK_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME.")
|
||||
}
|
||||
if errors.Is(err, ErrNoIncomeEvents) {
|
||||
continue
|
||||
}
|
||||
log.Error("trading_fetch_income_events", "ticker", symbol, "err", err)
|
||||
log.Error("stock_fetch_income_events", "ticker", symbol, "err", err)
|
||||
failed = append(failed, symbol)
|
||||
continue
|
||||
}
|
||||
@@ -323,7 +323,7 @@ func (s *state) handleIncomeEvents(ctx context.Context, b *bot.Bot, update *mode
|
||||
}
|
||||
if notConfigured {
|
||||
return chathelper.Reply(ctx, b, update.Message,
|
||||
"Income events API is not configured. Set TRADING_INCOME_EVENTS_API_URL or use the FireAnt default.")
|
||||
"Income events API is not configured. Set STOCK_INCOME_EVENTS_API_URL or use the FireAnt default.")
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
if all[i].DeployDate.Equal(all[j].DeployDate) {
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
package trading
|
||||
package stock
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -128,13 +128,13 @@ func installTradingIncomeEvents(t *testing.T, eventBody string, now time.Time) (
|
||||
nowFn: func() time.Time { return now },
|
||||
}
|
||||
cmd := modules.Command{
|
||||
Name: "trade_income_events",
|
||||
Name: "stock_income_events",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "x",
|
||||
Handler: s.handleIncomeEvents,
|
||||
}
|
||||
reg := &modules.Registry{
|
||||
Modules: []modules.Module{{Name: "trading", Commands: []modules.Command{cmd}}},
|
||||
Modules: []modules.Module{{Name: "stock", Commands: []modules.Command{cmd}}},
|
||||
AllCommands: map[string]modules.Command{cmd.Name: cmd},
|
||||
}
|
||||
modules.Install(rb.Bot, reg, modules.Auth{})
|
||||
@@ -146,7 +146,7 @@ func TestHandleIncomeEvents_WithTicker(t *testing.T) {
|
||||
body := `[{"id":"1","label":"GDKHQ","date":"2026-05-25T00:00:00Z","title":"TCX: 25.5.2026, ngày GDKHQ trả cổ tức bằng cổ phiếu năm 2024 (tỷ lệ 5:1)"}]`
|
||||
rb, _ := installTradingIncomeEvents(t, body, now)
|
||||
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/trade_income_events TCX"))
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/stock_income_events TCX"))
|
||||
got := rb.LastSent().Text()
|
||||
for _, want := range []string{"Income events from FireAnt", "TCX - 25/05/2026", "trả cổ tức"} {
|
||||
if !strings.Contains(got, want) {
|
||||
@@ -165,7 +165,7 @@ func TestHandleIncomeEvents_UsesHoldingsWhenTickerMissing(t *testing.T) {
|
||||
t.Fatalf("SavePortfolio: %v", err)
|
||||
}
|
||||
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/trade_income_events"))
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/stock_income_events"))
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "TCX - 25/05/2026: Holding event") {
|
||||
t.Errorf("expected holding event reply; got:\n%s", got)
|
||||
@@ -1,4 +1,4 @@
|
||||
package trading
|
||||
package stock
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
// Portfolio is the per-user trading state. Currency is a map for forward-
|
||||
// Portfolio is the per-user stock state. Currency is a map for forward-
|
||||
// compat with USD/EUR (currently VND-only). Assets is a flat ticker→qty map
|
||||
// — category lives in the symbol cache, not the portfolio.
|
||||
type Portfolio struct {
|
||||
@@ -60,14 +60,14 @@ func LoadPortfolio(ctx context.Context, kv storage.KVStore, userID int64, now in
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return NewPortfolio(now), nil
|
||||
default:
|
||||
return Portfolio{}, fmt.Errorf("trading: load portfolio %d: %w", userID, err)
|
||||
return Portfolio{}, fmt.Errorf("stock: load portfolio %d: %w", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// SavePortfolio persists the portfolio.
|
||||
func SavePortfolio(ctx context.Context, kv storage.KVStore, userID int64, p Portfolio) error {
|
||||
if err := kv.PutJSON(ctx, portfolioKey(userID), p); err != nil {
|
||||
return fmt.Errorf("trading: save portfolio %d: %w", userID, err)
|
||||
return fmt.Errorf("stock: save portfolio %d: %w", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package trading
|
||||
package stock
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package trading
|
||||
package stock
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -29,7 +29,7 @@ type PriceClient struct {
|
||||
URL string
|
||||
|
||||
// defaultClient memoises the zero-value HTTP fallback so the transport's
|
||||
// connection pool survives across FetchPrice calls — /trade_stats fans
|
||||
// connection pool survives across FetchPrice calls — /stock_stats fans
|
||||
// out per held ticker, and a fresh client per call means a fresh TLS
|
||||
// handshake per ticker.
|
||||
defaultOnce sync.Once
|
||||
@@ -76,7 +76,7 @@ func kbsFormatDate(t time.Time) string {
|
||||
// decode errors are returned wrapped.
|
||||
func (c *PriceClient) FetchPrice(ctx context.Context, ticker string) (float64, error) {
|
||||
if ticker == "" {
|
||||
return 0, errors.New("trading: ticker is empty")
|
||||
return 0, errors.New("stock: ticker is empty")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
edate := kbsFormatDate(now)
|
||||
@@ -90,13 +90,13 @@ func (c *PriceClient) FetchPrice(ctx context.Context, ticker string) (float64, e
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("trading: build KBS request: %w", err)
|
||||
return 0, fmt.Errorf("stock: build KBS request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (miti99bot)")
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("trading: KBS request: %w", err)
|
||||
return 0, fmt.Errorf("stock: KBS request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
@@ -106,7 +106,7 @@ func (c *PriceClient) FetchPrice(ctx context.Context, ticker string) (float64, e
|
||||
|
||||
var body kbsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return 0, fmt.Errorf("trading: KBS decode: %w", err)
|
||||
return 0, fmt.Errorf("stock: KBS decode: %w", err)
|
||||
}
|
||||
if len(body.DataDay) == 0 {
|
||||
return 0, ErrNoPrice
|
||||
@@ -121,4 +121,4 @@ func (c *PriceClient) FetchPrice(ctx context.Context, ticker string) (float64, e
|
||||
// ErrNoPrice means KBS returned no usable price for the ticker — either the
|
||||
// symbol is unknown, the market hasn't traded recently, or the data was
|
||||
// invalid. Used by symbol resolution to detect "is this a real ticker".
|
||||
var ErrNoPrice = errors.New("trading: no price available")
|
||||
var ErrNoPrice = errors.New("stock: no price available")
|
||||
@@ -1,4 +1,4 @@
|
||||
package trading
|
||||
package stock
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,10 +1,10 @@
|
||||
package trading
|
||||
package stock
|
||||
|
||||
import (
|
||||
"github.com/tiennm99/miti99bot/internal/modules"
|
||||
)
|
||||
|
||||
// New is the trading module Factory. Five user-facing commands; no crons.
|
||||
// New is the stock module Factory. Five user-facing commands; no crons.
|
||||
// (Original miti99bot only has a SQL retention cron, which our KV-only port
|
||||
// does not implement — keeping commits paper-ledger-only is acceptable.)
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
@@ -12,49 +12,49 @@ func New(deps modules.Deps) modules.Module {
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "trade_topup",
|
||||
Name: "stock_topup",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Top up VND to your trading account",
|
||||
Description: "Top up VND to your stock account",
|
||||
Handler: s.handleTopup,
|
||||
},
|
||||
{
|
||||
Name: "trade_buy",
|
||||
Name: "stock_buy",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Buy VN stock at market price (qty TICKER)",
|
||||
Handler: s.handleBuy,
|
||||
},
|
||||
{
|
||||
Name: "trade_sell",
|
||||
Name: "stock_sell",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Sell VN stock back to VND (qty TICKER)",
|
||||
Handler: s.handleSell,
|
||||
},
|
||||
{
|
||||
Name: "trade_income_stock",
|
||||
Name: "stock_income_stock",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Record stock dividend (bonus shares)",
|
||||
Handler: s.handleIncomeStock,
|
||||
},
|
||||
{
|
||||
Name: "trade_income_vnd",
|
||||
Name: "stock_income_vnd",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Record cash dividend (VND per share)",
|
||||
Handler: s.handleIncomeVND,
|
||||
},
|
||||
{
|
||||
Name: "trade_income_events",
|
||||
Name: "stock_income_events",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Check recent income events from FireAnt",
|
||||
Handler: s.handleIncomeEvents,
|
||||
},
|
||||
{
|
||||
Name: "trade_convert",
|
||||
Name: "stock_convert",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Currency exchange (coming soon)",
|
||||
Handler: s.handleConvert,
|
||||
},
|
||||
{
|
||||
Name: "trade_stats",
|
||||
Name: "stock_stats",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show portfolio summary with P&L",
|
||||
Handler: s.handleStats,
|
||||
@@ -1,4 +1,4 @@
|
||||
package trading
|
||||
package stock
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -25,7 +25,7 @@ type ResolvedSymbol struct {
|
||||
|
||||
// ErrUnknownTicker means KBS has no price data for the given ticker — i.e.
|
||||
// the symbol is not a tradeable VN stock as far as our source is concerned.
|
||||
var ErrUnknownTicker = errors.New("trading: unknown ticker")
|
||||
var ErrUnknownTicker = errors.New("stock: unknown ticker")
|
||||
|
||||
// ResolveSymbol returns the cached ResolvedSymbol if any, otherwise queries
|
||||
// KBS to validate the ticker and caches the result permanently. Tickers
|
||||
@@ -44,7 +44,7 @@ func ResolveSymbol(ctx context.Context, kv storage.KVStore, prices *PriceClient,
|
||||
if err := kv.GetJSON(ctx, cacheKey, &cached); err == nil {
|
||||
return cached, nil
|
||||
} else if !errors.Is(err, storage.ErrNotFound) {
|
||||
return ResolvedSymbol{}, fmt.Errorf("trading: cache read %s: %w", ticker, err)
|
||||
return ResolvedSymbol{}, fmt.Errorf("stock: cache read %s: %w", ticker, err)
|
||||
}
|
||||
|
||||
// Cache miss → validate against KBS by attempting a price fetch.
|
||||
@@ -1,4 +1,4 @@
|
||||
package trading
|
||||
package stock
|
||||
|
||||
import (
|
||||
"context"
|
||||
Reference in new issue
Block a user