- Move flutter operator console and push notification milestones to operator-ui phase - Archive kis-live-data-collection-pipeline milestone - Add command-first operator workflow milestone - Add operator-ui phase definition - Add paper-trading-command-workflow milestone - Update phase documentation and dependencies
454 lines
15 KiB
Go
454 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")
|
|
}
|
|
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")
|
|
}
|
|
}
|