package services import ( "encoding/json" "fmt" "io" "log" "net/http" "strconv" "strings" "sync" "time" ) // IndexQuote 지수 현재가 정보 type IndexQuote struct { Name string `json:"name"` Price float64 `json:"price"` Change float64 `json:"change"` // 전일대비 ChangeRate float64 `json:"changeRate"` // 등락률(%) } // indexCache 지수 캐시 (5초 TTL) type indexCache struct { data []IndexQuote expiresAt time.Time } var ( indexSvcOnce sync.Once indexSvc *IndexService ) // IndexService 국내/해외 주요 지수 조회 서비스 type IndexService struct { kiwoom *KiwoomClient httpClient *http.Client mu sync.Mutex cache *indexCache } // GetIndexService 싱글턴 반환 func GetIndexService() *IndexService { indexSvcOnce.Do(func() { indexSvc = &IndexService{ kiwoom: GetKiwoomClient(), httpClient: &http.Client{Timeout: 5 * time.Second}, } }) return indexSvc } // GetIndices 코스피·코스닥·다우·나스닥 현재가 반환 (5초 캐시) func (s *IndexService) GetIndices() ([]IndexQuote, error) { s.mu.Lock() defer s.mu.Unlock() if s.cache != nil && time.Now().Before(s.cache.expiresAt) { return s.cache.data, nil } quotes := make([]IndexQuote, 0, 4) // 국내 지수: 코스피(001), 코스닥(101) domestic := []struct { name string mrktTp string indsCd string }{ {"KOSPI", "0", "001"}, {"KOSDAQ", "1", "101"}, } for _, d := range domestic { q, err := s.fetchKiwoomIndex(d.name, d.mrktTp, d.indsCd) if err != nil { log.Printf("지수 조회 실패 (%s): %v", d.name, err) quotes = append(quotes, IndexQuote{Name: d.name}) continue } quotes = append(quotes, q) } // 해외 지수: 다우(^DJI), 나스닥(^IXIC) overseas, err := s.fetchYahooIndices() if err != nil { log.Printf("해외 지수 조회 실패: %v", err) quotes = append(quotes, IndexQuote{Name: "DOW"}, IndexQuote{Name: "NASDAQ"}) } else { quotes = append(quotes, overseas...) } s.cache = &indexCache{data: quotes, expiresAt: time.Now().Add(5 * time.Second)} return quotes, nil } // fetchKiwoomIndex 키움 ka20001로 업종 현재가 조회 func (s *IndexService) fetchKiwoomIndex(name, mrktTp, indsCd string) (IndexQuote, error) { body := map[string]string{ "mrkt_tp": mrktTp, "inds_cd": indsCd, } raw, err := s.kiwoom.post("ka20001", "/api/dostk/sect", body) if err != nil { return IndexQuote{Name: name}, err } var resp struct { CurPrc string `json:"cur_prc"` PredPre string `json:"pred_pre"` FluRt string `json:"flu_rt"` } if err := json.Unmarshal(raw, &resp); err != nil { return IndexQuote{Name: name}, fmt.Errorf("파싱 실패: %w", err) } return IndexQuote{ Name: name, Price: parseIndexFloat(resp.CurPrc), Change: parseIndexFloat(resp.PredPre), ChangeRate: parseIndexFloat(resp.FluRt), }, nil } // fetchYahooIndices Yahoo Finance v8 chart API로 다우·나스닥 조회 (순서 보장) func (s *IndexService) fetchYahooIndices() ([]IndexQuote, error) { targets := []struct { name string symbol string }{ {"DOW", "^DJI"}, {"NASDAQ", "^IXIC"}, } quotes := make([]IndexQuote, 0, len(targets)) for _, t := range targets { q, err := s.fetchYahooChart(t.name, t.symbol) if err != nil { log.Printf("Yahoo Finance %s 조회 실패: %v", t.name, err) quotes = append(quotes, IndexQuote{Name: t.name}) // 실패 시 0값 플레이스홀더 continue } quotes = append(quotes, q) } return quotes, nil } // fetchYahooChart Yahoo Finance v8 chart API로 단일 지수 조회 func (s *IndexService) fetchYahooChart(name, symbol string) (IndexQuote, error) { url := "https://query1.finance.yahoo.com/v8/finance/chart/" + symbol + "?interval=1d&range=1d" req, _ := http.NewRequest("GET", url, nil) req.Header.Set("User-Agent", "Mozilla/5.0") resp, err := s.httpClient.Do(req) if err != nil { return IndexQuote{Name: name}, fmt.Errorf("%s 요청 실패: %w", name, err) } defer resp.Body.Close() data, _ := io.ReadAll(resp.Body) var result struct { Chart struct { Result []struct { Meta struct { RegularMarketPrice float64 `json:"regularMarketPrice"` ChartPreviousClose float64 `json:"chartPreviousClose"` } `json:"meta"` } `json:"result"` } `json:"chart"` } if err := json.Unmarshal(data, &result); err != nil { return IndexQuote{Name: name}, fmt.Errorf("%s 파싱 실패: %w", name, err) } if len(result.Chart.Result) == 0 { return IndexQuote{Name: name}, fmt.Errorf("%s 데이터 없음", name) } meta := result.Chart.Result[0].Meta price := meta.RegularMarketPrice prev := meta.ChartPreviousClose change := price - prev var changeRate float64 if prev != 0 { changeRate = (change / prev) * 100 } return IndexQuote{ Name: name, Price: price, Change: change, ChangeRate: changeRate, }, nil } // parseIndexFloat 키움 지수 문자열 → float64 (부호 포함) func parseIndexFloat(s string) float64 { s = strings.TrimSpace(s) if s == "" { return 0 } neg := strings.HasPrefix(s, "-") s = strings.TrimLeft(s, "+-") s = strings.ReplaceAll(s, ",", "") f, _ := strconv.ParseFloat(s, 64) if neg { return -f } return f }