Files
stocksearch/services/analysis_service.go
2026-03-31 19:32:59 +09:00

350 lines
10 KiB
Go

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
}