package kis import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "strings" "time" "git.toki-labs.com/toki/alt/packages/domain/market" "git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer" ) const ( RealBaseURL = "https://openapi.koreainvestment.com:9443" PaperBaseURL = "https://openapivts.koreainvestment.com:29443" tokenPath = "/oauth2/tokenP" DefaultUserAgent = "ALT worker KIS smoke" ) type Environment string const ( EnvironmentReal Environment = "real" EnvironmentPaper Environment = "paper" ) type ErrorKind string const ( ErrorUnavailable ErrorKind = "unavailable" ErrorAuth ErrorKind = "auth" ErrorQuota ErrorKind = "quota" ErrorMalformed ErrorKind = "malformed" ErrorProvider ErrorKind = "provider" ) type Error struct { Kind ErrorKind Op string StatusCode int Code string Message string Err error } func (e *Error) Error() string { var b strings.Builder if e.Op != "" { b.WriteString("kis ") b.WriteString(e.Op) b.WriteString(": ") } b.WriteString(string(e.Kind)) if e.StatusCode != 0 { fmt.Fprintf(&b, " status=%d", e.StatusCode) } if e.Code != "" { fmt.Fprintf(&b, " code=%s", e.Code) } if e.Message != "" { fmt.Fprintf(&b, " msg=%s", e.Message) } if e.Err != nil { fmt.Fprintf(&b, ": %v", e.Err) } return b.String() } func (e *Error) Unwrap() error { return e.Err } func IsErrorKind(err error, kind ErrorKind) bool { var kisErr *Error return errors.As(err, &kisErr) && kisErr.Kind == kind } type Config struct { Environment Environment ConfigError string BaseURL string AppKey string AppSecret string AccountNo string AccountProductCode string UserAgent string HTTPClient *http.Client } func ConfigFromEnv() Config { env := Environment(strings.TrimSpace(os.Getenv("KIS_ACTIVE_ENV"))) switch env { case EnvironmentReal: return Config{ Environment: EnvironmentReal, BaseURL: RealBaseURL, AppKey: os.Getenv("KIS_REAL_APP_KEY"), AppSecret: os.Getenv("KIS_REAL_APP_SECRET"), AccountNo: os.Getenv("KIS_REAL_CANO"), AccountProductCode: os.Getenv("KIS_REAL_ACNT_PRDT_CD"), } case EnvironmentPaper: return Config{ Environment: EnvironmentPaper, BaseURL: PaperBaseURL, AppKey: os.Getenv("KIS_PAPER_APP_KEY"), AppSecret: os.Getenv("KIS_PAPER_APP_SECRET"), AccountNo: os.Getenv("KIS_PAPER_CANO"), AccountProductCode: os.Getenv("KIS_PAPER_ACNT_PRDT_CD"), } default: return Config{ Environment: env, ConfigError: `KIS_ACTIVE_ENV must be "paper" or "real"`, } } } type Client struct { cfg Config httpClient *http.Client } func NewClient(cfg Config) *Client { if cfg.UserAgent == "" { cfg.UserAgent = DefaultUserAgent } cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/") httpClient := cfg.HTTPClient if httpClient == nil { httpClient = &http.Client{Timeout: 20 * time.Second} } return &Client{cfg: cfg, httpClient: httpClient} } type Token struct { AccessToken string ExpiresAt time.Time } func (c *Client) Auth(ctx context.Context) (Token, error) { if err := c.validateBase("auth"); err != nil { return Token{}, err } payload := map[string]string{ "grant_type": "client_credentials", "appkey": c.cfg.AppKey, "appsecret": c.cfg.AppSecret, } body, status, err := c.doJSON(ctx, http.MethodPost, tokenPath, nil, payload, nil) if err != nil { return Token{}, err } if status != http.StatusOK { return Token{}, c.httpStatusError("auth", status, body) } var resp struct { AccessToken string `json:"access_token"` ExpiresAt string `json:"access_token_token_expired"` } if err := json.Unmarshal(body, &resp); err != nil { return Token{}, &Error{Kind: ErrorMalformed, Op: "auth", Err: err} } if resp.AccessToken == "" { return Token{}, &Error{Kind: ErrorAuth, Op: "auth", Message: "missing access_token"} } expiresAt, err := time.ParseInLocation("2006-01-02 15:04:05", resp.ExpiresAt, time.Local) if err != nil { return Token{}, &Error{Kind: ErrorMalformed, Op: "auth", Message: "invalid token expiry", Err: err} } return Token{AccessToken: resp.AccessToken, ExpiresAt: expiresAt}, nil } type DailyItemChartPriceQuery struct { MarketDivCode string Symbol string From string To string PeriodDivCode string AdjustedPrice string } func (q DailyItemChartPriceQuery) withDefaults() DailyItemChartPriceQuery { if q.MarketDivCode == "" { q.MarketDivCode = "J" } if q.PeriodDivCode == "" { q.PeriodDivCode = "D" } if q.AdjustedPrice == "" { q.AdjustedPrice = "0" } return q } func (q DailyItemChartPriceQuery) validate() error { if q.Symbol == "" { return &Error{Kind: ErrorMalformed, Op: "daily", Message: "symbol is required"} } if !isYYYYMMDD(q.From) { return &Error{Kind: ErrorMalformed, Op: "daily", Message: "from must be YYYYMMDD"} } if !isYYYYMMDD(q.To) { return &Error{Kind: ErrorMalformed, Op: "daily", Message: "to must be YYYYMMDD"} } if q.From > q.To { return &Error{Kind: ErrorMalformed, Op: "daily", Message: "from must be before or equal to to"} } return nil } func (c *Client) FetchDailyItemChartPrice(ctx context.Context, query DailyItemChartPriceQuery) (DailyItemChartPriceResponse, error) { token, err := c.Auth(ctx) if err != nil { return DailyItemChartPriceResponse{}, err } return c.InquireDailyItemChartPrice(ctx, token.AccessToken, query) } func (c *Client) InquireDailyItemChartPrice(ctx context.Context, accessToken string, query DailyItemChartPriceQuery) (DailyItemChartPriceResponse, error) { if err := c.validateBase("daily"); err != nil { return DailyItemChartPriceResponse{}, err } if accessToken == "" { return DailyItemChartPriceResponse{}, &Error{Kind: ErrorAuth, Op: "daily", Message: "missing access token"} } query = query.withDefaults() if err := query.validate(); err != nil { return DailyItemChartPriceResponse{}, err } headers := map[string]string{ "authorization": "Bearer " + accessToken, "appkey": c.cfg.AppKey, "appsecret": c.cfg.AppSecret, "tr_id": DailyItemChartPriceTRID, "custtype": "P", "tr_cont": "", } params := url.Values{ "FID_COND_MRKT_DIV_CODE": []string{query.MarketDivCode}, "FID_INPUT_ISCD": []string{query.Symbol}, "FID_INPUT_DATE_1": []string{query.From}, "FID_INPUT_DATE_2": []string{query.To}, "FID_PERIOD_DIV_CODE": []string{query.PeriodDivCode}, "FID_ORG_ADJ_PRC": []string{query.AdjustedPrice}, } body, status, err := c.doJSON(ctx, http.MethodGet, DailyItemChartPricePath, headers, nil, params) if err != nil { return DailyItemChartPriceResponse{}, err } if status != http.StatusOK { return DailyItemChartPriceResponse{}, c.httpStatusError("daily", status, body) } var resp DailyItemChartPriceResponse if err := json.Unmarshal(body, &resp); err != nil { return DailyItemChartPriceResponse{}, &Error{Kind: ErrorMalformed, Op: "daily", Err: err} } if resp.ReturnCode != "0" { return DailyItemChartPriceResponse{}, providerPayloadError("daily", resp.MessageCd, resp.Message) } if len(resp.Output2) == 0 { return DailyItemChartPriceResponse{}, &Error{Kind: ErrorMalformed, Op: "daily", Message: "no output2 rows"} } return resp, nil } type LiveProvider struct { client *Client } func NewLiveProvider(client *Client) *LiveProvider { return &LiveProvider{client: client} } func (p *LiveProvider) FetchDailyBars(ctx context.Context, request importer.DailyBarRequest) ([]importer.InstrumentBars, error) { if p == nil || p.client == nil { return nil, &Error{Kind: ErrorUnavailable, Op: "provider", Message: "KIS client is not configured"} } if request.Provider != "" && request.Provider != market.ProviderKIS { return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: fmt.Sprintf("unsupported provider %q", request.Provider)} } if len(request.Selector.Symbols) == 0 { return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: "selector symbols are required"} } if request.From.IsZero() || request.To.IsZero() { return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: "from/to date range is required"} } from := request.From.Format(kisDateLayout) to := request.To.Format(kisDateLayout) items := make([]importer.InstrumentBars, 0, len(request.Selector.Symbols)) for _, symbol := range request.Selector.Symbols { resp, err := p.client.FetchDailyItemChartPrice(ctx, DailyItemChartPriceQuery{ Symbol: symbol, From: from, To: to, }) if err != nil { return nil, err } inst := instrumentFromDailyResponse(resp, symbol) bars, err := NormalizeDailyBars(resp, inst) if err != nil { return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: "normalize daily bars", Err: err} } items = append(items, importer.InstrumentBars{Instrument: inst, Bars: bars}) } return items, nil } func instrumentFromDailyResponse(resp DailyItemChartPriceResponse, requestedSymbol string) market.Instrument { symbol := resp.Output1.ShortCode if symbol == "" { symbol = requestedSymbol } return market.Instrument{ ID: market.InstrumentID("KRX:" + symbol), Market: market.MarketKR, Venue: market.VenueKRX, Symbol: symbol, Name: resp.Output1.Name, Currency: market.CurrencyKRW, ProviderSymbols: map[string]string{ string(market.ProviderKIS): symbol, }, } } func (c *Client) validateBase(op string) error { if c.cfg.ConfigError != "" { return &Error{Kind: ErrorUnavailable, Op: op, Message: c.cfg.ConfigError} } if c.cfg.BaseURL == "" { return &Error{Kind: ErrorUnavailable, Op: op, Message: "base URL is not configured"} } if c.cfg.AppKey == "" { return &Error{Kind: ErrorUnavailable, Op: op, Message: "app key is not configured"} } if c.cfg.AppSecret == "" { return &Error{Kind: ErrorUnavailable, Op: op, Message: "app secret is not configured"} } return nil } func (c *Client) doJSON(ctx context.Context, method, path string, headers map[string]string, payload map[string]string, params url.Values) ([]byte, int, error) { reqURL := c.cfg.BaseURL + path if len(params) > 0 { reqURL += "?" + params.Encode() } var body io.Reader if payload != nil { encoded, err := json.Marshal(payload) if err != nil { return nil, 0, &Error{Kind: ErrorMalformed, Op: strings.ToLower(method), Err: err} } body = bytes.NewReader(encoded) } req, err := http.NewRequestWithContext(ctx, method, reqURL, body) if err != nil { return nil, 0, &Error{Kind: ErrorMalformed, Op: strings.ToLower(method), Err: err} } req.Header.Set("Content-Type", "application/json; charset=utf-8") req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", c.cfg.UserAgent) for key, value := range headers { req.Header.Set(key, value) } resp, err := c.httpClient.Do(req) if err != nil { return nil, 0, &Error{Kind: ErrorUnavailable, Op: strings.ToLower(method), Err: err} } defer resp.Body.Close() data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, resp.StatusCode, &Error{Kind: ErrorUnavailable, Op: strings.ToLower(method), Err: err} } return data, resp.StatusCode, nil } func (c *Client) httpStatusError(op string, status int, body []byte) error { message, code := responseMessage(body) return &Error{ Kind: classifyFailure(status, code, message), Op: op, StatusCode: status, Code: code, Message: message, } } func providerPayloadError(op, code, message string) error { return &Error{ Kind: classifyFailure(http.StatusOK, code, message), Op: op, Code: code, Message: message, } } func responseMessage(body []byte) (string, string) { var payload struct { Code string `json:"msg_cd"` Message string `json:"msg1"` } if err := json.Unmarshal(body, &payload); err == nil && (payload.Code != "" || payload.Message != "") { return payload.Message, payload.Code } trimmed := strings.TrimSpace(string(body)) if len(trimmed) > 300 { trimmed = trimmed[:300] } return trimmed, "" } func classifyFailure(status int, code, message string) ErrorKind { if status == http.StatusTooManyRequests { return ErrorQuota } if status == http.StatusUnauthorized || status == http.StatusForbidden { return ErrorAuth } text := strings.ToLower(code + " " + message) switch { case strings.Contains(text, "token"), strings.Contains(text, "auth"), strings.Contains(text, "oauth"), strings.Contains(text, "인증"): return ErrorAuth case strings.Contains(text, "quota"), strings.Contains(text, "rate"), strings.Contains(text, "exceed"), strings.Contains(text, "초당"), strings.Contains(text, "과다"): return ErrorQuota default: return ErrorProvider } } func isYYYYMMDD(value string) bool { if len(value) != 8 { return false } for _, r := range value { if r < '0' || r > '9' { return false } } return true }