mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-03 02:20:16 +00:00
fix(stock): drop yahoo price fallback
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}]}`))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user