first commit
This commit is contained in:
349
services/analysis_service.go
Normal file
349
services/analysis_service.go
Normal file
@@ -0,0 +1,349 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user