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() }