package services import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "strings" "sync" "time" ) const groqAPIURL = "https://api.groq.com/openai/v1/chat/completions" var ( analysisSvcOnce sync.Once analysisSvc *AnalysisService ) // AnalysisService 공시·뉴스를 Groq LLM으로 분석하는 서비스 type AnalysisService struct { dartSvc *DartService newsSvc *NewsService cache *CacheService groqAPIKey string // Groq API 키 groqModel string // 사용할 모델명 httpClient *http.Client } // GetAnalysisService 싱글턴 반환 func GetAnalysisService(groqAPIKey, groqModel string) *AnalysisService { analysisSvcOnce.Do(func() { analysisSvc = &AnalysisService{ dartSvc: GetDartService(), newsSvc: GetNewsService(), cache: GetCacheService(), groqAPIKey: groqAPIKey, groqModel: groqModel, httpClient: &http.Client{Timeout: 30 * time.Second}, } }) return analysisSvc } // Analyze 공시·뉴스를 Groq LLM으로 분석하여 sentiment, reason 반환 // sentiment: "호재" | "악재" | "중립" | "정보없음" func (s *AnalysisService) Analyze(code, name string) (string, string) { kst, _ := time.LoadLocation("Asia/Seoul") now := time.Now().In(kst) today := now.Format("20060102") yesterday := now.AddDate(0, 0, -1).Format("20060102") // 캐시 확인 (10분 TTL) cacheKey := "analysis:" + code + today if cached, ok := s.cache.Get(cacheKey); ok { if pair, ok := cached.([2]string); ok { return pair[0], pair[1] } } // 공시 조회 (오늘/어제 필터) var disclosureTitles []string disclosures, err := s.dartSvc.GetDisclosures(code) if err != nil { log.Printf("분석서비스 공시 조회 실패 [%s]: %v", code, err) } else { for _, d := range disclosures { if d.RceptDt == today || d.RceptDt == yesterday { disclosureTitles = append(disclosureTitles, d.ReportNm) } } } // 뉴스 조회 (오늘/어제 필터) var newsTitles []string newsItems, err := s.newsSvc.GetNews(name) if err != nil { log.Printf("분석서비스 뉴스 조회 실패 [%s]: %v", name, err) } else { for _, item := range newsItems { if isRecentDate(item.PublishedAt, today, yesterday) { newsTitles = append(newsTitles, item.Title) } } } // 공시·뉴스 모두 없으면 API 호출 생략 if len(disclosureTitles) == 0 && len(newsTitles) == 0 { s.cache.Set(cacheKey, [2]string{"정보없음", ""}, 10*time.Minute) return "정보없음", "" } sentiment, reason := s.callGroqAPI(name, disclosureTitles, newsTitles) s.cache.Set(cacheKey, [2]string{sentiment, reason}, 10*time.Minute) return sentiment, reason } // callGroqAPI Groq API를 호출하여 호재/악재/중립 분류 func (s *AnalysisService) callGroqAPI(name string, disclosures, news []string) (string, string) { disclosureText := "없음" if len(disclosures) > 0 { disclosureText = strings.Join(disclosures, "\n") } newsText := "없음" if len(news) > 0 { newsText = strings.Join(news, "\n") } prompt := fmt.Sprintf( `당신은 주식 투자 전문가입니다. 아래 [%s] 종목의 공시와 뉴스를 보고`+ ` 투자자 관점에서 호재/악재/중립 중 하나로 분류하고, 이유를 15자 이내로 답하세요.`+ ` 반드시 JSON만 출력: {"sentiment":"호재","reason":"수주 계약 체결"}`+"\n\n"+ "[공시]\n%s\n\n[뉴스]\n%s", name, disclosureText, newsText, ) return s.callGroq(prompt, func(text string) (string, string) { var result struct { Sentiment string `json:"sentiment"` Reason string `json:"reason"` } if err := json.Unmarshal([]byte(text), &result); err != nil { log.Printf("Groq 감성 분석 JSON 파싱 실패: %v (text=%s)", err, text) return "중립", "" } switch result.Sentiment { case "호재", "악재", "중립": return result.Sentiment, result.Reason default: return "중립", "" } }) } // PredictTargetPriceFromSignal 체결강도 급등 종목 시그널로 단기 목표가 추론 func (s *AnalysisService) PredictTargetPriceFromSignal( code, name string, currentPrice, high, low, open int64, changeRate, cntrStr, prevCntrStr float64, risingCount int, sentiment, sentimentReason string, ) (int64, string) { cacheKey := fmt.Sprintf("target:%s:%d", code, currentPrice) if cached, ok := s.cache.Get(cacheKey); ok { if pair, ok := cached.([2]interface{}); ok { return pair[0].(int64), pair[1].(string) } } prompt := fmt.Sprintf( `당신은 주식 단기 매매 전문가입니다. 체결강도가 연속 상승 중인 [%s](%s) 종목을 매수했을 때 단기(당일~2일) 수익 실현 매도 목표가를 추론하세요. 목표가는 반드시 현재가(%d원)보다 높아야 합니다. 현재가: %d원 / 시가: %d원 / 고가: %d원 / 저가: %d원 등락률: %.2f%% / 체결강도: %.2f(직전 %.2f, %d회 연속 상승) 공시·뉴스 분석: %s (%s) 반드시 JSON만 출력: {"targetPrice":12500,"reason":"체결강도 급등+호재 공시"}`, name, code, currentPrice, currentPrice, open, high, low, changeRate, cntrStr, prevCntrStr, risingCount, sentiment, sentimentReason, ) price, reason := s.callGroq(prompt, func(text string) (string, string) { var result struct { TargetPrice int64 `json:"targetPrice"` Reason string `json:"reason"` } if err := json.Unmarshal([]byte(text), &result); err != nil { log.Printf("목표가 추론 JSON 파싱 실패 [%s]: %v (text=%s)", code, err, text) return "", "" } if result.TargetPrice <= currentPrice { log.Printf("목표가 무효 [%s]: AI 반환값 %d원 ≤ 현재가 %d원", code, result.TargetPrice, currentPrice) return "", "" } return fmt.Sprintf("%d", result.TargetPrice), result.Reason }) if price == "" { return 0, "" } var targetPrice int64 fmt.Sscanf(price, "%d", &targetPrice) s.cache.Set(cacheKey, [2]interface{}{targetPrice, reason}, 5*time.Minute) return targetPrice, reason } // PredictNextDayTrend 체결강도·시세·감성 데이터를 바탕으로 익일 주가 추세를 예측 // trend: "상승" | "하락" | "횡보", confidence: "높음" | "보통" | "낮음" func (s *AnalysisService) PredictNextDayTrend( code, name string, currentPrice, high, low, open int64, changeRate, cntrStr float64, risingCount int, sentiment, sentimentReason string, ) (trend, confidence, reason string) { kst, _ := time.LoadLocation("Asia/Seoul") today := time.Now().In(kst).Format("20060102") cacheKey := fmt.Sprintf("nextday:%s:%s", code, today) if cached, ok := s.cache.Get(cacheKey); ok { if arr, ok := cached.([3]string); ok { return arr[0], arr[1], arr[2] } } prompt := fmt.Sprintf( `당신은 한국 주식 단기 매매 전문가입니다. 아래 데이터를 종합하여 [%s](%s) 종목의 익일(다음 거래일) 주가 추세를 예측하세요. 현재가: %d원 / 시가: %d원 / 고가: %d원 / 저가: %d원 당일 등락률: %.2f%% / 체결강도: %.2f (%d회 연속 상승) 오늘 공시·뉴스 분석: %s (%s) 판단 기준: - 체결강도 연속 상승·호재 공시·양봉 마감이면 "상승" 가능성 - 체결강도 100 미만·악재 공시·음봉 마감이면 "하락" 가능성 - 신호 혼재 혹은 근거 부족이면 "횡보" - 신뢰도는 근거가 명확할수록 "높음", 애매하면 "낮음" 반드시 JSON만 출력: {"trend":"상승","confidence":"높음","reason":"체결강도 연속 급등+호재 공시"}`, name, code, currentPrice, open, high, low, changeRate, cntrStr, risingCount, sentiment, sentimentReason, ) raw, reason := s.callGroq(prompt, func(text string) (string, string) { var result struct { Trend string `json:"trend"` Confidence string `json:"confidence"` Reason string `json:"reason"` } if err := json.Unmarshal([]byte(text), &result); err != nil { log.Printf("익일 추세 JSON 파싱 실패 [%s]: %v (text=%s)", code, err, text) return "", "" } switch result.Trend { case "상승", "하락", "횡보": default: result.Trend = "횡보" } // trend|confidence 를 첫 번째 반환값에 묶어서 전달 return result.Trend + "|" + result.Confidence, result.Reason }) if raw == "" { return "횡보", "", "" } parts := strings.SplitN(raw, "|", 2) trend = parts[0] if len(parts) > 1 { confidence = parts[1] } s.cache.Set(cacheKey, [3]string{trend, confidence, reason}, 30*time.Minute) return trend, confidence, reason } // callGroq Groq API 공통 호출 함수 (OpenAI 호환) // parseFunc: 응답 텍스트를 파싱하여 결과 반환 func (s *AnalysisService) callGroq(prompt string, parseFunc func(string) (string, string)) (string, string) { reqBody, _ := json.Marshal(map[string]any{ "model": s.groqModel, "stream": false, "messages": []map[string]string{ {"role": "user", "content": prompt}, }, }) req, err := http.NewRequest("POST", groqAPIURL, bytes.NewReader(reqBody)) if err != nil { log.Printf("Groq API 요청 생성 실패: %v", err) return "중립", "" } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+s.groqAPIKey) resp, err := s.httpClient.Do(req) if err != nil { log.Printf("Groq API 호출 실패: %v", err) return "중립", "" } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { log.Printf("Groq API 오류 [%d]: %s", resp.StatusCode, string(body)) return "중립", "" } // OpenAI 호환 응답 파싱: choices[0].message.content var apiResp struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.Unmarshal(body, &apiResp); err != nil { log.Printf("Groq API 응답 파싱 실패: %v", err) return "중립", "" } if len(apiResp.Choices) == 0 { log.Printf("Groq API 응답 choices 비어있음") return "중립", "" } text := strings.TrimSpace(apiResp.Choices[0].Message.Content) // JSON 블록 추출 (```json ... ``` 감싸진 경우 대비) if idx := strings.Index(text, "{"); idx >= 0 { if end := strings.LastIndex(text, "}"); end >= idx { text = text[idx : end+1] } } if text == "" { log.Printf("Groq API 응답 비어있음") return "중립", "" } return parseFunc(text) } // isRecentDate RFC1123Z 형식 날짜가 today/yesterday 포함 여부 확인 func isRecentDate(pubDate, today, yesterday string) bool { // RFC1123Z: "Mon, 02 Jan 2006 15:04:05 -0700" formats := []string{ time.RFC1123Z, time.RFC1123, } for _, f := range formats { t, err := time.Parse(f, pubDate) if err == nil { kst, _ := time.LoadLocation("Asia/Seoul") d := t.In(kst).Format("20060102") return d == today || d == yesterday } } return false }