first commit
This commit is contained in:
575
services/kiwoom_service.go
Normal file
575
services/kiwoom_service.go
Normal file
@@ -0,0 +1,575 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"stocksearch/config"
|
||||
"stocksearch/models"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// cntrStrCacheEntry getCntrStr 캐시 항목
|
||||
type cntrStrCacheEntry struct {
|
||||
value float64
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// KiwoomClient 키움증권 REST API HTTP 클라이언트
|
||||
type KiwoomClient struct {
|
||||
httpClient *http.Client
|
||||
tokenService *TokenService
|
||||
limiter *rate.Limiter
|
||||
cntrStrCache sync.Map // stockCode → cntrStrCacheEntry (5초 TTL)
|
||||
}
|
||||
|
||||
var kiwoomClient *KiwoomClient
|
||||
|
||||
// GetKiwoomClient 키움 클라이언트 싱글턴 반환
|
||||
func GetKiwoomClient() *KiwoomClient {
|
||||
if kiwoomClient == nil {
|
||||
kiwoomClient = &KiwoomClient{
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
tokenService: GetTokenService(),
|
||||
// 초당 1건, 버스트 1 → 완전 직렬화 (키움 API 실질 한도 ~1req/s per API ID)
|
||||
limiter: rate.NewLimiter(rate.Limit(1), 1),
|
||||
}
|
||||
}
|
||||
return kiwoomClient
|
||||
}
|
||||
|
||||
// post 공통 POST 요청 (api-id 헤더, JSON body, Rate Limit 적용, 429 재시도)
|
||||
func (k *KiwoomClient) post(apiID string, path string, body map[string]string) ([]byte, error) {
|
||||
const maxRetries = 3
|
||||
backoff := 1 * time.Second
|
||||
|
||||
data, _ := json.Marshal(body)
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if err := k.limiter.Wait(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("Rate Limit 대기 실패: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", config.App.BaseURL+path, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("요청 생성 실패: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
|
||||
req.Header.Set("authorization", "Bearer "+k.tokenService.GetToken())
|
||||
req.Header.Set("api-id", apiID)
|
||||
req.Header.Set("cont-yn", "N")
|
||||
req.Header.Set("next-key", "")
|
||||
|
||||
resp, err := k.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("API 요청 실패: %w", err)
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("응답 읽기 실패: %w", err)
|
||||
}
|
||||
|
||||
// 429: 잠시 대기 후 재시도
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
if attempt < maxRetries-1 {
|
||||
log.Printf("[키움API] 429 Too Many Requests (api-id=%s), %v 후 재시도 (%d/%d)", apiID, backoff, attempt+1, maxRetries)
|
||||
time.Sleep(backoff)
|
||||
backoff *= 2
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("API 요청 한도 초과 (api-id=%s): %s", apiID, string(respBody))
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API 응답 오류: HTTP %d, body: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// HTML 응답 감지 (서버 점검/리다이렉트 시 HTML 반환)
|
||||
if len(respBody) > 0 && respBody[0] == '<' {
|
||||
return nil, fmt.Errorf("API 서버 점검 중 (HTML 응답 수신)")
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("API 요청 최대 재시도 초과 (api-id=%s)", apiID)
|
||||
}
|
||||
|
||||
// postPaged 연속조회 지원 POST 요청 - 응답 헤더(cont-yn, next-key) 함께 반환
|
||||
func (k *KiwoomClient) postPaged(apiID, path string, body map[string]string, contYn, nextKey string) ([]byte, string, string, error) {
|
||||
if err := k.limiter.Wait(context.Background()); err != nil {
|
||||
return nil, "", "", fmt.Errorf("Rate Limit 대기 실패: %w", err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(body)
|
||||
req, err := http.NewRequest("POST", config.App.BaseURL+path, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("요청 생성 실패: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
|
||||
req.Header.Set("authorization", "Bearer "+k.tokenService.GetToken())
|
||||
req.Header.Set("api-id", apiID)
|
||||
req.Header.Set("cont-yn", contYn)
|
||||
req.Header.Set("next-key", nextKey)
|
||||
|
||||
resp, err := k.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("API 요청 실패: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("응답 읽기 실패: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", "", fmt.Errorf("API 오류: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return respBody, resp.Header.Get("cont-yn"), resp.Header.Get("next-key"), nil
|
||||
}
|
||||
|
||||
// GetCurrentPrice 주식 기본정보 + 체결강도 조회 (ka10001 + ka10003)
|
||||
// NXT 거래소 데이터를 우선 조회하고, NXT에 없으면 KRX로 폴백
|
||||
func (k *KiwoomClient) GetCurrentPrice(stockCode string) (*models.StockPrice, error) {
|
||||
// 이미 거래소 접미사(_NX, _AL)가 붙어있으면 그대로 조회
|
||||
if strings.Contains(stockCode, "_") {
|
||||
return k.fetchPrice(stockCode)
|
||||
}
|
||||
|
||||
// NXT 우선 시도 → 실패하거나 종목명이 비어있으면 KRX 폴백
|
||||
if price, err := k.fetchPrice(stockCode + "_NX"); err == nil && price.Name != "" {
|
||||
log.Printf("NXT 가격 사용: %s → %d원", stockCode, price.CurrentPrice)
|
||||
return price, nil
|
||||
}
|
||||
return k.fetchPrice(stockCode)
|
||||
}
|
||||
|
||||
// fetchPrice ka10001로 특정 거래소 종목코드의 현재가 조회
|
||||
func (k *KiwoomClient) fetchPrice(stkCd string) (*models.StockPrice, error) {
|
||||
// 브라우저 표시용 종목코드는 거래소 접미사 제거 (005930_NX → 005930)
|
||||
displayCode := strings.SplitN(stkCd, "_", 2)[0]
|
||||
|
||||
body, err := k.post("ka10001", "/api/dostk/stkinfo", map[string]string{
|
||||
"stk_cd": stkCd,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
StkNm string `json:"stk_nm"`
|
||||
CurPrc string `json:"cur_prc"`
|
||||
PredPre string `json:"pred_pre"`
|
||||
FluRt string `json:"flu_rt"`
|
||||
TrdeQty string `json:"trde_qty"`
|
||||
OpenPric string `json:"open_pric"`
|
||||
HighPric string `json:"high_pric"`
|
||||
LowPric string `json:"low_pric"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("현재가 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return nil, fmt.Errorf("현재가 조회 실패: %s", result.ReturnMsg)
|
||||
}
|
||||
|
||||
price := &models.StockPrice{
|
||||
Code: displayCode,
|
||||
Name: result.StkNm,
|
||||
CurrentPrice: absParseIntSafe(result.CurPrc),
|
||||
ChangePrice: parseIntSafe(result.PredPre),
|
||||
ChangeRate: parseFloatSafe(result.FluRt),
|
||||
Volume: absParseIntSafe(result.TrdeQty),
|
||||
High: absParseIntSafe(result.HighPric),
|
||||
Low: absParseIntSafe(result.LowPric),
|
||||
Open: absParseIntSafe(result.OpenPric),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// ka10003으로 체결강도 조회 (실패해도 나머지 데이터 반환)
|
||||
if cntrStr, err := k.getCntrStr(stkCd); err == nil {
|
||||
price.CntrStr = cntrStr
|
||||
}
|
||||
|
||||
return price, nil
|
||||
}
|
||||
|
||||
// getCntrStr 체결정보에서 최신 체결강도 조회 (ka10003, 5초 캐시)
|
||||
func (k *KiwoomClient) getCntrStr(stockCode string) (float64, error) {
|
||||
// 캐시 확인
|
||||
if v, ok := k.cntrStrCache.Load(stockCode); ok {
|
||||
entry := v.(cntrStrCacheEntry)
|
||||
if time.Now().Before(entry.expiresAt) {
|
||||
return entry.value, nil
|
||||
}
|
||||
}
|
||||
|
||||
body, err := k.post("ka10003", "/api/dostk/stkinfo", map[string]string{
|
||||
"stk_cd": stockCode,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
CntrInfr []struct {
|
||||
CntrStr string `json:"cntr_str"`
|
||||
} `json:"cntr_infr"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if result.ReturnCode != 0 || len(result.CntrInfr) == 0 {
|
||||
return 0, fmt.Errorf("체결강도 없음")
|
||||
}
|
||||
|
||||
val := parseFloatSafe(result.CntrInfr[0].CntrStr)
|
||||
// 결과 캐시 저장 (30초 — 스캔 3주기(30s) 동안 재호출 없음)
|
||||
k.cntrStrCache.Store(stockCode, cntrStrCacheEntry{value: val, expiresAt: time.Now().Add(30 * time.Second)})
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// GetDailyChart 일봉 차트 데이터 조회 (ka10005)
|
||||
func (k *KiwoomClient) GetDailyChart(stockCode string) ([]models.CandleData, error) {
|
||||
body, err := k.post("ka10005", "/api/dostk/mrkcond", map[string]string{
|
||||
"stk_cd": stockCode,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
StkDdwkmm []struct {
|
||||
Date string `json:"date"`
|
||||
OpenPric string `json:"open_pric"`
|
||||
HighPric string `json:"high_pric"`
|
||||
LowPric string `json:"low_pric"`
|
||||
ClosePric string `json:"close_pric"`
|
||||
TrdeQty string `json:"trde_qty"`
|
||||
} `json:"stk_ddwkmm"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("일봉 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return nil, fmt.Errorf("일봉 조회 실패: %s", result.ReturnMsg)
|
||||
}
|
||||
|
||||
// 날짜 오름차순 정렬 (API는 내림차순 반환)
|
||||
candles := make([]models.CandleData, 0, len(result.StkDdwkmm))
|
||||
for i := len(result.StkDdwkmm) - 1; i >= 0; i-- {
|
||||
row := result.StkDdwkmm[i]
|
||||
candles = append(candles, models.CandleData{
|
||||
Time: parseDateToUnix(row.Date),
|
||||
Open: absParseIntSafe(row.OpenPric),
|
||||
High: absParseIntSafe(row.HighPric),
|
||||
Low: absParseIntSafe(row.LowPric),
|
||||
Close: absParseIntSafe(row.ClosePric),
|
||||
Volume: absParseIntSafe(row.TrdeQty),
|
||||
})
|
||||
}
|
||||
return candles, nil
|
||||
}
|
||||
|
||||
// GetMinuteChart 분봉 차트 데이터 조회 (ka10080)
|
||||
// minutes: 1, 5, 10, 15, 30, 60
|
||||
func (k *KiwoomClient) GetMinuteChart(stockCode string, minutes int) ([]models.CandleData, error) {
|
||||
body, err := k.post("ka10080", "/api/dostk/chart", map[string]string{
|
||||
"stk_cd": stockCode,
|
||||
"tic_scope": fmt.Sprintf("%d", minutes),
|
||||
"upd_stkpc_tp": "1",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
StkMinPoleChartQry []struct {
|
||||
CurPrc string `json:"cur_prc"`
|
||||
TrdeQty string `json:"trde_qty"`
|
||||
CntrTm string `json:"cntr_tm"`
|
||||
OpenPric string `json:"open_pric"`
|
||||
HighPric string `json:"high_pric"`
|
||||
LowPric string `json:"low_pric"`
|
||||
} `json:"stk_min_pole_chart_qry"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("분봉 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return nil, fmt.Errorf("분봉 조회 실패: %s", result.ReturnMsg)
|
||||
}
|
||||
|
||||
// 시간 오름차순 정렬 (API는 내림차순 반환)
|
||||
rows := result.StkMinPoleChartQry
|
||||
candles := make([]models.CandleData, 0, len(rows))
|
||||
for i := len(rows) - 1; i >= 0; i-- {
|
||||
row := rows[i]
|
||||
candles = append(candles, models.CandleData{
|
||||
Time: parseMinuteCandleTime(row.CntrTm),
|
||||
Open: absParseIntSafe(row.OpenPric),
|
||||
High: absParseIntSafe(row.HighPric),
|
||||
Low: absParseIntSafe(row.LowPric),
|
||||
Close: absParseIntSafe(row.CurPrc),
|
||||
Volume: absParseIntSafe(row.TrdeQty),
|
||||
})
|
||||
}
|
||||
return candles, nil
|
||||
}
|
||||
|
||||
// parseMinuteCandleTime 분봉 체결시간(YYYYMMDDHHmmss) → Unix 초 변환
|
||||
func parseMinuteCandleTime(s string) int64 {
|
||||
s = strings.TrimSpace(s)
|
||||
// "YYYYMMDDHHmmss" (14자리) 또는 "HHmmss" (6자리) 처리
|
||||
var t time.Time
|
||||
var err error
|
||||
switch len(s) {
|
||||
case 14:
|
||||
t, err = time.ParseInLocation("20060102150405", s, time.Local)
|
||||
case 12:
|
||||
t, err = time.ParseInLocation("060102150405", s, time.Local)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
// GetTopVolumeStocks 거래량 상위 종목 조회 (ka10030)
|
||||
// market: "J"(KOSPI) → "001", "Q"(KOSDAQ) → "101"
|
||||
func (k *KiwoomClient) GetTopVolumeStocks(market string, count int) ([]models.StockPrice, error) {
|
||||
// market 코드 변환
|
||||
mrktTp := "000"
|
||||
mktName := "전체"
|
||||
switch market {
|
||||
case "J":
|
||||
mrktTp = "001"
|
||||
mktName = "KOSPI"
|
||||
case "Q":
|
||||
mrktTp = "101"
|
||||
mktName = "KOSDAQ"
|
||||
}
|
||||
|
||||
body, err := k.post("ka10030", "/api/dostk/rkinfo", map[string]string{
|
||||
"mrkt_tp": mrktTp,
|
||||
"sort_tp": "1", // 거래량 기준 정렬
|
||||
"mang_stk_incls": "1", // 관리종목 미포함
|
||||
"crd_tp": "0",
|
||||
"trde_qty_tp": "0",
|
||||
"pric_tp": "0",
|
||||
"trde_prica_tp": "0",
|
||||
"mrkt_open_tp": "0",
|
||||
"stex_tp": "3", // 통합
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
TdyTrdeQtyUpper []struct {
|
||||
StkCd string `json:"stk_cd"`
|
||||
StkNm string `json:"stk_nm"`
|
||||
CurPrc string `json:"cur_prc"`
|
||||
PredPre string `json:"pred_pre"`
|
||||
FluRt string `json:"flu_rt"`
|
||||
TrdeQty string `json:"trde_qty"`
|
||||
} `json:"tdy_trde_qty_upper"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("거래량순위 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return nil, fmt.Errorf("거래량순위 조회 실패: %s", result.ReturnMsg)
|
||||
}
|
||||
|
||||
stocks := make([]models.StockPrice, 0, count)
|
||||
for i, row := range result.TdyTrdeQtyUpper {
|
||||
if i >= count {
|
||||
break
|
||||
}
|
||||
stocks = append(stocks, models.StockPrice{
|
||||
Code: row.StkCd,
|
||||
Name: row.StkNm,
|
||||
CurrentPrice: absParseIntSafe(row.CurPrc),
|
||||
ChangePrice: parseIntSafe(row.PredPre),
|
||||
ChangeRate: parseFloatSafe(row.FluRt),
|
||||
Volume: absParseIntSafe(row.TrdeQty),
|
||||
Market: mktName,
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
return stocks, nil
|
||||
}
|
||||
|
||||
// getOrderBook 호가잔량 조회 (ka10004) - 총매도잔량, 총매수잔량, 총매도잔량직전대비 반환
|
||||
// WS 구독 중인 종목은 CacheService 캐시 우선 조회 → REST API 호출 최소화
|
||||
func (k *KiwoomClient) getOrderBook(stockCode string) (totalAsk, totalBid, askChange int64, err error) {
|
||||
if cached, ok := GetCacheService().Get("orderbook:" + stockCode); ok {
|
||||
if ob, ok2 := cached.(*models.OrderBook); ok2 {
|
||||
return ob.TotalAskVol, ob.TotalBidVol, 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
body, err := k.post("ka10004", "/api/dostk/mrkcond", map[string]string{
|
||||
"stk_cd": stockCode,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
var result struct {
|
||||
TotSelReq string `json:"tot_sel_req"`
|
||||
TotBuyReq string `json:"tot_buy_req"`
|
||||
TotSelReqJub string `json:"tot_sel_req_jub_pre"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return 0, 0, 0, fmt.Errorf("호가 조회 실패 (return_code=%d)", result.ReturnCode)
|
||||
}
|
||||
return absParseIntSafe(result.TotSelReq),
|
||||
absParseIntSafe(result.TotBuyReq),
|
||||
parseIntSafe(result.TotSelReqJub),
|
||||
nil
|
||||
}
|
||||
|
||||
// GetTopFluctuation 전일대비 등락률 상위 종목 조회 (ka10027)
|
||||
// ascending=false: 상승률, ascending=true: 하락률
|
||||
func (k *KiwoomClient) GetTopFluctuation(market string, ascending bool, count int) ([]models.StockPrice, error) {
|
||||
sortTp := "1" // 상승률
|
||||
if ascending {
|
||||
sortTp = "3" // 하락률
|
||||
}
|
||||
|
||||
// market: "J"(KOSPI) → "001", "Q"(KOSDAQ) → "101", 그 외 "000"(전체)
|
||||
mrktTp := "000"
|
||||
mktName := "전체"
|
||||
switch market {
|
||||
case "J":
|
||||
mrktTp = "001"
|
||||
mktName = "KOSPI"
|
||||
case "Q":
|
||||
mrktTp = "101"
|
||||
mktName = "KOSDAQ"
|
||||
}
|
||||
|
||||
body, err := k.post("ka10027", "/api/dostk/rkinfo", map[string]string{
|
||||
"mrkt_tp": mrktTp,
|
||||
"sort_tp": sortTp,
|
||||
"trde_qty_cnd": "0000", // 거래량 전체
|
||||
"stk_cnd": "0", // 종목조건 전체
|
||||
"crd_cnd": "0", // 신용조건 전체
|
||||
"updown_incls": "1", // 상하한포함
|
||||
"pric_cnd": "0", // 가격조건 전체
|
||||
"trde_prica_cnd": "0", // 거래대금조건 전체
|
||||
"stex_tp": "1", // KRX
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
PredPreFluRtUpper []struct {
|
||||
StkCd string `json:"stk_cd"`
|
||||
StkNm string `json:"stk_nm"`
|
||||
CurPrc string `json:"cur_prc"`
|
||||
PredPre string `json:"pred_pre"`
|
||||
FluRt string `json:"flu_rt"`
|
||||
NowTrdeQty string `json:"now_trde_qty"`
|
||||
CntrStr string `json:"cntr_str"`
|
||||
} `json:"pred_pre_flu_rt_upper"`
|
||||
ReturnCode int `json:"return_code"`
|
||||
ReturnMsg string `json:"return_msg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("등락률 응답 파싱 실패: %w", err)
|
||||
}
|
||||
if result.ReturnCode != 0 {
|
||||
return nil, fmt.Errorf("등락률 조회 실패: %s", result.ReturnMsg)
|
||||
}
|
||||
|
||||
stocks := make([]models.StockPrice, 0, count)
|
||||
for i, row := range result.PredPreFluRtUpper {
|
||||
if i >= count {
|
||||
break
|
||||
}
|
||||
stocks = append(stocks, models.StockPrice{
|
||||
Code: row.StkCd,
|
||||
Name: row.StkNm,
|
||||
CurrentPrice: absParseIntSafe(row.CurPrc),
|
||||
ChangePrice: parseIntSafe(row.PredPre),
|
||||
ChangeRate: parseFloatSafe(row.FluRt),
|
||||
Volume: absParseIntSafe(row.NowTrdeQty),
|
||||
CntrStr: parseFloatSafe(row.CntrStr),
|
||||
Market: mktName,
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
return stocks, nil
|
||||
}
|
||||
|
||||
// --- 유틸 함수 ---
|
||||
|
||||
func parseIntSafe(s string) int64 {
|
||||
s = strings.ReplaceAll(s, ",", "")
|
||||
s = strings.TrimPrefix(s, "+")
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
// absParse 키움 API 가격 필드 파싱 (+/- 부호는 방향 표시용이므로 절댓값 반환)
|
||||
func absParseIntSafe(s string) int64 {
|
||||
n := parseIntSafe(s)
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func parseFloatSafe(s string) float64 {
|
||||
s = strings.ReplaceAll(s, ",", "")
|
||||
s = strings.TrimPrefix(s, "+")
|
||||
f, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
return f
|
||||
}
|
||||
|
||||
// parseDateToUnix YYYYMMDD 형식을 Unix 타임스탬프(초)로 변환
|
||||
func parseDateToUnix(dateStr string) int64 {
|
||||
t, err := time.ParseInLocation("20060102", dateStr, time.Local)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
Reference in New Issue
Block a user