alt/services/worker/internal/providers/kis/live_client_test.go
toki f38cbbc5cb feat(marketdata): KIS 미국장 일봉 수집을 추가한다
미국장 일봉 import가 국내 KIS chart endpoint에 묶여 있으면 NASDAQ/NYSE 요청을 정상화된 USD bar로 저장할 수 없다. provider capability로 venue/timeframe 지원 여부를 먼저 검증하고, KIS overseas dailyprice 응답을 venue timezone과 USD 통화 기준으로 normalize하도록 경로를 분리한다.
2026-06-06 17:54:52 +09:00

714 lines
24 KiB
Go

package kis
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"git.toki-labs.com/toki/alt/packages/domain/market"
workerbacktest "git.toki-labs.com/toki/alt/services/worker/internal/backtest"
"git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer"
"git.toki-labs.com/toki/alt/services/worker/internal/storage"
)
func TestAuthRequestsTokenAndParsesExpiry(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != tokenPath {
t.Fatalf("unexpected path: got %q", r.URL.Path)
}
if r.Method != http.MethodPost {
t.Fatalf("unexpected method: got %q", r.Method)
}
var payload map[string]string
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode auth payload: %v", err)
}
if payload["grant_type"] != "client_credentials" {
t.Errorf("grant_type: got %q", payload["grant_type"])
}
if payload["appkey"] != "app-key" || payload["appsecret"] != "app-secret" {
t.Errorf("auth payload did not carry configured credentials")
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"token-value","access_token_token_expired":"2026-06-04 12:34:56"}`))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
token, err := client.Auth(context.Background())
if err != nil {
t.Fatalf("auth: %v", err)
}
if token.AccessToken != "token-value" {
t.Errorf("access token: got %q", token.AccessToken)
}
if token.ExpiresAt.IsZero() {
t.Error("expected parsed expiry, got zero time")
}
}
func TestAuthUnavailableWhenCredentialsAreMissing(t *testing.T) {
client := NewClient(Config{BaseURL: "https://example.invalid", AppKey: "", AppSecret: ""})
_, err := client.Auth(context.Background())
if err == nil {
t.Fatal("expected unavailable error, got nil")
}
if !IsErrorKind(err, ErrorUnavailable) {
t.Fatalf("error kind: got %v, want %s", err, ErrorUnavailable)
}
}
func TestConfigFromEnvSelectsExplicitEnvironment(t *testing.T) {
t.Run("paper", func(t *testing.T) {
t.Setenv("KIS_ACTIVE_ENV", "paper")
t.Setenv("KIS_PAPER_APP_KEY", "paper-key")
t.Setenv("KIS_PAPER_APP_SECRET", "paper-secret")
t.Setenv("KIS_PAPER_CANO", "paper-cano")
t.Setenv("KIS_PAPER_ACNT_PRDT_CD", "01")
cfg := ConfigFromEnv()
if cfg.ConfigError != "" {
t.Fatalf("config error: %s", cfg.ConfigError)
}
if cfg.Environment != EnvironmentPaper {
t.Fatalf("environment: got %q, want %q", cfg.Environment, EnvironmentPaper)
}
if cfg.BaseURL != PaperBaseURL || cfg.AppKey != "paper-key" || cfg.AppSecret != "paper-secret" {
t.Fatalf("paper config: got %+v", cfg)
}
})
t.Run("real", func(t *testing.T) {
t.Setenv("KIS_ACTIVE_ENV", "real")
t.Setenv("KIS_REAL_APP_KEY", "real-key")
t.Setenv("KIS_REAL_APP_SECRET", "real-secret")
t.Setenv("KIS_REAL_CANO", "real-cano")
t.Setenv("KIS_REAL_ACNT_PRDT_CD", "01")
cfg := ConfigFromEnv()
if cfg.ConfigError != "" {
t.Fatalf("config error: %s", cfg.ConfigError)
}
if cfg.Environment != EnvironmentReal {
t.Fatalf("environment: got %q, want %q", cfg.Environment, EnvironmentReal)
}
if cfg.BaseURL != RealBaseURL || cfg.AppKey != "real-key" || cfg.AppSecret != "real-secret" {
t.Fatalf("real config: got %+v", cfg)
}
})
}
func TestConfigFromEnvRejectsMissingOrUnknownEnvironment(t *testing.T) {
for _, activeEnv := range []string{"", "papre"} {
t.Run("env="+activeEnv, func(t *testing.T) {
t.Setenv("KIS_ACTIVE_ENV", activeEnv)
client := NewClient(ConfigFromEnv())
_, err := client.Auth(context.Background())
if err == nil {
t.Fatal("expected unavailable error, got nil")
}
if !IsErrorKind(err, ErrorUnavailable) {
t.Fatalf("error kind: got %v, want %s", err, ErrorUnavailable)
}
if !strings.Contains(err.Error(), "KIS_ACTIVE_ENV") {
t.Fatalf("error should mention KIS_ACTIVE_ENV: %v", err)
}
})
}
}
func TestInquireDailyItemChartPriceSendsHeadersAndDecodes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != DailyItemChartPricePath {
t.Fatalf("unexpected path: got %q", r.URL.Path)
}
if got := r.Header.Get("authorization"); got != "Bearer access-token" {
t.Errorf("authorization header: got %q", got)
}
if got := r.Header.Get("tr_id"); got != DailyItemChartPriceTRID {
t.Errorf("tr_id header: got %q", got)
}
if got := r.Header.Get("custtype"); got != "P" {
t.Errorf("custtype header: got %q", got)
}
query := r.URL.Query()
wantParams := map[string]string{
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": "005930",
"FID_INPUT_DATE_1": "20240527",
"FID_INPUT_DATE_2": "20240528",
"FID_PERIOD_DIV_CODE": "D",
"FID_ORG_ADJ_PRC": "0",
}
for key, want := range wantParams {
if got := query.Get(key); got != want {
t.Errorf("query %s: got %q, want %q", key, got, want)
}
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(readFixture(t, "daily_itemchartprice_response.sample.json"))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
resp, err := client.InquireDailyItemChartPrice(context.Background(), "access-token", DailyItemChartPriceQuery{
Symbol: "005930",
From: "20240527",
To: "20240528",
})
if err != nil {
t.Fatalf("daily query: %v", err)
}
if resp.Output1.ShortCode != "005930" {
t.Errorf("short code: got %q", resp.Output1.ShortCode)
}
if len(resp.Output2) != 2 {
t.Errorf("bar rows: got %d, want 2", len(resp.Output2))
}
}
func TestInquireDailyItemChartPriceClassifiesQuotaAndMalformed(t *testing.T) {
t.Run("quota", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"msg_cd":"RATE_LIMIT","msg1":"too many requests"}`))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
_, err := client.InquireDailyItemChartPrice(context.Background(), "access-token", DailyItemChartPriceQuery{
Symbol: "005930",
From: "20240527",
To: "20240528",
})
if err == nil || !IsErrorKind(err, ErrorQuota) {
t.Fatalf("error kind: got %v, want %s", err, ErrorQuota)
}
})
t.Run("malformed", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{`))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
_, err := client.InquireDailyItemChartPrice(context.Background(), "access-token", DailyItemChartPriceQuery{
Symbol: "005930",
From: "20240527",
To: "20240528",
})
if err == nil || !IsErrorKind(err, ErrorMalformed) {
t.Fatalf("error kind: got %v, want %s", err, ErrorMalformed)
}
})
}
func TestInquireDailyItemChartPriceDoesNotExposeSecretsInError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"msg_cd":"AUTH","msg1":"invalid credential"}`))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
_, err := client.InquireDailyItemChartPrice(context.Background(), "access-token", DailyItemChartPriceQuery{
Symbol: "005930",
From: "20240527",
To: "20240528",
})
if err == nil {
t.Fatal("expected error, got nil")
}
text := err.Error()
for _, secret := range []string{"app-key", "app-secret", "access-token"} {
if strings.Contains(text, secret) {
t.Fatalf("error exposed secret %q: %s", secret, text)
}
}
}
func TestLiveProviderFetchesAndNormalizesDailyBars(t *testing.T) {
var authCalls, dailyCalls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case tokenPath:
authCalls++
_, _ = w.Write([]byte(`{"access_token":"access-token","access_token_token_expired":"2026-06-04 12:34:56"}`))
case DailyItemChartPricePath:
dailyCalls++
if got := r.URL.Query().Get("FID_INPUT_ISCD"); got != "005930" {
t.Errorf("symbol query: got %q", got)
}
_, _ = w.Write(readFixture(t, "daily_itemchartprice_response.sample.json"))
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
provider := NewLiveProvider(client)
seoul := time.FixedZone("Asia/Seoul", 9*60*60)
items, err := provider.FetchDailyBars(context.Background(), importer.DailyBarRequest{
Provider: market.ProviderKIS,
Selector: market.UniverseSelector{
Kind: market.UniverseSelectorWatchlist,
Venue: market.VenueKRX,
Symbols: []string{"005930"},
},
From: time.Date(2024, 5, 27, 0, 0, 0, 0, seoul),
To: time.Date(2024, 5, 28, 0, 0, 0, 0, seoul),
})
if err != nil {
t.Fatalf("fetch daily bars: %v", err)
}
if authCalls != 1 || dailyCalls != 1 {
t.Fatalf("calls: auth=%d daily=%d, want 1 each", authCalls, dailyCalls)
}
if len(items) != 1 {
t.Fatalf("items: got %d, want 1", len(items))
}
if items[0].Instrument.ID != market.InstrumentID("KRX:005930") {
t.Errorf("instrument id: got %q", items[0].Instrument.ID)
}
if got := items[0].Instrument.ProviderSymbols[string(market.ProviderKIS)]; got != "005930" {
t.Errorf("provider symbol: got %q", got)
}
if len(items[0].Bars) != 2 {
t.Fatalf("bars: got %d, want 2", len(items[0].Bars))
}
if items[0].Bars[0].InstrumentID != items[0].Instrument.ID {
t.Errorf("bar instrument id: got %q, want %q", items[0].Bars[0].InstrumentID, items[0].Instrument.ID)
}
}
func TestInquireOverseasDailyPriceSendsHeadersAndDecodes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != OverseasDailyPricePath {
t.Fatalf("unexpected path: got %q", r.URL.Path)
}
if got := r.Header.Get("authorization"); got != "Bearer access-token" {
t.Errorf("authorization header: got %q", got)
}
if got := r.Header.Get("tr_id"); got != OverseasDailyPriceTRID {
t.Errorf("tr_id header: got %q", got)
}
if got := r.Header.Get("custtype"); got != "P" {
t.Errorf("custtype header: got %q", got)
}
query := r.URL.Query()
wantParams := map[string]string{
"EXCD": "NAS",
"SYMB": "AAPL",
"GUBN": "0",
"BYMD": "20240529",
"MODP": "1",
}
for key, want := range wantParams {
if got := query.Get(key); got != want {
t.Errorf("query %s: got %q, want %q", key, got, want)
}
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(readFixture(t, "overseas_dailyprice_response.sample.json"))
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
resp, err := client.InquireOverseasDailyPrice(context.Background(), "access-token", OverseasDailyPriceQuery{
ExchangeCode: "NAS",
Symbol: "AAPL",
BaseDate: "20240529",
})
if err != nil {
t.Fatalf("overseas query: %v", err)
}
if len(resp.Output2) != 2 {
t.Errorf("bar rows: got %d, want 2", len(resp.Output2))
}
// Lock the overseas field rename the decoder must read.
if resp.Output2[0].BusinessDate != "20240528" || resp.Output2[0].Close != "189.99" {
t.Errorf("overseas row decode: got date=%q clos=%q", resp.Output2[0].BusinessDate, resp.Output2[0].Close)
}
}
func TestLiveProviderFetchesAndNormalizesOverseasDailyBars(t *testing.T) {
var authCalls, overseasCalls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case tokenPath:
authCalls++
_, _ = w.Write([]byte(`{"access_token":"access-token","access_token_token_expired":"2026-06-04 12:34:56"}`))
case OverseasDailyPricePath:
overseasCalls++
if got := r.URL.Query().Get("SYMB"); got != "AAPL" {
t.Errorf("symbol query: got %q", got)
}
if got := r.URL.Query().Get("EXCD"); got != "NAS" {
t.Errorf("exchange query: got %q", got)
}
_, _ = w.Write(readFixture(t, "overseas_dailyprice_response.sample.json"))
case DailyItemChartPricePath:
t.Fatal("US request must not hit the domestic endpoint")
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
provider := NewLiveProvider(client)
ny := time.FixedZone("America/New_York", -4*60*60)
items, err := provider.FetchDailyBars(context.Background(), importer.DailyBarRequest{
Provider: market.ProviderKIS,
Selector: market.UniverseSelector{
Kind: market.UniverseSelectorWatchlist,
Market: market.MarketUS,
Venue: market.VenueNASDAQ,
Symbols: []string{"AAPL"},
},
From: time.Date(2024, 5, 28, 0, 0, 0, 0, ny),
To: time.Date(2024, 5, 29, 0, 0, 0, 0, ny),
})
if err != nil {
t.Fatalf("fetch overseas daily bars: %v", err)
}
if authCalls != 1 || overseasCalls != 1 {
t.Fatalf("calls: auth=%d overseas=%d, want 1 each", authCalls, overseasCalls)
}
if len(items) != 1 {
t.Fatalf("items: got %d, want 1", len(items))
}
inst := items[0].Instrument
if inst.ID != market.InstrumentID("NASDAQ:AAPL") {
t.Errorf("instrument id: got %q, want NASDAQ:AAPL", inst.ID)
}
if inst.Market != market.MarketUS || inst.Venue != market.VenueNASDAQ {
t.Errorf("instrument market/venue: got %q/%q, want US/NASDAQ", inst.Market, inst.Venue)
}
if inst.Currency != market.CurrencyUSD {
t.Errorf("instrument currency: got %q, want USD", inst.Currency)
}
if got := inst.ProviderSymbols[string(market.ProviderKIS)]; got != "AAPL" {
t.Errorf("provider symbol: got %q, want AAPL", got)
}
if len(items[0].Bars) != 2 {
t.Fatalf("bars: got %d, want 2", len(items[0].Bars))
}
bar := items[0].Bars[0]
if bar.Close.Currency != market.CurrencyUSD {
t.Errorf("bar currency: got %q, want USD", bar.Close.Currency)
}
if got := bar.Timestamp.Format(time.RFC3339); got != "2024-05-28T00:00:00-04:00" {
t.Errorf("bar timestamp: got %q, want New York midnight", got)
}
}
func TestLiveProviderFiltersOverseasDailyBarsToRequestedRange(t *testing.T) {
var authCalls, overseasCalls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case tokenPath:
authCalls++
_, _ = w.Write([]byte(`{"access_token":"access-token","access_token_token_expired":"2026-06-04 12:34:56"}`))
case OverseasDailyPricePath:
overseasCalls++
if got := r.URL.Query().Get("SYMB"); got != "AAPL" {
t.Errorf("symbol query: got %q", got)
}
if got := r.URL.Query().Get("EXCD"); got != "NAS" {
t.Errorf("exchange query: got %q", got)
}
var resp OverseasDailyPriceResponse
fixtureData := readFixture(t, "overseas_dailyprice_response.sample.json")
if err := json.Unmarshal(fixtureData, &resp); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
resp.Output2 = append(resp.Output2, OverseasDailyPriceBarRow{
BusinessDate: "20240527",
Open: "188.00",
High: "189.00",
Low: "187.00",
Close: "188.50",
Volume: "40000000",
TradeAmount: "7500000000",
})
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
t.Fatalf("encode mock response: %v", err)
}
case DailyItemChartPricePath:
t.Fatal("US request must not hit the domestic endpoint")
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
provider := NewLiveProvider(client)
ny := time.FixedZone("America/New_York", -4*60*60)
items, err := provider.FetchDailyBars(context.Background(), importer.DailyBarRequest{
Provider: market.ProviderKIS,
Selector: market.UniverseSelector{
Kind: market.UniverseSelectorWatchlist,
Market: market.MarketUS,
Venue: market.VenueNASDAQ,
Symbols: []string{"AAPL"},
},
From: time.Date(2024, 5, 28, 0, 0, 0, 0, ny),
To: time.Date(2024, 5, 29, 0, 0, 0, 0, ny),
})
if err != nil {
t.Fatalf("fetch overseas daily bars: %v", err)
}
if authCalls != 1 || overseasCalls != 1 {
t.Fatalf("calls: auth=%d overseas=%d, want 1 each", authCalls, overseasCalls)
}
if len(items) != 1 {
t.Fatalf("items: got %d, want 1", len(items))
}
if len(items[0].Bars) != 2 {
t.Fatalf("bars: got %d, want 2", len(items[0].Bars))
}
for _, bar := range items[0].Bars {
barDate := bar.Timestamp.Format("20060102")
if barDate != "20240528" && barDate != "20240529" {
t.Errorf("unexpected bar date stored: %s", barDate)
}
}
}
func TestLiveProviderRejectsMissingRange(t *testing.T) {
provider := NewLiveProvider(NewClient(Config{BaseURL: "https://example.invalid", AppKey: "app-key", AppSecret: "app-secret"}))
_, err := provider.FetchDailyBars(context.Background(), importer.DailyBarRequest{
Provider: market.ProviderKIS,
Selector: market.UniverseSelector{Symbols: []string{"005930"}},
})
if err == nil || !IsErrorKind(err, ErrorMalformed) {
t.Fatalf("error kind: got %v, want %s", err, ErrorMalformed)
}
}
type importStore struct {
instruments map[market.InstrumentID]market.Instrument
bars map[importBarKey]market.Bar
}
type importBarKey struct {
instrumentID market.InstrumentID
timeframe market.Timeframe
timestamp time.Time
}
func newImportStore() *importStore {
return &importStore{
instruments: make(map[market.InstrumentID]market.Instrument),
bars: make(map[importBarKey]market.Bar),
}
}
func (s *importStore) UpsertInstrument(_ context.Context, inst market.Instrument) error {
s.instruments[inst.ID] = inst
return nil
}
func (s *importStore) GetInstrument(_ context.Context, id market.InstrumentID) (market.Instrument, error) {
inst, ok := s.instruments[id]
if !ok {
return market.Instrument{}, storage.ErrInstrumentNotFound
}
return inst, nil
}
func (s *importStore) ListInstruments(_ context.Context) ([]market.Instrument, error) {
out := make([]market.Instrument, 0, len(s.instruments))
for _, inst := range s.instruments {
out = append(out, inst)
}
return out, nil
}
func (s *importStore) UpsertBar(_ context.Context, bar market.Bar) error {
s.bars[importBarKey{bar.InstrumentID, bar.Timeframe, bar.Timestamp}] = bar
return nil
}
func (s *importStore) GetBars(_ context.Context, id market.InstrumentID, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) {
var out []market.Bar
for key, bar := range s.bars {
if key.instrumentID != id || key.timeframe != timeframe {
continue
}
if key.timestamp.Before(from) || key.timestamp.After(to) {
continue
}
out = append(out, bar)
}
return out, nil
}
func TestLiveProviderImportFeedsBacktestBarSource(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case tokenPath:
_, _ = w.Write([]byte(`{"access_token":"access-token","access_token_token_expired":"2026-06-04 12:34:56"}`))
case DailyItemChartPricePath:
_, _ = w.Write(readFixture(t, "daily_itemchartprice_response.sample.json"))
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
provider := NewLiveProvider(client)
store := newImportStore()
imp := importer.New(provider, store)
seoul := time.FixedZone("Asia/Seoul", 9*60*60)
from := time.Date(2024, 5, 27, 0, 0, 0, 0, seoul)
to := time.Date(2024, 5, 28, 0, 0, 0, 0, seoul)
result, err := imp.ImportDailyBars(context.Background(), importer.DailyBarRequest{
Provider: market.ProviderKIS,
Selector: market.UniverseSelector{
Kind: market.UniverseSelectorWatchlist,
Venue: market.VenueKRX,
Symbols: []string{"005930"},
},
From: from,
To: to,
})
if err != nil {
t.Fatalf("import daily bars: %v", err)
}
if result.Instruments != 1 || result.Bars != 2 {
t.Fatalf("import result: got %+v, want 1 instrument and 2 bars", result)
}
source := workerbacktest.NewStorageBarSource(store, store)
bars, err := source.GetBars(context.Background(), market.MarketKR, market.TimeframeDaily, from, to)
if err != nil {
t.Fatalf("backtest bar source query: %v", err)
}
if len(bars) != 2 {
t.Fatalf("query bars: got %d, want 2", len(bars))
}
if bars[0].InstrumentID != market.InstrumentID("KRX:005930") {
t.Fatalf("bar instrument id: got %q", bars[0].InstrumentID)
}
}
func TestLiveProviderImportStoresUSDailyBars(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case tokenPath:
_, _ = w.Write([]byte(`{"access_token":"access-token","access_token_token_expired":"2026-06-04 12:34:56"}`))
case OverseasDailyPricePath:
_, _ = w.Write(readFixture(t, "overseas_dailyprice_response.sample.json"))
case DailyItemChartPricePath:
t.Fatal("US import must not hit the domestic endpoint")
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
provider := NewLiveProvider(client)
store := newImportStore()
imp := importer.New(provider, store)
ny := time.FixedZone("America/New_York", -4*60*60)
from := time.Date(2024, 5, 28, 0, 0, 0, 0, ny)
to := time.Date(2024, 5, 29, 0, 0, 0, 0, ny)
result, err := imp.ImportDailyBars(context.Background(), importer.DailyBarRequest{
Provider: market.ProviderKIS,
Selector: market.UniverseSelector{
Kind: market.UniverseSelectorWatchlist,
Market: market.MarketUS,
Venue: market.VenueNASDAQ,
Symbols: []string{"AAPL"},
},
From: from,
To: to,
})
if err != nil {
t.Fatalf("import US daily bars: %v", err)
}
if result.Instruments != 1 || result.Bars != 2 {
t.Fatalf("import result: got %+v, want 1 instrument and 2 bars", result)
}
stored, err := store.GetInstrument(context.Background(), market.InstrumentID("NASDAQ:AAPL"))
if err != nil {
t.Fatalf("get stored instrument: %v", err)
}
if stored.Market != market.MarketUS || stored.Venue != market.VenueNASDAQ || stored.Currency != market.CurrencyUSD {
t.Errorf("stored instrument: got market=%q venue=%q currency=%q, want US/NASDAQ/USD", stored.Market, stored.Venue, stored.Currency)
}
bars, err := store.GetBars(context.Background(), market.InstrumentID("NASDAQ:AAPL"), market.TimeframeDaily, from.Add(-24*time.Hour), to.Add(24*time.Hour))
if err != nil {
t.Fatalf("get stored bars: %v", err)
}
if len(bars) != 2 {
t.Fatalf("stored bars: got %d, want 2", len(bars))
}
for _, b := range bars {
if b.Close.Currency != market.CurrencyUSD {
t.Errorf("stored bar currency: got %q, want USD", b.Close.Currency)
}
}
}
func TestLiveProviderPaperSmoke(t *testing.T) {
if os.Getenv("KIS_LIVE_SMOKE") != "1" {
t.Skip("set KIS_LIVE_SMOKE=1 with SOPS-injected KIS paper env to run live smoke")
}
runLiveProviderSmoke(t, EnvironmentPaper)
}
func TestLiveProviderRealSmoke(t *testing.T) {
if os.Getenv("KIS_REAL_LIVE_SMOKE") != "1" {
t.Skip("set KIS_REAL_LIVE_SMOKE=1 with SOPS-injected KIS real env to run live smoke")
}
runLiveProviderSmoke(t, EnvironmentReal)
}
func runLiveProviderSmoke(t *testing.T, env Environment) {
t.Helper()
if os.Getenv("KIS_ACTIVE_ENV") != string(env) {
t.Fatalf("KIS_ACTIVE_ENV must be %s for this live smoke", env)
}
provider := NewLiveProvider(NewClient(ConfigFromEnv()))
seoul := time.FixedZone("Asia/Seoul", 9*60*60)
items, err := provider.FetchDailyBars(context.Background(), importer.DailyBarRequest{
Provider: market.ProviderKIS,
Selector: market.UniverseSelector{
Kind: market.UniverseSelectorWatchlist,
Venue: market.VenueKRX,
Symbols: []string{"005930"},
},
From: time.Date(2024, 5, 27, 0, 0, 0, 0, seoul),
To: time.Date(2024, 5, 28, 0, 0, 0, 0, seoul),
})
if err != nil {
t.Fatalf("%s live provider smoke: %v", env, err)
}
if len(items) != 1 {
t.Fatalf("items: got %d, want 1", len(items))
}
if items[0].Instrument.ID != market.InstrumentID("KRX:005930") {
t.Fatalf("instrument id: got %q", items[0].Instrument.ID)
}
if len(items[0].Bars) == 0 {
t.Fatal("expected at least one live bar")
}
}