alt/services/worker/internal/providers/kis/live_client.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

465 lines
13 KiB
Go

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
}