diff --git a/docs/deploy-aws.md b/docs/deploy-aws.md index 9ea66d3..e66f7f8 100644 --- a/docs/deploy-aws.md +++ b/docs/deploy-aws.md @@ -111,6 +111,16 @@ fixes, non-public commands) need no menu action. FireAnt response is an array of timescale marks with `id`, `label`, `date`, `title`, and `color`. The bot keeps marks whose label/title indicate dividends, ex-right dates, final registration dates, rights issues, or bonus/share dividends. +## Stock price providers + +`/stock_buy`, `/stock_sell`, and `/stock_stats` use unofficial public quote endpoints. Zero-value provider order is: + +1. KBS current price board (`/stock/iss`). +2. VCI current quote board (`/price/symbols/getList`). +3. SSI iBoard direct quote. + +This order is intentional for current-price commands. KBS and VCI both support batch current quotes, while SSI can return a Cloudflare security page. Treat all three as unofficial app-internal endpoints and keep provider/source errors visible in Lambda logs. + ## Gold module `gold` is opt-in for first deploy. Enable it by adding `gold` to the `ModulesCSV` parameter / `MODULES` env, for example `util,misc,wordle,loldle,lolschedule,twentyq,stock,stats,gold`. diff --git a/internal/modules/stock/handlers.go b/internal/modules/stock/handlers.go index ecc010d..d7d0d23 100644 --- a/internal/modules/stock/handlers.go +++ b/internal/modules/stock/handlers.go @@ -328,8 +328,8 @@ func (s *state) handleConvert(ctx context.Context, b *bot.Bot, update *models.Up "Currency exchange is not available yet.\n"+s.comingSoonMessage) } -// handleStats fetches current prices for held tickers in one SSI batch request -// and renders the portfolio. Read-only; no portfolio mutation, so no keylock. +// handleStats fetches current prices for held tickers 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, ok := senderInfo(update) if !ok { diff --git a/internal/modules/stock/prices.go b/internal/modules/stock/prices.go index 55d9941..4451490 100644 --- a/internal/modules/stock/prices.go +++ b/internal/modules/stock/prices.go @@ -2,32 +2,27 @@ package stock import ( "context" - "encoding/json" "errors" "fmt" - "io" "net/http" - "net/url" "strings" "sync" "time" ) -// ssiQueryDefaultURL is the SSI iBoard stock-query endpoint base. -const ssiQueryDefaultURL = "https://iboard-query.ssi.com.vn" +// stockPriceHTTPTimeout caps a stock quote request. Kept under the handler +// deadline so a slow upstream cannot starve the Telegram reply budget. +const stockPriceHTTPTimeout = 3 * time.Second -// ssiHTTPTimeout caps a stock quote request. Kept under the handler deadline -// so a slow upstream cannot starve the Telegram reply budget. -const ssiHTTPTimeout = 3 * time.Second +const stockBrowserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36" -// ssiErrorBodyLimit keeps upstream diagnostics useful without dumping large responses. -const ssiErrorBodyLimit = 512 - -// PriceClient is the SSI iBoard stock quote fetcher. Zero value uses the -// default SSI URL + a timeout-bound HTTP client; tests inject HTTP + URL. +// PriceClient fetches VN stock quotes. Zero value uses KBS current price-board +// quotes first, then VCI current quotes, then SSI direct quotes. type PriceClient struct { - HTTP *http.Client - URL string + HTTP *http.Client + URL string // SSI direct quote endpoint base override. + KBSURL string // KBS current quote endpoint override. + VCIURL string // VCI current quote endpoint override. // defaultClient memoises the zero-value HTTP client so the transport's // connection pool survives across stock commands. @@ -40,131 +35,137 @@ func (c *PriceClient) httpClient() *http.Client { return c.HTTP } c.defaultOnce.Do(func() { - c.defaultClient = &http.Client{Timeout: ssiHTTPTimeout} + c.defaultClient = &http.Client{Timeout: stockPriceHTTPTimeout} }) return c.defaultClient } -func (c *PriceClient) baseURL() string { - if c.URL != "" { - return strings.TrimRight(c.URL, "/") - } - return ssiQueryDefaultURL -} - -type ssiSingleResponse struct { - Data ssiStockQuote `json:"data"` -} - -type ssiMultipleResponse struct { - Data []ssiStockQuote `json:"data"` -} - -type ssiStockQuote struct { - StockSymbol string `json:"stockSymbol"` - MatchedPrice float64 `json:"matchedPrice"` -} - -// FetchPrice returns SSI's current matched price in VND for ticker, or -// ErrNoPrice if SSI returns no usable quote. Network / decode errors are -// returned wrapped. +// FetchPrice returns the current VND price for ticker, or ErrNoPrice if all +// configured providers return no usable quote. func (c *PriceClient) FetchPrice(ctx context.Context, ticker string) (float64, error) { + ticker = strings.ToUpper(strings.TrimSpace(ticker)) if ticker == "" { return 0, errors.New("stock: ticker is empty") } - full := c.baseURL() + "/stock/" + url.PathEscape(ticker) - req, err := newSSIRequest(ctx, http.MethodGet, full, nil) - if err != nil { - return 0, err - } - var body ssiSingleResponse - if err := c.doJSON(req, &body); err != nil { - return 0, err + var errs []providerError + if c.ssiFirst() { + price, err := c.fetchSSIPrice(ctx, ticker) + if err == nil { + return price, nil + } + errs = append(errs, providerError{name: "SSI direct", err: err}) } - price := body.Data.MatchedPrice - if price <= 0 { - return 0, fmt.Errorf("%w: SSI quote has no matchedPrice for %s", ErrNoPrice, strings.ToUpper(ticker)) + if c.kbsFallbackEnabled() { + price, err := c.fetchKBSPrice(ctx, ticker) + if err == nil { + return price, nil + } + errs = append(errs, providerError{name: "KBS", err: err}) } - return price, nil + if c.vciFallbackEnabled() { + price, err := c.fetchVCIPrice(ctx, ticker) + if err == nil { + return price, nil + } + errs = append(errs, providerError{name: "VCI", err: err}) + } + if !c.ssiFirst() { + price, err := c.fetchSSIPrice(ctx, ticker) + if err == nil { + return price, nil + } + errs = append(errs, providerError{name: "SSI direct", err: err}) + } + return 0, combineProviderErrors(ticker, errs...) } -// FetchPrices returns current matched prices for the requested tickers using -// SSI's batch quote endpoint. Missing or invalid quotes are omitted from the -// returned map; callers can degrade those symbols individually. +// FetchPrices returns current prices for the requested tickers. Missing or +// invalid quotes are omitted from the returned map; callers can degrade those +// symbols individually. func (c *PriceClient) FetchPrices(ctx context.Context, tickers []string) (map[string]float64, error) { - if len(tickers) == 0 { + requested := normalizeTickers(tickers) + if len(requested) == 0 { return map[string]float64{}, nil } - form := url.Values{} - requested := make([]string, 0, len(tickers)) + + var errs []providerError + if c.ssiFirst() { + prices, err := c.fetchSSIPrices(ctx, requested) + if err == nil { + return prices, nil + } + errs = append(errs, providerError{name: "SSI direct", err: err}) + } + if c.kbsFallbackEnabled() { + prices, err := c.fetchKBSPrices(ctx, requested) + if len(prices) > 0 { + return prices, nil + } + if err == nil { + err = fmt.Errorf("%w: KBS fallback returned no usable quotes", ErrNoPrice) + } + errs = append(errs, providerError{name: "KBS", err: err}) + } + if c.vciFallbackEnabled() { + prices, err := c.fetchVCIPrices(ctx, requested) + if len(prices) > 0 { + return prices, nil + } + if err == nil { + err = fmt.Errorf("%w: VCI fallback returned no usable quotes", ErrNoPrice) + } + errs = append(errs, providerError{name: "VCI", err: err}) + } + if !c.ssiFirst() { + prices, err := c.fetchSSIPrices(ctx, requested) + if err == nil { + return prices, nil + } + errs = append(errs, providerError{name: "SSI direct", err: err}) + } + return nil, combineProviderErrors(strings.Join(requested, ","), errs...) +} + +func (c *PriceClient) ssiFirst() bool { + return strings.TrimSpace(c.URL) != "" +} + +func normalizeTickers(tickers []string) []string { + out := make([]string, 0, len(tickers)) for _, ticker := range tickers { - ticker = strings.TrimSpace(ticker) - if ticker == "" { - continue + ticker = strings.ToUpper(strings.TrimSpace(ticker)) + if ticker != "" { + out = append(out, ticker) } - ticker = strings.ToUpper(ticker) - form.Add("stocks", ticker) - requested = append(requested, ticker) } - if len(form) == 0 { - return nil, errors.New("stock: no tickers to fetch") - } - - req, err := newSSIRequest(ctx, http.MethodPost, c.baseURL()+"/stock/multiple", strings.NewReader(form.Encode())) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - var body ssiMultipleResponse - if err := c.doJSON(req, &body); err != nil { - return nil, err - } - out := make(map[string]float64, len(body.Data)) - for _, quote := range body.Data { - symbol := strings.ToUpper(strings.TrimSpace(quote.StockSymbol)) - if symbol == "" || quote.MatchedPrice <= 0 { - continue - } - out[symbol] = quote.MatchedPrice - } - if len(out) == 0 { - return nil, fmt.Errorf("%w: SSI batch returned no usable quotes for %s (data_len=%d)", ErrNoPrice, strings.Join(requested, ","), len(body.Data)) - } - return out, nil + return out } -func newSSIRequest(ctx context.Context, method, full string, body io.Reader) (*http.Request, error) { - req, err := http.NewRequestWithContext(ctx, method, full, body) - if err != nil { - return nil, fmt.Errorf("stock: build SSI request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Origin", "https://iboard.ssi.com.vn") - req.Header.Set("Referer", "https://iboard.ssi.com.vn/") - req.Header.Set("User-Agent", "Mozilla/5.0 (miti99bot)") - return req, nil -} - -func (c *PriceClient) doJSON(req *http.Request, dst any) error { - resp, err := c.httpClient().Do(req) - if err != nil { - return fmt.Errorf("stock: SSI request: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - snippet, _ := io.ReadAll(io.LimitReader(resp.Body, ssiErrorBodyLimit)) - return fmt.Errorf("%w: SSI status %d body %q", ErrNoPrice, resp.StatusCode, strings.TrimSpace(string(snippet))) - } - if err := json.NewDecoder(resp.Body).Decode(dst); err != nil { - return fmt.Errorf("stock: SSI decode: %w", err) - } - return nil -} - -// ErrNoPrice means SSI 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". +// ErrNoPrice means no provider returned a usable price for the ticker. Used by +// symbol resolution to detect "is this a real ticker". var ErrNoPrice = errors.New("stock: no price available") + +type providerError struct { + name string + err error +} + +func combineProviderErrors(ticker string, errs ...providerError) error { + allNoPrice := true + parts := make([]string, 0, len(errs)) + for _, entry := range errs { + if entry.err == nil { + continue + } + if !errors.Is(entry.err, ErrNoPrice) { + allNoPrice = false + } + parts = append(parts, entry.name+" failed ("+entry.err.Error()+")") + } + msg := ticker + ": " + strings.Join(parts, "; ") + if allNoPrice { + return fmt.Errorf("%w: %s", ErrNoPrice, msg) + } + return errors.New("stock: price providers failed for " + msg) +} diff --git a/internal/modules/stock/prices_kbs.go b/internal/modules/stock/prices_kbs.go new file mode 100644 index 0000000..235d5be --- /dev/null +++ b/internal/modules/stock/prices_kbs.go @@ -0,0 +1,103 @@ +package stock + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// kbsDefaultURL is KBS's public current price-board endpoint. +const kbsDefaultURL = "https://kbbuddywts.kbsec.com.vn/iis-server/investment/stock/iss" + +type kbsRequest struct { + Code string `json:"code"` +} + +type kbsQuote struct { + Symbol string `json:"SB"` + Price float64 `json:"CP"` +} + +func (c *PriceClient) kbsFallbackEnabled() bool { + return strings.TrimSpace(c.KBSURL) != "" || !c.ssiFirst() +} + +func (c *PriceClient) fetchKBSPrice(ctx context.Context, ticker string) (float64, error) { + prices, err := c.fetchKBSPrices(ctx, []string{ticker}) + if err != nil { + return 0, err + } + price := prices[strings.ToUpper(strings.TrimSpace(ticker))] + if price <= 0 { + return 0, fmt.Errorf("%w: KBS returned no usable quote for %s", ErrNoPrice, strings.ToUpper(ticker)) + } + return price, nil +} + +func (c *PriceClient) fetchKBSPrices(ctx context.Context, tickers []string) (map[string]float64, error) { + requested := normalizeTickers(tickers) + if len(requested) == 0 { + return map[string]float64{}, nil + } + + payload, err := json.Marshal(kbsRequest{Code: strings.Join(requested, ",")}) + if err != nil { + return nil, fmt.Errorf("stock: build KBS payload: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.kbsURL(), bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("stock: build KBS request: %w", err) + } + setKBSHeaders(req) + + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("stock: KBS request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("%w: KBS status %d", ErrNoPrice, resp.StatusCode) + } + + var body []kbsQuote + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("stock: KBS decode: %w", err) + } + out := make(map[string]float64, len(body)) + for _, quote := range body { + symbol := strings.ToUpper(strings.TrimSpace(quote.Symbol)) + if symbol == "" || quote.Price <= 0 { + continue + } + out[symbol] = quote.Price + } + if len(out) == 0 { + return nil, fmt.Errorf("%w: KBS returned no usable quotes for %s", ErrNoPrice, strings.Join(requested, ",")) + } + return out, nil +} + +func (c *PriceClient) kbsURL() string { + if strings.TrimSpace(c.KBSURL) != "" { + return strings.TrimRight(c.KBSURL, "/") + } + return kbsDefaultURL +} + +func setKBSHeaders(req *http.Request) { + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Accept-Language", "en-US,en;q=0.9,vi;q=0.8") + req.Header.Set("Cache-Control", "no-cache") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("DNT", "1") + req.Header.Set("Pragma", "no-cache") + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Site", "same-origin") + req.Header.Set("User-Agent", stockBrowserUserAgent) + req.Header.Set("x-lang", "vi") +} diff --git a/internal/modules/stock/prices_ssi.go b/internal/modules/stock/prices_ssi.go new file mode 100644 index 0000000..80eaa68 --- /dev/null +++ b/internal/modules/stock/prices_ssi.go @@ -0,0 +1,128 @@ +package stock + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// ssiQueryDefaultURL is the SSI iBoard stock-query endpoint base. +const ssiQueryDefaultURL = "https://iboard-query.ssi.com.vn" + +// ssiErrorBodyLimit keeps upstream diagnostics useful without dumping large responses. +const ssiErrorBodyLimit = 512 + +type ssiSingleResponse struct { + Data ssiStockQuote `json:"data"` +} + +type ssiMultipleResponse struct { + Data []ssiStockQuote `json:"data"` +} + +type ssiStockQuote struct { + StockSymbol string `json:"stockSymbol"` + MatchedPrice float64 `json:"matchedPrice"` +} + +func (c *PriceClient) baseURL() string { + if c.URL != "" { + return strings.TrimRight(c.URL, "/") + } + return ssiQueryDefaultURL +} + +func (c *PriceClient) fetchSSIPrice(ctx context.Context, ticker string) (float64, error) { + if ticker == "" { + return 0, errors.New("stock: ticker is empty") + } + full := c.baseURL() + "/stock/" + url.PathEscape(ticker) + req, err := newSSIRequest(ctx, http.MethodGet, full, nil) + if err != nil { + return 0, err + } + + var body ssiSingleResponse + if err := c.doSSIJSON(req, &body); err != nil { + return 0, err + } + price := body.Data.MatchedPrice + if price <= 0 { + return 0, fmt.Errorf("%w: SSI quote has no matchedPrice for %s", ErrNoPrice, strings.ToUpper(ticker)) + } + return price, nil +} + +func (c *PriceClient) fetchSSIPrices(ctx context.Context, tickers []string) (map[string]float64, error) { + if len(tickers) == 0 { + return map[string]float64{}, nil + } + form := url.Values{} + requested := normalizeTickers(tickers) + for _, ticker := range requested { + form.Add("stocks", ticker) + } + if len(form) == 0 { + return nil, errors.New("stock: no tickers to fetch") + } + + req, err := newSSIRequest(ctx, http.MethodPost, c.baseURL()+"/stock/multiple", strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + var body ssiMultipleResponse + if err := c.doSSIJSON(req, &body); err != nil { + return nil, err + } + out := make(map[string]float64, len(body.Data)) + for _, quote := range body.Data { + symbol := strings.ToUpper(strings.TrimSpace(quote.StockSymbol)) + if symbol == "" || quote.MatchedPrice <= 0 { + continue + } + out[symbol] = quote.MatchedPrice + } + if len(out) == 0 { + return nil, fmt.Errorf("%w: SSI batch returned no usable quotes for %s (data_len=%d)", ErrNoPrice, strings.Join(requested, ","), len(body.Data)) + } + return out, nil +} + +func newSSIRequest(ctx context.Context, method, full string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, full, body) + if err != nil { + return nil, fmt.Errorf("stock: build SSI request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Origin", "https://iboard.ssi.com.vn") + req.Header.Set("Referer", "https://iboard.ssi.com.vn/") + req.Header.Set("User-Agent", "Mozilla/5.0 (miti99bot)") + return req, nil +} + +func (c *PriceClient) doSSIJSON(req *http.Request, dst any) error { + resp, err := c.httpClient().Do(req) + if err != nil { + return fmt.Errorf("stock: SSI request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, ssiErrorBodyLimit)) + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("%w: SSI status %d body %q", ErrNoPrice, resp.StatusCode, strings.TrimSpace(string(snippet))) + } + return fmt.Errorf("stock: SSI status %d body %q", resp.StatusCode, strings.TrimSpace(string(snippet))) + } + if err := json.NewDecoder(resp.Body).Decode(dst); err != nil { + return fmt.Errorf("stock: SSI decode: %w", err) + } + return nil +} diff --git a/internal/modules/stock/prices_test.go b/internal/modules/stock/prices_test.go index 732eb26..0f810f7 100644 --- a/internal/modules/stock/prices_test.go +++ b/internal/modules/stock/prices_test.go @@ -2,6 +2,7 @@ package stock import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -73,6 +74,117 @@ func TestPriceClient_BatchHappyPath(t *testing.T) { } } +func TestPriceClient_FallsBackToKBSWhenSSIDirectBlocked(t *testing.T) { + ssiSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("Security Check - SSI")) + })) + t.Cleanup(ssiSrv.Close) + + kbsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/" { + t.Errorf("path = %q, want /", r.URL.Path) + } + var body kbsRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("Decode KBS request: %v", err) + } + if body.Code != "TCB" { + t.Errorf("KBS code = %q, want TCB", body.Code) + } + if got := r.Header.Get("User-Agent"); !strings.Contains(got, "Chrome/120") { + t.Errorf("KBS user-agent = %q, want browser-like", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"SB":"TCB","CP":33400}]`)) + })) + t.Cleanup(kbsSrv.Close) + + c := &PriceClient{HTTP: ssiSrv.Client(), URL: ssiSrv.URL, KBSURL: kbsSrv.URL} + got, err := c.FetchPrice(context.Background(), "TCB") + if err != nil { + t.Fatalf("FetchPrice: %v", err) + } + if got != 33400 { + t.Errorf("price: got %v, want 33400", got) + } +} + +func TestPriceClient_BatchFallsBackToKBSWhenSSIDirectBlocked(t *testing.T) { + ssiSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("Security Check - SSI")) + })) + t.Cleanup(ssiSrv.Close) + + var gotCodes []string + kbsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body kbsRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("Decode KBS request: %v", err) + } + gotCodes = append(gotCodes, body.Code) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"SB":"TCB","CP":33400},{"SB":"FPT","CP":71000}]`)) + })) + t.Cleanup(kbsSrv.Close) + + c := &PriceClient{HTTP: ssiSrv.Client(), URL: ssiSrv.URL, KBSURL: kbsSrv.URL} + got, err := c.FetchPrices(context.Background(), []string{"TCB", "FPT"}) + if err != nil { + t.Fatalf("FetchPrices: %v", err) + } + if got["TCB"] != 33400 || got["FPT"] != 71000 { + t.Errorf("prices = %+v", got) + } + if strings.Join(gotCodes, ",") != "TCB,FPT" { + t.Errorf("KBS codes = %v, want one batch TCB,FPT", gotCodes) + } +} + +func TestPriceClient_BatchFallsBackToVCIWhenKBSBlocked(t *testing.T) { + ssiSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("Security Check - SSI")) + })) + t.Cleanup(ssiSrv.Close) + + kbsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + })) + t.Cleanup(kbsSrv.Close) + + var gotSymbols []string + vciSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body vciRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("Decode VCI request: %v", err) + } + gotSymbols = append(gotSymbols, body.Symbols...) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"listingInfo":{"symbol":"TCB"},"matchPrice":{"matchPrice":33400}}, + {"listingInfo":{"symbol":"FPT"},"matchPrice":{"matchPrice":71000}} + ]`)) + })) + t.Cleanup(vciSrv.Close) + + c := &PriceClient{HTTP: ssiSrv.Client(), URL: ssiSrv.URL, KBSURL: kbsSrv.URL, VCIURL: vciSrv.URL} + got, err := c.FetchPrices(context.Background(), []string{"TCB", "FPT"}) + if err != nil { + t.Fatalf("FetchPrices: %v", err) + } + if got["TCB"] != 33400 || got["FPT"] != 71000 { + t.Errorf("prices = %+v", got) + } + if strings.Join(gotSymbols, ",") != "TCB,FPT" { + t.Errorf("VCI symbols = %v, want TCB,FPT", gotSymbols) + } +} + func TestPriceClient_BatchOmitsInvalidQuotes(t *testing.T) { c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"data":[{"stockSymbol":"TCB","matchedPrice":24500},{"stockSymbol":"BAD","matchedPrice":0},{"stockSymbol":"NEG","matchedPrice":-1}]}`)) diff --git a/internal/modules/stock/prices_vci.go b/internal/modules/stock/prices_vci.go new file mode 100644 index 0000000..255b537 --- /dev/null +++ b/internal/modules/stock/prices_vci.go @@ -0,0 +1,109 @@ +package stock + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// vciDefaultURL is Vietcap's public current quote endpoint. +const vciDefaultURL = "https://trading.vietcap.com.vn/api/price/symbols/getList" + +type vciRequest struct { + Symbols []string `json:"symbols"` +} + +type vciQuote struct { + ListingInfo struct { + Symbol string `json:"symbol"` + } `json:"listingInfo"` + MatchPrice struct { + Price float64 `json:"matchPrice"` + } `json:"matchPrice"` +} + +func (c *PriceClient) vciFallbackEnabled() bool { + return strings.TrimSpace(c.VCIURL) != "" || !c.ssiFirst() +} + +func (c *PriceClient) fetchVCIPrice(ctx context.Context, ticker string) (float64, error) { + prices, err := c.fetchVCIPrices(ctx, []string{ticker}) + if err != nil { + return 0, err + } + price := prices[strings.ToUpper(strings.TrimSpace(ticker))] + if price <= 0 { + return 0, fmt.Errorf("%w: VCI returned no usable quote for %s", ErrNoPrice, strings.ToUpper(ticker)) + } + return price, nil +} + +func (c *PriceClient) fetchVCIPrices(ctx context.Context, tickers []string) (map[string]float64, error) { + requested := normalizeTickers(tickers) + if len(requested) == 0 { + return map[string]float64{}, nil + } + + payload, err := json.Marshal(vciRequest{Symbols: requested}) + if err != nil { + return nil, fmt.Errorf("stock: build VCI payload: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.vciURL(), bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("stock: build VCI request: %w", err) + } + setVCIHeaders(req) + + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("stock: VCI request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("%w: VCI status %d", ErrNoPrice, resp.StatusCode) + } + + var body []vciQuote + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("stock: VCI decode: %w", err) + } + out := make(map[string]float64, len(body)) + for _, quote := range body { + symbol := strings.ToUpper(strings.TrimSpace(quote.ListingInfo.Symbol)) + if symbol == "" || quote.MatchPrice.Price <= 0 { + continue + } + out[symbol] = quote.MatchPrice.Price + } + if len(out) == 0 { + return nil, fmt.Errorf("%w: VCI returned no usable quotes for %s", ErrNoPrice, strings.Join(requested, ",")) + } + return out, nil +} + +func (c *PriceClient) vciURL() string { + if strings.TrimSpace(c.VCIURL) != "" { + return strings.TrimRight(c.VCIURL, "/") + } + return vciDefaultURL +} + +func setVCIHeaders(req *http.Request) { + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Accept-Language", "en-US,en;q=0.9,vi-VN;q=0.8,vi;q=0.7") + req.Header.Set("Cache-Control", "no-cache") + req.Header.Set("Connection", "keep-alive") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("DNT", "1") + req.Header.Set("Origin", "https://trading.vietcap.com.vn") + req.Header.Set("Pragma", "no-cache") + req.Header.Set("Referer", "https://trading.vietcap.com.vn/") + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Site", "same-site") + req.Header.Set("User-Agent", stockBrowserUserAgent) +}