alt/services/worker/internal/providers/kis/live_client_test.go
toki 8c3c4ba5a2 feat: operator surface phase updates, worker backtest/kis live client, binary utilities
- Add bin/kis-paper-daily-smoke and bin/kis-sops-env utilities
- Update agent-roadmap for operator surface phase
- Add worker backtest bar source and Kis live client
- Update KIS live secret configuration and documentation
- Refine worker datacheck and daily chart price providers
2026-06-03 12:10:48 +09:00

443 lines
15 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 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 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")
}
if os.Getenv("KIS_ACTIVE_ENV") != "paper" {
t.Fatal("KIS_ACTIVE_ENV must be paper for this live smoke")
}
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("paper live provider smoke: %v", 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")
}
}