fix(stock): use ssi price provider

This commit is contained in:
2026-06-25 15:51:33 +07:00
parent d02a110d8e
commit e15f6ae1f1
7 changed files with 275 additions and 116 deletions
+14 -16
View File
@@ -328,9 +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 every held ticker's current price sequentially (reusing
// the pooled KBS connection) and renders the portfolio. Read-only — no
// portfolio mutation, so no keylock.
// handleStats fetches current prices for held tickers in one SSI batch request
// 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 {
@@ -366,21 +365,21 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
if len(heldList) > 0 {
lines = append(lines, "\nStocks:")
// Fetch sequentially, NOT concurrently. The memoised HTTP client keeps a
// keep-alive connection pool across calls (see PriceClient), so serial
// fetches to the same KBS host pay one TLS handshake and reuse the
// connection. Firing them in parallel instead opens N simultaneous
// handshakes into an empty pool; on the memory-constrained Lambda
// (256MB ≈ 0.15 vCPU) those CPU-bound handshakes thrash and each blows
// past the per-fetch timeout. The reply-reserved sub-context bounds the
// whole loop so the final Reply keeps its budget; a failed/slow ticker
// degrades to "(no price)" rather than failing the summary.
fetchCtx, cancel := chathelper.FetchContext(ctx)
defer cancel()
symbols := make([]string, 0, len(heldList))
for _, h := range heldList {
price, err := s.prices.FetchPrice(fetchCtx, h.symbol)
if err != nil {
log.Error("stock_fetch_price", "symbol", h.symbol, "err", err)
symbols = append(symbols, h.symbol)
}
prices, fetchErr := s.prices.FetchPrices(fetchCtx, symbols)
if fetchErr != nil {
log.Error("stock_fetch_prices", "symbols", strings.Join(symbols, ","), "err", fetchErr)
}
for _, h := range heldList {
price := prices[h.symbol]
if fetchErr != nil || price <= 0 {
lines = append(lines, " "+h.symbol+" x"+FormatStock(float64(h.qty))+" (no price)")
continue
}
@@ -390,7 +389,6 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
" @ "+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))
+1 -1
View File
@@ -115,7 +115,7 @@ func installTradingIncomeEvents(t *testing.T, eventBody string, now time.Time) (
priceSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data_day":[{"c":24500}]}`))
_, _ = w.Write([]byte(`{"data":{"stockSymbol":"TCX","matchedPrice":24500}}`))
}))
t.Cleanup(priceSrv.Close)
+100 -62
View File
@@ -5,34 +5,29 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// kbsDefaultURL is the KBS public stock data endpoint base.
const kbsDefaultURL = "https://kbbuddywts.kbsec.com.vn/iis-server/investment/stocks"
// ssiQueryDefaultURL is the SSI iBoard stock-query endpoint base.
const ssiQueryDefaultURL = "https://iboard-query.ssi.com.vn"
// 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
// 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
// kbsHTTPTimeout caps a single ticker's price fetch. Kept well under the
// handler's overall deadline so one slow/hung ticker cannot drain the budget
// the handler needs to deliver its Telegram reply (see chathelper.FetchContext).
const kbsHTTPTimeout = 3 * time.Second
// PriceClient is the KBS price fetcher. Zero value uses the default URL +
// `&{Timeout: kbsHTTPTimeout}` HTTP client; tests inject HTTP + URL.
// PriceClient is the SSI iBoard stock quote fetcher. Zero value uses the
// default SSI URL + a timeout-bound HTTP client; tests inject HTTP + URL.
type PriceClient struct {
HTTP *http.Client
URL string
// defaultClient memoises the zero-value HTTP fallback so the transport's
// connection pool survives across FetchPrice calls — /stock_stats fans
// out per held ticker, and a fresh client per call means a fresh TLS
// handshake per ticker.
// defaultClient memoises the zero-value HTTP client so the transport's
// connection pool survives across stock commands.
defaultOnce sync.Once
defaultClient *http.Client
}
@@ -42,84 +37,127 @@ func (c *PriceClient) httpClient() *http.Client {
return c.HTTP
}
c.defaultOnce.Do(func() {
c.defaultClient = &http.Client{Timeout: kbsHTTPTimeout}
c.defaultClient = &http.Client{Timeout: ssiHTTPTimeout}
})
return c.defaultClient
}
func (c *PriceClient) baseURL() string {
if c.URL != "" {
return c.URL
return strings.TrimRight(c.URL, "/")
}
return kbsDefaultURL
return ssiQueryDefaultURL
}
// 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 ssiSingleResponse struct {
Data ssiStockQuote `json:"data"`
}
type kbsBar struct {
C float64 `json:"c"` // close, already in VND, unscaled
type ssiMultipleResponse struct {
Data []ssiStockQuote `json:"data"`
}
// 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())
type ssiStockQuote struct {
StockSymbol string `json:"stockSymbol"`
MatchedPrice float64 `json:"matchedPrice"`
}
// 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.
// 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.
func (c *PriceClient) FetchPrice(ctx context.Context, ticker string) (float64, error) {
if ticker == "" {
return 0, errors.New("stock: 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)
full := c.baseURL() + "/stock/" + url.PathEscape(ticker)
req, err := newSSIRequest(ctx, http.MethodGet, full, nil)
if err != nil {
return 0, fmt.Errorf("stock: build KBS request: %w", err)
return 0, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (miti99bot)")
var body ssiSingleResponse
if err := c.doJSON(req, &body); err != nil {
return 0, err
}
price := body.Data.MatchedPrice
if price <= 0 {
return 0, ErrNoPrice
}
return price, nil
}
// 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.
func (c *PriceClient) FetchPrices(ctx context.Context, tickers []string) (map[string]float64, error) {
if len(tickers) == 0 {
return map[string]float64{}, nil
}
form := url.Values{}
for _, ticker := range tickers {
ticker = strings.TrimSpace(ticker)
if ticker == "" {
continue
}
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.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, ErrNoPrice
}
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) doJSON(req *http.Request, dst any) error {
resp, err := c.httpClient().Do(req)
if err != nil {
return 0, fmt.Errorf("stock: KBS request: %w", err)
return fmt.Errorf("stock: SSI request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return 0, ErrNoPrice
return ErrNoPrice
}
var body kbsResponse
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return 0, fmt.Errorf("stock: KBS decode: %w", err)
if err := json.NewDecoder(resp.Body).Decode(dst); err != nil {
return fmt.Errorf("stock: SSI 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
return nil
}
// ErrNoPrice means KBS returned no usable price for the ticker either the
// 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".
var ErrNoPrice = errors.New("stock: no price available")
+64 -24
View File
@@ -22,27 +22,73 @@ func newTestPriceClient(t *testing.T, handler http.HandlerFunc) (*PriceClient, *
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") {
if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
if r.URL.Path != "/stock/TCB" {
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)
if got := r.Header.Get("Origin"); got != "https://iboard.ssi.com.vn" {
t.Errorf("origin = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data_day":[{"c":24500}, {"c":24300}]}`))
_, _ = w.Write([]byte(`{"data":{"stockSymbol":"TCB","matchedPrice":24500}}`))
})
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)
t.Errorf("price: got %v, want 24500", got)
}
}
func TestPriceClient_BatchHappyPath(t *testing.T) {
c, _ := newTestPriceClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if r.URL.Path != "/stock/multiple" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if got := r.Header.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
t.Errorf("content-type = %q", got)
}
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm: %v", err)
}
stocks := r.Form["stocks"]
if strings.Join(stocks, ",") != "TCB,FPT" {
t.Errorf("stocks = %v, want [TCB FPT]", stocks)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"stockSymbol":"TCB","matchedPrice":24500},{"stockSymbol":"FPT","matchedPrice":120000}]}`))
})
got, err := c.FetchPrices(context.Background(), []string{"TCB", "FPT"})
if err != nil {
t.Fatalf("FetchPrices: %v", err)
}
if got["TCB"] != 24500 || got["FPT"] != 120000 {
t.Errorf("prices = %+v", got)
}
}
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}]}`))
})
got, err := c.FetchPrices(context.Background(), []string{"TCB", "BAD", "NEG"})
if err != nil {
t.Fatalf("FetchPrices: %v", err)
}
if len(got) != 1 || got["TCB"] != 24500 {
t.Errorf("prices = %+v, want only TCB", got)
}
}
func TestPriceClient_NoData_ReturnsErrNoPrice(t *testing.T) {
c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data_day":[]}`))
_, _ = w.Write([]byte(`{"data":null}`))
})
_, err := c.FetchPrice(context.Background(), "NOPE")
if !errors.Is(err, ErrNoPrice) {
@@ -50,6 +96,16 @@ func TestPriceClient_NoData_ReturnsErrNoPrice(t *testing.T) {
}
}
func TestPriceClient_BatchNoUsableData_ReturnsErrNoPrice(t *testing.T) {
c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":[{"stockSymbol":"NOPE","matchedPrice":0}]}`))
})
_, err := c.FetchPrices(context.Background(), []string{"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)
@@ -60,9 +116,9 @@ func TestPriceClient_4xx_ReturnsErrNoPrice(t *testing.T) {
}
}
func TestPriceClient_NegativeClose_ReturnsErrNoPrice(t *testing.T) {
func TestPriceClient_NegativeMatchedPrice_ReturnsErrNoPrice(t *testing.T) {
c, _ := newTestPriceClient(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data_day":[{"c":-1}]}`))
_, _ = w.Write([]byte(`{"data":{"stockSymbol":"WEIRD","matchedPrice":-1}}`))
})
_, err := c.FetchPrice(context.Background(), "WEIRD")
if !errors.Is(err, ErrNoPrice) {
@@ -77,19 +133,3 @@ func TestPriceClient_EmptyTicker(t *testing.T) {
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)
}
}
}
+84
View File
@@ -0,0 +1,84 @@
package stock
import (
"context"
"net/http"
"net/http/httptest"
"reflect"
"sort"
"strings"
"testing"
"time"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/testutil"
)
func TestHandleStats_UsesSSIBatchPrices(t *testing.T) {
ctx := context.Background()
now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)
requests := 0
var gotStocks []string
priceSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if r.URL.Path != "/stock/multiple" {
t.Errorf("path = %q, want /stock/multiple", r.URL.Path)
}
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm: %v", err)
}
gotStocks = append([]string(nil), r.PostForm["stocks"]...)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"stockSymbol":"MWG","matchedPrice":70000},{"stockSymbol":"TCB","matchedPrice":30000},{"stockSymbol":"FPT","matchedPrice":120000}]}`))
}))
t.Cleanup(priceSrv.Close)
kv := storage.NewMemoryKVStore()
p := NewPortfolio(now.UnixMilli())
p.Currency["VND"] = 2335000
p.Meta.Invested = 1000000000
p.AddAsset("MWG", 1800)
p.AddAsset("TCB", 4200)
p.AddAsset("FPT", 2300)
if err := SavePortfolio(ctx, kv, 7, p); err != nil {
t.Fatalf("SavePortfolio: %v", err)
}
s := &state{
kv: kv,
prices: &PriceClient{HTTP: priceSrv.Client(), URL: priceSrv.URL},
nowFn: func() time.Time { return now },
}
rb := testutil.NewRecordingBot(t)
if err := s.handleStats(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_stats")); err != nil {
t.Fatalf("handleStats: %v", err)
}
if requests != 1 {
t.Fatalf("requests = %d, want one SSI batch request", requests)
}
sort.Strings(gotStocks)
if wantStocks := []string{"FPT", "MWG", "TCB"}; !reflect.DeepEqual(gotStocks, wantStocks) {
t.Fatalf("stocks form values = %#v, want %#v", gotStocks, wantStocks)
}
text := rb.LastSent().Text()
for _, want := range []string{
"MWG x1800 @ 70.000 VND = 126.000.000 VND",
"TCB x4200 @ 30.000 VND = 126.000.000 VND",
"FPT x2300 @ 120.000 VND = 276.000.000 VND",
"Total value: 530.335.000 VND",
"P&L: -469.665.000 VND (-46.97%)",
} {
if !strings.Contains(text, want) {
t.Fatalf("stats missing %q in:\n%s", want, text)
}
}
if strings.Contains(text, "(no price)") {
t.Fatalf("stats rendered missing prices:\n%s", text)
}
}
+4 -4
View File
@@ -11,7 +11,7 @@ import (
)
// tickerRe restricts tickers to ASCII alphanumeric, 1-16 chars. Stops
// Cyrillic / unicode-lookalike inputs from amplifying KBS lookups, and
// Cyrillic / unicode-lookalike inputs from amplifying price-provider lookups, and
// guards the cache key alphabet (sym:<TICKER>) from oddities.
var tickerRe = regexp.MustCompile(`^[A-Z0-9]{1,16}$`)
@@ -23,12 +23,12 @@ type ResolvedSymbol struct {
Label string `json:"label"`
}
// ErrUnknownTicker means KBS has no price data for the given ticker — i.e.
// ErrUnknownTicker means the price provider 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("stock: unknown ticker")
// ResolveSymbol returns the cached ResolvedSymbol if any, otherwise queries
// KBS to validate the ticker and caches the result permanently. Tickers
// the price provider 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
@@ -47,7 +47,7 @@ func ResolveSymbol(ctx context.Context, kv storage.KVStore, prices *PriceClient,
return ResolvedSymbol{}, fmt.Errorf("stock: cache read %s: %w", ticker, err)
}
// Cache miss → validate against KBS by attempting a price fetch.
// Cache miss → validate against the price provider by attempting a price fetch.
if _, err := prices.FetchPrice(ctx, ticker); err != nil {
if errors.Is(err, ErrNoPrice) {
return ResolvedSymbol{}, ErrUnknownTicker
+8 -9
View File
@@ -5,7 +5,6 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
@@ -18,7 +17,7 @@ func TestResolveSymbol_FirstTime_QueriesAndCaches(t *testing.T) {
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}]}`))
_, _ = w.Write([]byte(`{"data":{"stockSymbol":"TCB","matchedPrice":24500}}`))
}))
defer srv.Close()
prices := &PriceClient{URL: srv.URL}
@@ -31,23 +30,23 @@ func TestResolveSymbol_FirstTime_QueriesAndCaches(t *testing.T) {
t.Errorf("resolved: got %+v, want {TCB stock TCB}", got)
}
if atomic.LoadInt32(&hits) != 1 {
t.Errorf("KBS hits: got %d, want 1", hits)
t.Errorf("price hits: got %d, want 1", hits)
}
// Second call should hit the cache, not KBS.
// Second call should hit the cache, not the price provider.
_, 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)
t.Errorf("price 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":[]}`))
_, _ = w.Write([]byte(`{"data":{"stockSymbol":"NOPE","matchedPrice":0}}`))
}))
defer srv.Close()
prices := &PriceClient{URL: srv.URL}
@@ -68,11 +67,11 @@ func TestResolveSymbol_EmptyInput(t *testing.T) {
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/") {
// SSI endpoint should receive the upper-cased ticker.
if r.URL.Path != "/stock/FPT" {
t.Errorf("ticker not upper-cased in URL: %s", r.URL.Path)
}
_, _ = w.Write([]byte(`{"data_day":[{"c":120000}]}`))
_, _ = w.Write([]byte(`{"data":{"stockSymbol":"FPT","matchedPrice":120000}}`))
}))
defer srv.Close()
prices := &PriceClient{URL: srv.URL}