mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 20:17:48 +00:00
feat(trading): VN stocks paper trading module — 5 commands + price engine
- Portfolio, holdings, prices, symbols packages with Telegram command handlers
- Handlers: trade_topup (add credits), trade_buy, trade_sell, trade_convert (coming-soon), trade_stats
- Per-user keylock for concurrency safety
- KBS (VN) as price source; ticker validation regex ^[A-Z0-9]{1,16}$
- Scope-trimmed: no SQL history, no retention cron, no daily refresh, no leaderboard, no forex
(matches upstream miti99bot's current VN-stocks-only state)
- Full unit test coverage for format, portfolio, prices, symbols, handlers
This commit is contained in:
1 parent
c0e179b9b8
commit
b9b44bc7b2
10 files changed
+1019
No files matched your search
@@ -0,0 +1,59 @@
|
||||
// Package trading is a paper-trading module for VN stocks. Per-user
|
||||
// portfolio + buy/sell at market price + stats with P&L. Ported from the
|
||||
// JS trading module in miti99bot; SQL-based history + retention cron are
|
||||
// out of scope for this v1 port.
|
||||
package trading
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FormatVND renders an integer-rounded amount with dot-thousands separators
|
||||
// and a "VND" suffix (e.g. 15000000 -> "15.000.000 VND"). Manual to avoid
|
||||
// locale-dependent formatting.
|
||||
func FormatVND(n float64) string {
|
||||
rounded := int64(math.Round(n))
|
||||
abs := strconv.FormatInt(absInt64(rounded), 10)
|
||||
var sb strings.Builder
|
||||
if rounded < 0 {
|
||||
sb.WriteByte('-')
|
||||
}
|
||||
for i := 0; i < len(abs); i++ {
|
||||
if i > 0 && (len(abs)-i)%3 == 0 {
|
||||
sb.WriteByte('.')
|
||||
}
|
||||
sb.WriteByte(abs[i])
|
||||
}
|
||||
sb.WriteString(" VND")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatStock renders an integer share quantity (always whole shares).
|
||||
func FormatStock(n float64) string {
|
||||
return strconv.FormatInt(int64(math.Floor(n)), 10)
|
||||
}
|
||||
|
||||
// FormatPnL renders a signed VND delta + percentage line, e.g.
|
||||
// "+1.234 VND (+12.34%)" or "-500.000 VND (-5.00%)". When invested is zero
|
||||
// the percentage is reported as 0.00 to avoid division-by-zero — matches JS.
|
||||
func FormatPnL(currentValue, invested float64) string {
|
||||
diff := currentValue - invested
|
||||
pct := 0.0
|
||||
if invested > 0 {
|
||||
pct = (diff / invested) * 100
|
||||
}
|
||||
sign := ""
|
||||
if diff >= 0 {
|
||||
sign = "+"
|
||||
}
|
||||
return sign + FormatVND(diff) + " (" + sign + strconv.FormatFloat(pct, 'f', 2, 64) + "%)"
|
||||
}
|
||||
|
||||
func absInt64(n int64) int64 {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package trading
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFormatVND(t *testing.T) {
|
||||
cases := []struct {
|
||||
in float64
|
||||
want string
|
||||
}{
|
||||
{0, "0 VND"},
|
||||
{1, "1 VND"},
|
||||
{100, "100 VND"},
|
||||
{1000, "1.000 VND"},
|
||||
{15_000_000, "15.000.000 VND"},
|
||||
{1_234_567_890, "1.234.567.890 VND"},
|
||||
{-500, "-500 VND"},
|
||||
{-1_500_000, "-1.500.000 VND"},
|
||||
// rounding: half away from zero (math.Round)
|
||||
{1.5, "2 VND"},
|
||||
{-1.5, "-2 VND"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := FormatVND(c.in); got != c.want {
|
||||
t.Errorf("FormatVND(%v): got %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatStock(t *testing.T) {
|
||||
cases := []struct {
|
||||
in float64
|
||||
want string
|
||||
}{
|
||||
{0, "0"},
|
||||
{1, "1"},
|
||||
{100, "100"},
|
||||
{99.9, "99"}, // floor for safety; we never store fractional shares anyway
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := FormatStock(c.in); got != c.want {
|
||||
t.Errorf("FormatStock(%v): got %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPnL(t *testing.T) {
|
||||
cases := []struct {
|
||||
current, invested float64
|
||||
want string
|
||||
}{
|
||||
{1_500_000, 1_000_000, "+500.000 VND (+50.00%)"},
|
||||
{800_000, 1_000_000, "-200.000 VND (-20.00%)"},
|
||||
{1_000_000, 1_000_000, "+0 VND (+0.00%)"},
|
||||
{500_000, 0, "+500.000 VND (+0.00%)"}, // invested=0 → pct=0, no NaN
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := FormatPnL(c.current, c.invested); got != c.want {
|
||||
t.Errorf("FormatPnL(%v,%v): got %q, want %q", c.current, c.invested, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package trading
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/keylock"
|
||||
"github.com/tiennm99/miti99bot-go/internal/log"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
// state is the per-module runtime. KV is module-scoped (the framework
|
||||
// prefixes/partitions). PriceClient is reused across calls; nowFn allows
|
||||
// tests to inject a deterministic clock for portfolio CreatedAt.
|
||||
type state struct {
|
||||
kv storage.KVStore
|
||||
prices *PriceClient
|
||||
locks keylock.Map
|
||||
nowFn func() time.Time
|
||||
comingSoonMessage string // exposed for tests / future i18n
|
||||
}
|
||||
|
||||
func (s *state) now() time.Time {
|
||||
if s.nowFn != nil {
|
||||
return s.nowFn()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// newState builds the default state used by the module factory.
|
||||
func newState(kv storage.KVStore) *state {
|
||||
return &state{
|
||||
kv: kv,
|
||||
prices: &PriceClient{},
|
||||
comingSoonMessage: "Crypto, gold & currency exchange coming soon!",
|
||||
}
|
||||
}
|
||||
|
||||
// senderInfo extracts the Telegram user ID for state-keying. Channel posts
|
||||
// and inline queries lack a from-user; we refuse to operate without one
|
||||
// because state under "user:0" would collide across all such updates.
|
||||
// Defensive against From.ID == 0 (anonymized senders / future Telegram
|
||||
// schema drift) for the same reason.
|
||||
func senderInfo(update *models.Update) (userID int64, chatID int64, ok bool) {
|
||||
msg := update.Message
|
||||
if msg == nil || msg.From == nil || msg.From.ID == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return msg.From.ID, msg.Chat.ID, true
|
||||
}
|
||||
|
||||
// argsAfterCommand splits the command body into whitespace-separated args.
|
||||
// "/trade_buy 100 TCB" → ["100", "TCB"]; "/trade_topup" → []
|
||||
func argsAfterCommand(text string) []string {
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) <= 1 {
|
||||
return nil
|
||||
}
|
||||
return parts[1:]
|
||||
}
|
||||
|
||||
func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
userID, chatID, ok := senderInfo(update)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message.Chat.ID,
|
||||
"Cannot identify user — trading only works in private/group chats with a sender.")
|
||||
}
|
||||
args := argsAfterCommand(update.Message.Text)
|
||||
if len(args) < 1 {
|
||||
return chathelper.Reply(ctx, b, chatID, "Usage: /trade_topup <amount>\nExample: /trade_topup 5000000")
|
||||
}
|
||||
amount, err := strconv.ParseFloat(args[0], 64)
|
||||
if err != nil || amount <= 0 {
|
||||
return chathelper.Reply(ctx, b, chatID, "Amount must be a positive number.")
|
||||
}
|
||||
|
||||
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, chatID, "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)
|
||||
return chathelper.Reply(ctx, b, chatID, "Could not save portfolio. Try again later.")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, chatID,
|
||||
"Topped up "+FormatVND(amount)+".\nBalance: "+FormatVND(p.Currency["VND"]))
|
||||
}
|
||||
|
||||
func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
userID, chatID, ok := senderInfo(update)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message.Chat.ID,
|
||||
"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, chatID, "Usage: /trade_buy <qty> <TICKER>\nExample: /trade_buy 100 TCB")
|
||||
}
|
||||
qty, err := strconv.ParseInt(args[0], 10, 64)
|
||||
if err != nil || qty <= 0 {
|
||||
return chathelper.Reply(ctx, b, chatID, "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, chatID,
|
||||
"Unknown stock ticker \""+strings.ToUpper(args[1])+"\".\n"+s.comingSoonMessage)
|
||||
}
|
||||
log.Error("trading_resolve_symbol", "ticker", args[1], "err", err)
|
||||
return chathelper.Reply(ctx, b, chatID, "Could not look up that ticker. Try again later.")
|
||||
}
|
||||
|
||||
price, err := s.prices.FetchPrice(ctx, resolved.Symbol)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNoPrice) {
|
||||
return chathelper.Reply(ctx, b, chatID, "No price available for "+resolved.Symbol+".")
|
||||
}
|
||||
log.Error("trading_fetch_price", "ticker", resolved.Symbol, "err", err)
|
||||
return chathelper.Reply(ctx, b, chatID, "Could not fetch price. Try again later.")
|
||||
}
|
||||
cost := float64(qty) * price
|
||||
|
||||
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, chatID, "Could not load portfolio. Try again later.")
|
||||
}
|
||||
ok, balance := p.DeductCurrency("VND", cost)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, chatID,
|
||||
"Insufficient VND. Need "+FormatVND(cost)+", have "+FormatVND(balance)+".")
|
||||
}
|
||||
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, chatID, "Could not save portfolio. Try again later.")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, chatID,
|
||||
"Bought "+FormatStock(float64(qty))+" "+resolved.Symbol+
|
||||
" @ "+FormatVND(price)+"\nCost: "+FormatVND(cost))
|
||||
}
|
||||
|
||||
func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
userID, chatID, ok := senderInfo(update)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message.Chat.ID,
|
||||
"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, chatID, "Usage: /trade_sell <qty> <TICKER>\nExample: /trade_sell 100 TCB")
|
||||
}
|
||||
qty, err := strconv.ParseInt(args[0], 10, 64)
|
||||
if err != nil || qty <= 0 {
|
||||
return chathelper.Reply(ctx, b, chatID, "Quantity must be a positive whole number.")
|
||||
}
|
||||
|
||||
// Resolve + fetch price BEFORE taking the per-user lock. Mirrors handleBuy:
|
||||
// keeps the critical section to a fast Get→mutate→Put, and removes any need
|
||||
// for a rollback path (no in-memory mutation precedes the network call).
|
||||
resolved, err := ResolveSymbol(ctx, s.kv, s.prices, args[1])
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnknownTicker) {
|
||||
return chathelper.Reply(ctx, b, chatID,
|
||||
"Unknown stock ticker \""+strings.ToUpper(args[1])+"\".")
|
||||
}
|
||||
log.Error("trading_resolve_symbol", "ticker", args[1], "err", err)
|
||||
return chathelper.Reply(ctx, b, chatID, "Could not look up that ticker. Try again later.")
|
||||
}
|
||||
price, err := s.prices.FetchPrice(ctx, resolved.Symbol)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNoPrice) {
|
||||
return chathelper.Reply(ctx, b, chatID, "No price available for "+resolved.Symbol+".")
|
||||
}
|
||||
log.Error("trading_fetch_price", "ticker", resolved.Symbol, "err", err)
|
||||
return chathelper.Reply(ctx, b, chatID, "Could not fetch price. 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, chatID, "Could not load portfolio. Try again later.")
|
||||
}
|
||||
ok, held := p.DeductAsset(resolved.Symbol, qty)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, chatID,
|
||||
"Insufficient "+resolved.Symbol+". You have: "+FormatStock(float64(held)))
|
||||
}
|
||||
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)
|
||||
return chathelper.Reply(ctx, b, chatID, "Could not save portfolio. Try again later.")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, chatID,
|
||||
"Sold "+FormatStock(float64(qty))+" "+resolved.Symbol+
|
||||
" @ "+FormatVND(price)+"\nRevenue: "+FormatVND(revenue))
|
||||
}
|
||||
|
||||
func (s *state) handleConvert(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
if update.Message == nil {
|
||||
return nil
|
||||
}
|
||||
return chathelper.Reply(ctx, b, update.Message.Chat.ID,
|
||||
"Currency exchange is not available yet.\n"+s.comingSoonMessage)
|
||||
}
|
||||
|
||||
// handleStats fetches every held ticker's current price (in parallel) and
|
||||
// renders the portfolio. Read-only — no portfolio mutation, so no keylock.
|
||||
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
userID, chatID, ok := senderInfo(update)
|
||||
if !ok {
|
||||
return chathelper.Reply(ctx, b, update.Message.Chat.ID,
|
||||
"Cannot identify user — /trade_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)
|
||||
return chathelper.Reply(ctx, b, chatID, "Could not load portfolio. Try again later.")
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, "📊 Portfolio Summary\n")
|
||||
totalValue := 0.0
|
||||
|
||||
if vnd := p.Currency["VND"]; vnd > 0 {
|
||||
totalValue += vnd
|
||||
lines = append(lines, "VND: "+FormatVND(vnd))
|
||||
}
|
||||
|
||||
// Filter out zero-balance assets (DeductAsset removes them, but defensive).
|
||||
type held struct {
|
||||
symbol string
|
||||
qty int64
|
||||
}
|
||||
var heldList []held
|
||||
for sym, qty := range p.Assets {
|
||||
if qty != 0 {
|
||||
heldList = append(heldList, held{sym, qty})
|
||||
}
|
||||
}
|
||||
|
||||
if len(heldList) > 0 {
|
||||
lines = append(lines, "\nStocks:")
|
||||
// Sequential price fetch (Lambda has no concurrency benefit at small N
|
||||
// and goroutines complicate test seams). For typical <10 holdings,
|
||||
// total latency is bounded by sum-of-fetches; KBS responds in <500ms.
|
||||
for _, h := range heldList {
|
||||
price, err := s.prices.FetchPrice(ctx, h.symbol)
|
||||
if err != nil {
|
||||
lines = append(lines, " "+h.symbol+" x"+FormatStock(float64(h.qty))+" (no price)")
|
||||
continue
|
||||
}
|
||||
val := float64(h.qty) * price
|
||||
totalValue += val
|
||||
lines = append(lines, " "+h.symbol+" x"+FormatStock(float64(h.qty))+
|
||||
" @ "+FormatVND(price)+" = "+FormatVND(val))
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, "\nTotal value: "+FormatVND(totalValue))
|
||||
lines = append(lines, "Invested: "+FormatVND(p.Meta.Invested))
|
||||
lines = append(lines, "P&L: "+FormatPnL(totalValue, p.Meta.Invested))
|
||||
return chathelper.Reply(ctx, b, chatID, strings.Join(lines, "\n"))
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package trading
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
// Portfolio is the per-user trading 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 {
|
||||
Currency map[string]float64 `json:"currency"`
|
||||
Assets map[string]int64 `json:"assets"`
|
||||
Meta PortfolioMeta `json:"meta"`
|
||||
}
|
||||
|
||||
// PortfolioMeta tracks invested cost basis for P&L. CreatedAt is purely
|
||||
// informational; carried for parity with JS schema.
|
||||
type PortfolioMeta struct {
|
||||
Invested float64 `json:"invested"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// NewPortfolio returns an empty starting state. Currency map seeded with VND=0
|
||||
// so deductCurrency on a fresh user reports "0 balance" cleanly instead of
|
||||
// nil-map panics.
|
||||
func NewPortfolio(now int64) Portfolio {
|
||||
return Portfolio{
|
||||
Currency: map[string]float64{"VND": 0},
|
||||
Assets: map[string]int64{},
|
||||
Meta: PortfolioMeta{Invested: 0, CreatedAt: now},
|
||||
}
|
||||
}
|
||||
|
||||
func portfolioKey(userID int64) string {
|
||||
return "user:" + strconv.FormatInt(userID, 10)
|
||||
}
|
||||
|
||||
// LoadPortfolio reads from KV; returns an empty portfolio on first-time use.
|
||||
// Defensively initialises nil maps so callers never need a nil check.
|
||||
func LoadPortfolio(ctx context.Context, kv storage.KVStore, userID int64, now int64) (Portfolio, error) {
|
||||
var p Portfolio
|
||||
err := kv.GetJSON(ctx, portfolioKey(userID), &p)
|
||||
switch {
|
||||
case err == nil:
|
||||
// Repair any nils from older / partial saves — defence in depth.
|
||||
if p.Currency == nil {
|
||||
p.Currency = map[string]float64{"VND": 0}
|
||||
} else if _, ok := p.Currency["VND"]; !ok {
|
||||
p.Currency["VND"] = 0
|
||||
}
|
||||
if p.Assets == nil {
|
||||
p.Assets = map[string]int64{}
|
||||
}
|
||||
return p, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return NewPortfolio(now), nil
|
||||
default:
|
||||
return Portfolio{}, fmt.Errorf("trading: 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 nil
|
||||
}
|
||||
|
||||
// AddCurrency credits the currency balance.
|
||||
func (p *Portfolio) AddCurrency(currency string, amount float64) {
|
||||
if p.Currency == nil {
|
||||
p.Currency = map[string]float64{}
|
||||
}
|
||||
p.Currency[currency] += amount
|
||||
}
|
||||
|
||||
// DeductCurrency debits the currency balance. Returns false + the current
|
||||
// balance when insufficient — caller renders the user-facing error.
|
||||
func (p *Portfolio) DeductCurrency(currency string, amount float64) (ok bool, balance float64) {
|
||||
if p.Currency == nil {
|
||||
p.Currency = map[string]float64{}
|
||||
}
|
||||
balance = p.Currency[currency]
|
||||
if balance < amount {
|
||||
return false, balance
|
||||
}
|
||||
p.Currency[currency] = balance - amount
|
||||
return true, p.Currency[currency]
|
||||
}
|
||||
|
||||
// AddAsset credits the share holding.
|
||||
func (p *Portfolio) AddAsset(symbol string, qty int64) {
|
||||
if p.Assets == nil {
|
||||
p.Assets = map[string]int64{}
|
||||
}
|
||||
p.Assets[symbol] += qty
|
||||
}
|
||||
|
||||
// DeductAsset debits the share holding. Returns false + held when caller asks
|
||||
// for more than they own. Removes the key when balance hits zero so the
|
||||
// portfolio doesn't accumulate empty entries.
|
||||
func (p *Portfolio) DeductAsset(symbol string, qty int64) (ok bool, held int64) {
|
||||
if p.Assets == nil {
|
||||
p.Assets = map[string]int64{}
|
||||
}
|
||||
held = p.Assets[symbol]
|
||||
if held < qty {
|
||||
return false, held
|
||||
}
|
||||
remaining := held - qty
|
||||
if remaining == 0 {
|
||||
delete(p.Assets, symbol)
|
||||
} else {
|
||||
p.Assets[symbol] = remaining
|
||||
}
|
||||
return true, remaining
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package trading
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
func TestLoadPortfolio_FirstTimeUser(t *testing.T) {
|
||||
kv := storage.NewMemoryKVStore()
|
||||
p, err := LoadPortfolio(context.Background(), kv, 42, 1234567890)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPortfolio: %v", err)
|
||||
}
|
||||
if p.Currency["VND"] != 0 {
|
||||
t.Errorf("VND seeded: got %v, want 0", p.Currency["VND"])
|
||||
}
|
||||
if p.Assets == nil {
|
||||
t.Error("Assets is nil")
|
||||
}
|
||||
if p.Meta.CreatedAt != 1234567890 {
|
||||
t.Errorf("CreatedAt: got %d, want 1234567890", p.Meta.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoadRoundTrip(t *testing.T) {
|
||||
kv := storage.NewMemoryKVStore()
|
||||
p, _ := LoadPortfolio(context.Background(), kv, 42, 1)
|
||||
p.AddCurrency("VND", 5_000_000)
|
||||
p.AddAsset("TCB", 100)
|
||||
p.Meta.Invested = 5_000_000
|
||||
if err := SavePortfolio(context.Background(), kv, 42, p); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
got, err := LoadPortfolio(context.Background(), kv, 42, 999) // CreatedAt should NOT be reset
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got.Currency["VND"] != 5_000_000 {
|
||||
t.Errorf("VND: got %v, want 5000000", got.Currency["VND"])
|
||||
}
|
||||
if got.Assets["TCB"] != 100 {
|
||||
t.Errorf("TCB: got %d, want 100", got.Assets["TCB"])
|
||||
}
|
||||
if got.Meta.Invested != 5_000_000 {
|
||||
t.Errorf("Invested: got %v, want 5000000", got.Meta.Invested)
|
||||
}
|
||||
if got.Meta.CreatedAt != 1 {
|
||||
t.Errorf("CreatedAt: got %d, want 1 (load must NOT overwrite existing)", got.Meta.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddDeductCurrency(t *testing.T) {
|
||||
p := NewPortfolio(0)
|
||||
p.AddCurrency("VND", 1000)
|
||||
p.AddCurrency("VND", 500)
|
||||
if p.Currency["VND"] != 1500 {
|
||||
t.Errorf("after add: got %v, want 1500", p.Currency["VND"])
|
||||
}
|
||||
ok, bal := p.DeductCurrency("VND", 600)
|
||||
if !ok || bal != 900 {
|
||||
t.Errorf("deduct 600: ok=%v bal=%v, want ok=true bal=900", ok, bal)
|
||||
}
|
||||
ok, bal = p.DeductCurrency("VND", 9999)
|
||||
if ok || bal != 900 {
|
||||
t.Errorf("deduct over balance: ok=%v bal=%v, want ok=false bal=900 (unchanged)", ok, bal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddDeductAsset(t *testing.T) {
|
||||
p := NewPortfolio(0)
|
||||
p.AddAsset("TCB", 10)
|
||||
p.AddAsset("TCB", 5)
|
||||
if p.Assets["TCB"] != 15 {
|
||||
t.Errorf("TCB after add: got %d, want 15", p.Assets["TCB"])
|
||||
}
|
||||
ok, held := p.DeductAsset("TCB", 3)
|
||||
if !ok || held != 12 {
|
||||
t.Errorf("deduct 3: ok=%v held=%v, want ok=true held=12", ok, held)
|
||||
}
|
||||
ok, _ = p.DeductAsset("TCB", 999)
|
||||
if ok {
|
||||
t.Error("deduct over holdings: should fail")
|
||||
}
|
||||
// Final deduction removes key entirely.
|
||||
p.DeductAsset("TCB", 12)
|
||||
if _, present := p.Assets["TCB"]; present {
|
||||
t.Error("zero-balance asset should be removed from map")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package trading
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// kbsDefaultURL is the KBS public stock data endpoint base.
|
||||
const kbsDefaultURL = "https://kbbuddywts.kbsec.com.vn/iis-server/investment/stocks"
|
||||
|
||||
// kbsLookbackDays widens the requested window to absorb weekends and Vietnam
|
||||
// market holidays — KBS returns the latest bar within the window in [0].
|
||||
const kbsLookbackDays = 14
|
||||
|
||||
// kbsHTTPTimeout caps the price fetch. KBS is generally fast; 10s leaves
|
||||
// headroom for TLS + DNS on a Lambda cold start.
|
||||
const kbsHTTPTimeout = 10 * time.Second
|
||||
|
||||
// PriceClient is the KBS price fetcher. Zero value uses the default URL +
|
||||
// `&{Timeout: kbsHTTPTimeout}` HTTP client; tests inject HTTP + URL.
|
||||
type PriceClient struct {
|
||||
HTTP *http.Client
|
||||
URL string
|
||||
}
|
||||
|
||||
func (c *PriceClient) httpClient() *http.Client {
|
||||
if c.HTTP != nil {
|
||||
return c.HTTP
|
||||
}
|
||||
return &http.Client{Timeout: kbsHTTPTimeout}
|
||||
}
|
||||
|
||||
func (c *PriceClient) baseURL() string {
|
||||
if c.URL != "" {
|
||||
return c.URL
|
||||
}
|
||||
return kbsDefaultURL
|
||||
}
|
||||
|
||||
// kbsResponse is the slice of the KBS payload we care about. We intentionally
|
||||
// don't model the full response (open/high/low/volume) — only the latest
|
||||
// close. The struct still names them so Json doesn't error on unknown fields
|
||||
// (Go's json decoder ignores them by default).
|
||||
type kbsResponse struct {
|
||||
DataDay []kbsBar `json:"data_day"`
|
||||
}
|
||||
|
||||
type kbsBar struct {
|
||||
C float64 `json:"c"` // close, already in VND, unscaled
|
||||
}
|
||||
|
||||
// kbsFormatDate formats t as "DD-MM-YYYY" — KBS's expected query date shape.
|
||||
func kbsFormatDate(t time.Time) string {
|
||||
t = t.UTC()
|
||||
return fmt.Sprintf("%02d-%02d-%04d", t.Day(), int(t.Month()), t.Year())
|
||||
}
|
||||
|
||||
// FetchPrice returns the latest VND close for ticker, or ErrNoPrice if KBS
|
||||
// has no data (unknown ticker, suspended, holiday-only window). Network /
|
||||
// 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")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
edate := kbsFormatDate(now)
|
||||
sdate := kbsFormatDate(now.Add(-time.Duration(kbsLookbackDays) * 24 * time.Hour))
|
||||
|
||||
endpoint := c.baseURL() + "/" + url.PathEscape(ticker) + "/data_day"
|
||||
q := url.Values{}
|
||||
q.Set("sdate", sdate)
|
||||
q.Set("edate", edate)
|
||||
full := endpoint + "?" + q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("trading: build KBS request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (miti99bot-go)")
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("trading: KBS request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return 0, ErrNoPrice
|
||||
}
|
||||
|
||||
var body kbsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return 0, fmt.Errorf("trading: KBS decode: %w", err)
|
||||
}
|
||||
if len(body.DataDay) == 0 {
|
||||
return 0, ErrNoPrice
|
||||
}
|
||||
close := body.DataDay[0].C
|
||||
if close <= 0 {
|
||||
return 0, ErrNoPrice
|
||||
}
|
||||
return close, nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
@@ -0,0 +1,95 @@
|
||||
package trading
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestPriceClient(t *testing.T, handler http.HandlerFunc) (*PriceClient, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
return &PriceClient{
|
||||
HTTP: &http.Client{Timeout: 2 * time.Second},
|
||||
URL: srv.URL,
|
||||
}, srv
|
||||
}
|
||||
|
||||
func TestPriceClient_HappyPath(t *testing.T) {
|
||||
c, _ := newTestPriceClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.URL.Path, "/TCB/data_day") {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("sdate") == "" || r.URL.Query().Get("edate") == "" {
|
||||
t.Errorf("missing sdate/edate: %s", r.URL.RawQuery)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data_day":[{"c":24500}, {"c":24300}]}`))
|
||||
})
|
||||
got, err := c.FetchPrice(context.Background(), "TCB")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchPrice: %v", err)
|
||||
}
|
||||
if got != 24500 {
|
||||
t.Errorf("price: got %v, want 24500 (latest bar = data_day[0])", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceClient_NoData_ReturnsErrNoPrice(t *testing.T) {
|
||||
c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data_day":[]}`))
|
||||
})
|
||||
_, err := c.FetchPrice(context.Background(), "NOPE")
|
||||
if !errors.Is(err, ErrNoPrice) {
|
||||
t.Errorf("got %v, want ErrNoPrice", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceClient_4xx_ReturnsErrNoPrice(t *testing.T) {
|
||||
c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
_, err := c.FetchPrice(context.Background(), "BADTICKER")
|
||||
if !errors.Is(err, ErrNoPrice) {
|
||||
t.Errorf("got %v, want ErrNoPrice", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceClient_NegativeClose_ReturnsErrNoPrice(t *testing.T) {
|
||||
c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data_day":[{"c":-1}]}`))
|
||||
})
|
||||
_, err := c.FetchPrice(context.Background(), "WEIRD")
|
||||
if !errors.Is(err, ErrNoPrice) {
|
||||
t.Errorf("got %v, want ErrNoPrice", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceClient_EmptyTicker(t *testing.T) {
|
||||
c := &PriceClient{}
|
||||
_, err := c.FetchPrice(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Error("empty ticker: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKBSFormatDate(t *testing.T) {
|
||||
cases := []struct {
|
||||
in time.Time
|
||||
want string
|
||||
}{
|
||||
{time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC), "10-05-2026"},
|
||||
{time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC), "05-01-2026"},
|
||||
{time.Date(2026, 12, 31, 23, 59, 0, 0, time.UTC), "31-12-2026"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := kbsFormatDate(c.in); got != c.want {
|
||||
t.Errorf("kbsFormatDate(%v): got %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package trading
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
// tickerRe restricts tickers to ASCII alphanumeric, 1-16 chars. Stops
|
||||
// Cyrillic / unicode-lookalike inputs from amplifying KBS lookups, and
|
||||
// guards the cache key alphabet (sym:<TICKER>) from oddities.
|
||||
var tickerRe = regexp.MustCompile(`^[A-Z0-9]{1,16}$`)
|
||||
|
||||
// ResolvedSymbol is the cached entry written under "sym:<TICKER>". Category
|
||||
// is currently always "stock" — crypto/gold/forex are upstream future-work.
|
||||
type ResolvedSymbol struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Category string `json:"category"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
// ResolveSymbol returns the cached ResolvedSymbol if any, otherwise queries
|
||||
// KBS to validate the ticker and caches the result permanently. Tickers
|
||||
// don't change; permanent caching is correct.
|
||||
//
|
||||
// The empty-input case returns ErrUnknownTicker to keep the caller's branch
|
||||
// shape simple (one error path covers both empty + unknown).
|
||||
func ResolveSymbol(ctx context.Context, kv storage.KVStore, prices *PriceClient, ticker string) (ResolvedSymbol, error) {
|
||||
ticker = strings.ToUpper(strings.TrimSpace(ticker))
|
||||
if !tickerRe.MatchString(ticker) {
|
||||
return ResolvedSymbol{}, ErrUnknownTicker
|
||||
}
|
||||
cacheKey := "sym:" + ticker
|
||||
|
||||
var cached ResolvedSymbol
|
||||
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)
|
||||
}
|
||||
|
||||
// Cache miss → validate against KBS by attempting a price fetch.
|
||||
if _, err := prices.FetchPrice(ctx, ticker); err != nil {
|
||||
if errors.Is(err, ErrNoPrice) {
|
||||
return ResolvedSymbol{}, ErrUnknownTicker
|
||||
}
|
||||
return ResolvedSymbol{}, err
|
||||
}
|
||||
|
||||
resolved := ResolvedSymbol{Symbol: ticker, Category: "stock", Label: ticker}
|
||||
// Cache write failure is non-fatal — next call will resolve again.
|
||||
_ = kv.PutJSON(ctx, cacheKey, resolved)
|
||||
return resolved, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package trading
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
func TestResolveSymbol_FirstTime_QueriesAndCaches(t *testing.T) {
|
||||
kv := storage.NewMemoryKVStore()
|
||||
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
_, _ = w.Write([]byte(`{"data_day":[{"c":24500}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
prices := &PriceClient{URL: srv.URL}
|
||||
|
||||
got, err := ResolveSymbol(context.Background(), kv, prices, "tcb")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSymbol: %v", err)
|
||||
}
|
||||
if got.Symbol != "TCB" || got.Category != "stock" {
|
||||
t.Errorf("resolved: got %+v, want {TCB stock TCB}", got)
|
||||
}
|
||||
if atomic.LoadInt32(&hits) != 1 {
|
||||
t.Errorf("KBS hits: got %d, want 1", hits)
|
||||
}
|
||||
|
||||
// Second call should hit the cache, not KBS.
|
||||
_, err = ResolveSymbol(context.Background(), kv, prices, "TCB")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSymbol (cached): %v", err)
|
||||
}
|
||||
if atomic.LoadInt32(&hits) != 1 {
|
||||
t.Errorf("KBS hits after cache: got %d, want 1 (cached)", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSymbol_Unknown(t *testing.T) {
|
||||
kv := storage.NewMemoryKVStore()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data_day":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
prices := &PriceClient{URL: srv.URL}
|
||||
|
||||
_, err := ResolveSymbol(context.Background(), kv, prices, "NOPE")
|
||||
if !errors.Is(err, ErrUnknownTicker) {
|
||||
t.Errorf("got %v, want ErrUnknownTicker", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSymbol_EmptyInput(t *testing.T) {
|
||||
_, err := ResolveSymbol(context.Background(), storage.NewMemoryKVStore(), &PriceClient{}, " ")
|
||||
if !errors.Is(err, ErrUnknownTicker) {
|
||||
t.Errorf("got %v, want ErrUnknownTicker for empty input", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSymbol_NormalizesCase(t *testing.T) {
|
||||
kv := storage.NewMemoryKVStore()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// KBS endpoint should receive the upper-cased ticker.
|
||||
if !strings.Contains(r.URL.Path, "/FPT/") {
|
||||
t.Errorf("ticker not upper-cased in URL: %s", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data_day":[{"c":120000}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
prices := &PriceClient{URL: srv.URL}
|
||||
|
||||
got, err := ResolveSymbol(context.Background(), kv, prices, " fpt ")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSymbol: %v", err)
|
||||
}
|
||||
if got.Symbol != "FPT" {
|
||||
t.Errorf("normalised: got %q, want FPT", got.Symbol)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package trading
|
||||
|
||||
import (
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
)
|
||||
|
||||
// New is the trading 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 {
|
||||
s := newState(deps.KV)
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "trade_topup",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Top up VND to your trading account",
|
||||
Handler: s.handleTopup,
|
||||
},
|
||||
{
|
||||
Name: "trade_buy",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Buy VN stock at market price (qty TICKER)",
|
||||
Handler: s.handleBuy,
|
||||
},
|
||||
{
|
||||
Name: "trade_sell",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Sell VN stock back to VND (qty TICKER)",
|
||||
Handler: s.handleSell,
|
||||
},
|
||||
{
|
||||
Name: "trade_convert",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Currency exchange (coming soon)",
|
||||
Handler: s.handleConvert,
|
||||
},
|
||||
{
|
||||
Name: "trade_stats",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show portfolio summary with P&L",
|
||||
Handler: s.handleStats,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user