미국장 일봉 import가 국내 KIS chart endpoint에 묶여 있으면 NASDAQ/NYSE 요청을 정상화된 USD bar로 저장할 수 없다. provider capability로 venue/timeframe 지원 여부를 먼저 검증하고, KIS overseas dailyprice 응답을 venue timezone과 USD 통화 기준으로 normalize하도록 경로를 분리한다.
714 lines
24 KiB
Go
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")
|
|
}
|
|
}
|