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

117 lines
4.6 KiB
Go

// Package kis decodes and normalizes Korea Investment & Securities (KIS) daily
// chart provider payloads into ALT domain market types. It works against mock
// fixtures only; live HTTP and credential wiring belong to a later milestone.
package kis
import (
"encoding/json"
"fmt"
"time"
"git.toki-labs.com/toki/alt/packages/domain/market"
)
const (
DailyItemChartPricePath = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
DailyItemChartPriceTRID = "FHKST03010100"
)
// seoulOffset is the fixed KST offset. KRX has no DST, so a fixed +09:00 zone is
// deterministic and avoids depending on tzdata being present at runtime.
const seoulOffsetSeconds = 9 * 60 * 60
// kisDateLayout matches the KIS stck_bsop_date field, e.g. "20240527".
const kisDateLayout = "20060102"
// DailyItemChartPriceRequest mirrors the KIS inquire-daily-itemchartprice
// request shape captured in the worker test fixtures.
type DailyItemChartPriceRequest struct {
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
TrID string `json:"tr_id"`
Params map[string]string `json:"params"`
}
// DailyItemChartPriceResponse mirrors the KIS daily chart response payload.
type DailyItemChartPriceResponse struct {
ReturnCode string `json:"rt_cd"`
MessageCd string `json:"msg_cd"`
Message string `json:"msg1"`
Output1 DailyItemChartPriceSummary `json:"output1"`
Output2 []DailyItemChartPriceBarRow `json:"output2"`
}
// DailyItemChartPriceSummary is the KIS output1 instrument-level metadata block.
type DailyItemChartPriceSummary struct {
Name string `json:"hts_kor_isnm"`
ShortCode string `json:"stck_shrn_iscd"`
CurrentPrice string `json:"stck_prpr"`
AccumVolume string `json:"acml_vol"`
AccumTradeAmt string `json:"acml_tr_pbmn"`
}
// DailyItemChartPriceBarRow is a single KIS output2 OHLCV row.
type DailyItemChartPriceBarRow struct {
BusinessDate string `json:"stck_bsop_date"`
Open string `json:"stck_oprc"`
High string `json:"stck_hgpr"`
Low string `json:"stck_lwpr"`
Close string `json:"stck_clpr"`
Volume string `json:"acml_vol"`
TradeAmount string `json:"acml_tr_pbmn"`
}
// DecodeDailyItemChartPriceRequest decodes a KIS daily chart request fixture.
func DecodeDailyItemChartPriceRequest(data []byte) (DailyItemChartPriceRequest, error) {
var req DailyItemChartPriceRequest
if err := json.Unmarshal(data, &req); err != nil {
return DailyItemChartPriceRequest{}, fmt.Errorf("decode kis daily request: %w", err)
}
return req, nil
}
// DecodeDailyItemChartPriceResponse decodes a KIS daily chart response fixture
// and validates that it represents a successful payload with bar rows.
func DecodeDailyItemChartPriceResponse(data []byte) (DailyItemChartPriceResponse, error) {
var resp DailyItemChartPriceResponse
if err := json.Unmarshal(data, &resp); err != nil {
return DailyItemChartPriceResponse{}, fmt.Errorf("decode kis daily response: %w", err)
}
if resp.ReturnCode != "0" {
return DailyItemChartPriceResponse{}, fmt.Errorf("kis daily response not successful: rt_cd=%q msg=%q", resp.ReturnCode, resp.Message)
}
if len(resp.Output2) == 0 {
return DailyItemChartPriceResponse{}, fmt.Errorf("kis daily response has no output2 rows")
}
return resp, nil
}
// NormalizeDailyBars converts KIS output2 rows into ALT domain bars for the
// given instrument. KRX daily defaults are applied: Asia/Seoul midnight
// timestamps, the daily timeframe, and the instrument currency (KRW for KRX).
func NormalizeDailyBars(resp DailyItemChartPriceResponse, inst market.Instrument) ([]market.Bar, error) {
currency := inst.Currency
if currency == "" {
currency = market.CurrencyKRW
}
seoul := time.FixedZone("Asia/Seoul", seoulOffsetSeconds)
bars := make([]market.Bar, 0, len(resp.Output2))
for i, row := range resp.Output2 {
ts, err := time.ParseInLocation(kisDateLayout, row.BusinessDate, seoul)
if err != nil {
return nil, fmt.Errorf("parse business date for row %d: %w", i, err)
}
bars = append(bars, market.Bar{
InstrumentID: inst.ID,
Timeframe: market.TimeframeDaily,
Timestamp: ts,
Open: market.Price{Currency: currency, Amount: market.Decimal{Value: row.Open}},
High: market.Price{Currency: currency, Amount: market.Decimal{Value: row.High}},
Low: market.Price{Currency: currency, Amount: market.Decimal{Value: row.Low}},
Close: market.Price{Currency: currency, Amount: market.Decimal{Value: row.Close}},
Volume: market.Quantity{Amount: market.Decimal{Value: row.Volume}},
})
}
return bars, nil
}