fix(stock): drop yahoo price fallback

This commit is contained in:
2026-06-25 17:16:54 +07:00
parent 35372d25d7
commit 350f1efee1
7 changed files with 586 additions and 123 deletions
+10
View File
@@ -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`.
+2 -2
View File
@@ -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 {
+122 -121
View File
@@ -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)
}
+103
View File
@@ -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")
}
+128
View File
@@ -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
}
+112
View File
@@ -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("<title>Security Check - SSI</title>"))
}))
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("<title>Security Check - SSI</title>"))
}))
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("<title>Security Check - SSI</title>"))
}))
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}]}`))
+109
View File
@@ -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)
}