first commit

This commit is contained in:
hayato5246
2026-03-31 19:32:59 +09:00
commit d10b794c9f
78 changed files with 1671595 additions and 0 deletions

767
services/scanner_service.go Normal file
View File

@@ -0,0 +1,767 @@
package services
import (
"log"
"sort"
"stocksearch/config"
"stocksearch/models"
"sync"
"sync/atomic"
"time"
)
// SignalStock 체결강도 상승 감지 시그널 종목
type SignalStock struct {
models.StockPrice
PrevCntrStr float64 `json:"prevCntrStr"` // 직전 체결강도
RisingCount int `json:"risingCount"` // 연속 상승 횟수 (10초 단위)
DetectedAt time.Time `json:"detectedAt"` // 감지 시각
Sentiment string `json:"sentiment"` // 호재/악재/중립/정보없음
SentimentReason string `json:"sentimentReason"` // 한 줄 이유
TargetPrice int64 `json:"targetPrice"` // AI 추론 목표가
TargetReason string `json:"targetReason"` // 목표가 추론 근거 (한 줄)
RiseScore int `json:"riseScore"` // 상승 확률 점수 (0~100)
RiseLabel string `json:"riseLabel"` // "매우 높음" / "높음" / ""
NextDayTrend string `json:"nextDayTrend"` // 익일 추세: "상승" | "하락" | "횡보"
NextDayConf string `json:"nextDayConf"` // 신뢰도: "높음" | "보통" | "낮음"
NextDayReason string `json:"nextDayReason"` // 익일 추세 근거 (한 줄)
// 복합 분석 지표 (체결강도 + 매도잔량 + 거래량 + 가격위치)
TotalAskVol int64 `json:"totalAskVol"` // 총매도잔량
TotalBidVol int64 `json:"totalBidVol"` // 총매수잔량
AskBidRatio float64 `json:"askBidRatio"` // 매도/매수 잔량비 (1 이상=매도우세)
VolDelta int64 `json:"volDelta"` // 당 구간 거래량 증가분 (10초)
VolRatio float64 `json:"volRatio"` // 거래량 증가율 (직전 평균 대비 배수)
UpperWick float64 `json:"upperWick"` // 윗꼬리 비율 (0=없음, 1=전부 윗꼬리)
PricePos float64 `json:"pricePos"` // 장중 가격 위치 % (0=저가, 100=고가)
SignalType string `json:"signalType"` // "강한매수" | "매수우세" | "물량소화" | "추격위험" | "약한상승"
}
// cntrHistory 종목별 체결강도 이력 (최근 N회)
type cntrHistory struct {
values []float64 // 오래된 것부터, 최신이 마지막
}
func (h *cntrHistory) push(v float64) {
h.values = append(h.values, v)
if len(h.values) > 6 { // 최대 6회(1분) 유지
h.values = h.values[1:]
}
}
// risingCount 직전 N회 연속 상승 횟수 반환 (최소 1회 비교 필요)
func (h *cntrHistory) risingCount() int {
vals := h.values
if len(vals) < 2 {
return 0
}
count := 0
for i := len(vals) - 1; i >= 1; i-- {
if vals[i] > vals[i-1] {
count++
} else {
break
}
}
return count
}
// volumeHist 종목별 거래량 이력 (10초 구간 증가분 추적)
type volumeHist struct {
last int64 // 직전 스캔 누적 거래량
deltas []int64 // 최근 6회 구간 증가분
}
// push 현재 누적 거래량을 기록하고 (구간 증가분, 평균 대비 배수) 반환
func (h *volumeHist) push(current int64) (delta int64, ratio float64) {
if h.last > 0 && current > h.last {
delta = current - h.last
// 비율 계산: 기존 이력 기준 (현재 delta 추가 전)
if len(h.deltas) > 0 {
sum := int64(0)
for _, d := range h.deltas {
sum += d
}
avg := float64(sum) / float64(len(h.deltas))
if avg > 0 {
ratio = float64(delta) / avg
}
}
h.deltas = append(h.deltas, delta)
if len(h.deltas) > 6 {
h.deltas = h.deltas[1:]
}
}
h.last = current
return delta, ratio
}
// ScannerService 체결강도 상승 감지 스캐너 서비스
type ScannerService struct {
kiwoom *KiwoomClient
stockSvc *StockService
analysis *AnalysisService
mu sync.RWMutex
enabled int32 // atomic: 1=켜짐(기본), 0=꺼짐
signals []SignalStock
history map[string]*cntrHistory // 종목별 체결강도 이력
volumeHistory map[string]*volumeHist // 종목별 거래량 이력
signalCache map[string]SignalStock // 종목별 마지막 시그널 (LLM 결과 포함)
signalExpiry map[string]time.Time // 종목별 시그널 만료 시각 (1분)
// 관심종목 전용 이력/캐시
watchlistHistory map[string]*cntrHistory
watchlistVolHistory map[string]*volumeHist
watchlistSignalCache map[string]SignalStock
watchlistSignalExpiry map[string]time.Time
// WS 구독 콜백 (Hub.SubscribeInternal 연결)
subscribeCallback func([]string)
}
var scannerSvc *ScannerService
// GetScannerService 스캐너 서비스 싱글턴 반환
func GetScannerService() *ScannerService {
if scannerSvc == nil {
scannerSvc = &ScannerService{
kiwoom: GetKiwoomClient(),
stockSvc: GetStockService(),
analysis: GetAnalysisService(config.App.GroqAPIKey, config.App.GroqModel),
history: make(map[string]*cntrHistory),
volumeHistory: make(map[string]*volumeHist),
signalCache: make(map[string]SignalStock),
signalExpiry: make(map[string]time.Time),
watchlistHistory: make(map[string]*cntrHistory),
watchlistVolHistory: make(map[string]*volumeHist),
watchlistSignalCache: make(map[string]SignalStock),
watchlistSignalExpiry: make(map[string]time.Time),
}
atomic.StoreInt32(&scannerSvc.enabled, 1) // 기본값: 켜짐
}
return scannerSvc
}
// Start 스캐너 백그라운드 고루틴 시작
func (s *ScannerService) Start() {
go s.run()
}
// SetEnabled 스캐너 활성화 여부 설정
func (s *ScannerService) SetEnabled(on bool) {
if on {
atomic.StoreInt32(&s.enabled, 1)
} else {
atomic.StoreInt32(&s.enabled, 0)
}
}
// IsEnabled 스캐너 활성화 여부 반환
func (s *ScannerService) IsEnabled() bool {
return atomic.LoadInt32(&s.enabled) == 1
}
// SetSubscribeCallback 종목 WS 구독 요청 콜백 설정 (Hub.SubscribeInternal 연결)
func (s *ScannerService) SetSubscribeCallback(fn func([]string)) {
s.mu.Lock()
s.subscribeCallback = fn
s.mu.Unlock()
}
// GetSignals 현재 감지된 시그널 종목 목록 반환
func (s *ScannerService) GetSignals() []SignalStock {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]SignalStock, len(s.signals))
copy(result, s.signals)
return result
}
// run 08:00 KST 이후 10초 주기로 스캔 반복 (enabled=0이면 스캔 건너뜀)
func (s *ScannerService) run() {
kst, _ := time.LoadLocation("Asia/Seoul")
for {
now := time.Now().In(kst)
if now.Hour() < 8 {
next := time.Date(now.Year(), now.Month(), now.Day(), 8, 0, 0, 0, kst)
log.Printf("스캐너: 08:00 KST 대기 중 (%v)", next.Format("2006-01-02 15:04:05"))
time.Sleep(time.Until(next))
}
if s.IsEnabled() {
s.scan()
}
time.Sleep(10 * time.Second)
}
}
// calcRiseScore 4가지 복합 요소 기반 상승 확률 점수 계산 (0~100점)
// A.체결강도(30) + B.연속상승(25) + C.가격위치/캔들(20) + D.거래량건전성(15) + E.매도잔량소화(10)
func calcRiseScore(cntrStr float64, risingCount int, changeRate float64,
high, low, currentPrice int64, volRatio float64,
totalAskVol, totalBidVol int64) int {
score := 0
// A. 체결강도 레벨 (0~30점)
switch {
case cntrStr >= 150:
score += 30
case cntrStr >= 130:
score += 22
case cntrStr >= 110:
score += 14
case cntrStr >= 100:
score += 7
}
// B. 연속 상승 횟수 (0~25점)
switch {
case risingCount >= 5:
score += 25
case risingCount >= 4:
score += 20
case risingCount >= 3:
score += 14
case risingCount >= 2:
score += 8
case risingCount == 1:
score += 3
}
// C. 가격 위치 및 캔들 형태 (0~20점)
// C1. 등락률
switch {
case changeRate >= 3.0:
score += 6
case changeRate >= 1.0:
score += 4
case changeRate >= 0.0:
score += 1
}
// C2. 윗꼬리 비율 (0=없음 → 강한 양봉, 클수록 매도 압력)
if high > low {
upperWick := float64(high-currentPrice) / float64(high-low)
switch {
case upperWick <= 0.10:
score += 10 // 윗꼬리 거의 없음 = 강한 양봉
case upperWick <= 0.25:
score += 6
case upperWick <= 0.40:
score += 2
case upperWick > 0.60:
score -= 8 // 긴 윗꼬리 = 강한 매도 압력
}
// C3. 가격이 고가 80% 이상 위치 → 매수 우세
pricePos := float64(currentPrice-low) / float64(high-low) * 100
if pricePos >= 80 {
score += 4
}
}
// D. 거래량 건전성 (0~15점)
// 2~5배 증가가 최적, 10배+ 과열은 고점 물량털기 가능성으로 감점
switch {
case volRatio >= 2.0 && volRatio < 5.0:
score += 15 // 건강한 거래량 증가
case volRatio >= 1.5 && volRatio < 2.0:
score += 10
case volRatio >= 1.0 && volRatio < 1.5:
score += 6
case volRatio >= 5.0 && volRatio < 10.0:
score += 4 // 과열 초입
case volRatio >= 10.0:
score -= 5 // 폭발적 과열: 고점 물량털기 경계
case volRatio > 0:
score += 2
}
// E. 매도잔량 소화 여부 (0~10점)
if totalAskVol > 0 && totalBidVol > 0 {
bidAskRatio := float64(totalBidVol) / float64(totalAskVol)
switch {
case bidAskRatio >= 1.5:
score += 10 // 매수잔량 압도적: 위 물량 소화 중
case bidAskRatio >= 1.0:
score += 6 // 매수 ≥ 매도
case bidAskRatio >= 0.7:
score += 2
default:
score -= 3 // 매도잔량 크게 우세: 상단 물량 부담
}
}
if score < 0 {
return 0
}
return score
}
// classifySignalType 4가지 요소 조합으로 신호 유형 분류
func classifySignalType(sig *SignalStock) string {
upperWick := sig.UpperWick
askBidRatio := sig.AskBidRatio // 1 이상=매도우세, 1 미만=매수우세
if askBidRatio == 0 {
askBidRatio = 1.0 // 데이터 없으면 중립으로 취급
}
// 추격위험: 체결강도 과열 + 거래량 폭발 + 긴 윗꼬리
// → 단타 추격매수 몰림 후 고점 물량털기 패턴
if sig.CntrStr >= 170 && sig.VolRatio >= 7.0 && upperWick >= 0.4 {
return "추격위험"
}
// 강한매수: 체결강도 강함 + 연속상승 + 가격 우상향 + 윗꼬리 없음 + 매도잔량 소화
// → "사는 쪽이 실제로 강하고, 던지는 물량도 받아내는" 패턴
if sig.CntrStr >= 130 && sig.RisingCount >= 3 &&
sig.ChangeRate >= 1.0 && upperWick <= 0.25 && askBidRatio <= 1.0 {
return "강한매수"
}
// 물량소화: 체결강도 높은데 가격이 제자리 + 긴 윗꼬리
// → 위에서 던지는 물량을 받아내기만 하는 중
if sig.CntrStr >= 120 && sig.ChangeRate <= 0.5 && upperWick >= 0.35 {
return "물량소화"
}
// 약한상승: 거래량 적고 체결강도도 약함
// → 얇은 호가에서 뜬 상승, 쉽게 꺾일 수 있음
if sig.VolRatio < 1.0 && sig.CntrStr < 120 {
return "약한상승"
}
return "매수우세"
}
// scan 거래량 상위 20종목을 조회해 복합 분석으로 시그널 종목 필터링
func (s *ScannerService) scan() {
stocks, err := s.kiwoom.GetTopVolumeStocks("J", 20)
if err != nil {
log.Printf("스캐너 거래량순위 조회 실패: %v", err)
return
}
// 거래량 상위 종목 WS 구독 요청 (캐시 활용을 위해 미리 등록)
s.mu.RLock()
cb := s.subscribeCallback
s.mu.RUnlock()
if cb != nil {
codes := make([]string, len(stocks))
for i, st := range stocks {
codes[i] = st.Code
}
cb(codes)
}
var signals []SignalStock
s.mu.Lock()
for _, stock := range stocks {
// ka10003으로 최신 체결강도 조회; 실패 시 순위 응답값 사용
cntrStr, err := s.kiwoom.getCntrStr(stock.Code)
if err != nil || cntrStr == 0 {
cntrStr = stock.CntrStr
}
// 체결강도 이력 업데이트
h, ok := s.history[stock.Code]
if !ok {
h = &cntrHistory{}
s.history[stock.Code] = h
}
h.push(cntrStr)
rising := h.risingCount()
if rising == 0 {
continue
}
prev := float64(0)
if len(h.values) >= 2 {
prev = h.values[len(h.values)-2]
}
// 거래량 이력 업데이트 → 구간 증가분 및 증가율 계산
vh, ok := s.volumeHistory[stock.Code]
if !ok {
vh = &volumeHist{}
s.volumeHistory[stock.Code] = vh
}
volDelta, volRatio := vh.push(stock.Volume)
// 윗꼬리 비율 및 가격 위치 계산
upperWick, pricePos := 0.5, 50.0 // 데이터 없으면 중간값
if stock.High > stock.Low {
upperWick = float64(stock.High-stock.CurrentPrice) / float64(stock.High-stock.Low)
pricePos = float64(stock.CurrentPrice-stock.Low) / float64(stock.High-stock.Low) * 100
}
stock.CntrStr = cntrStr
signals = append(signals, SignalStock{
StockPrice: stock,
PrevCntrStr: prev,
RisingCount: rising,
DetectedAt: time.Now(),
VolDelta: volDelta,
VolRatio: volRatio,
UpperWick: upperWick,
PricePos: pricePos,
})
}
s.mu.Unlock()
// ── 호가잔량 병렬 조회 (체결강도 상승 종목에 한해) ────────────────
if len(signals) > 0 {
var wg sync.WaitGroup
for i := range signals {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ask, bid, _, err := s.kiwoom.getOrderBook(signals[idx].Code)
if err != nil {
return
}
signals[idx].TotalAskVol = ask
signals[idx].TotalBidVol = bid
if bid > 0 {
signals[idx].AskBidRatio = float64(ask) / float64(bid)
}
}(i)
}
wg.Wait()
}
// ── 최종 스코어 및 신호 유형 계산 (호가잔량 포함) ────────────────
for i := range signals {
sig := &signals[i]
sig.RiseScore = calcRiseScore(
sig.CntrStr, sig.RisingCount, sig.ChangeRate,
sig.High, sig.Low, sig.CurrentPrice,
sig.VolRatio, sig.TotalAskVol, sig.TotalBidVol,
)
sig.SignalType = classifySignalType(sig)
switch {
case sig.RiseScore >= 70:
sig.RiseLabel = "매우 높음"
case sig.RiseScore >= 50:
sig.RiseLabel = "높음"
default:
sig.RiseLabel = ""
}
}
// ── 1분 유지 캐시 병합 ────────────────────────────────────────────
const signalTTL = time.Minute
now := time.Now()
s.mu.Lock()
activeCodes := make(map[string]bool, len(signals))
for i := range signals {
code := signals[i].Code
activeCodes[code] = true
s.signalExpiry[code] = now.Add(signalTTL)
// 기존 LLM 결과 재사용 (Groq 재호출 방지)
if cached, ok := s.signalCache[code]; ok && cached.Sentiment != "" {
signals[i].Sentiment = cached.Sentiment
signals[i].SentimentReason = cached.SentimentReason
signals[i].TargetPrice = cached.TargetPrice
signals[i].TargetReason = cached.TargetReason
signals[i].NextDayTrend = cached.NextDayTrend
signals[i].NextDayConf = cached.NextDayConf
signals[i].NextDayReason = cached.NextDayReason
}
}
// 만료 안 된 이전 시그널 병합
for code, expiry := range s.signalExpiry {
if expiry.Before(now) {
delete(s.signalExpiry, code)
delete(s.signalCache, code)
continue
}
if !activeCodes[code] {
signals = append(signals, s.signalCache[code])
}
}
s.mu.Unlock()
// ── RiseScore 내림차순 정렬 (동점 시 체결강도 기준) ──────────────
sort.Slice(signals, func(i, j int) bool {
if signals[i].RiseScore != signals[j].RiseScore {
return signals[i].RiseScore > signals[j].RiseScore
}
return signals[i].CntrStr > signals[j].CntrStr
})
// ── LLM 병렬 분석 (shouldAnalyze 통과 종목만, 5초 타임아웃) ──────
if len(signals) > 0 {
var wg sync.WaitGroup
done := make(chan struct{})
for i := range signals {
if !shouldAnalyze(&signals[i]) {
continue
}
wg.Add(1)
go func(idx int) {
defer wg.Done()
sig := &signals[idx]
sentiment, reason := s.analysis.Analyze(sig.Code, sig.Name)
sig.Sentiment = sentiment
sig.SentimentReason = reason
targetPrice, targetReason := s.analysis.PredictTargetPriceFromSignal(
sig.Code, sig.Name,
sig.CurrentPrice, sig.High, sig.Low, sig.Open,
sig.ChangeRate, sig.CntrStr, sig.PrevCntrStr, sig.RisingCount,
sentiment, reason,
)
sig.TargetPrice = targetPrice
sig.TargetReason = targetReason
trend, conf, trendReason := s.analysis.PredictNextDayTrend(
sig.Code, sig.Name,
sig.CurrentPrice, sig.High, sig.Low, sig.Open,
sig.ChangeRate, sig.CntrStr, sig.RisingCount,
sentiment, reason,
)
sig.NextDayTrend = trend
sig.NextDayConf = conf
sig.NextDayReason = trendReason
}(i)
}
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
log.Printf("스캐너: 감성 분석 5초 타임아웃 (일부 미완료 가능)")
}
}
s.mu.Lock()
for _, sig := range signals {
s.signalCache[sig.Code] = sig
}
s.signals = signals
s.mu.Unlock()
log.Printf("스캐너: 거래량상위20 체결강도 체크 완료 → 시그널 %d개", len(signals))
}
// shouldAnalyze LLM 분석 호출 여부 판단 (Groq API 호출량 절감)
// 상승 확률이 높은 종목만 통과: RiseScore 50+, 2회 이상 연속 상승, 체결강도 100+, 등락률 0% 이상
func shouldAnalyze(sig *SignalStock) bool {
return sig.RiseScore >= 50 &&
sig.RisingCount >= 2 &&
sig.CntrStr >= 100 &&
sig.ChangeRate >= 0
}
// AnalyzeWatchlist 관심종목 코드 목록에 대해 복합 분석 수행 후 SignalStock 반환
func (s *ScannerService) AnalyzeWatchlist(codes []string) []SignalStock {
// 분석 전 WS 구독 요청 (다음 사이클부터 캐시 활용 가능)
s.mu.RLock()
cb := s.subscribeCallback
s.mu.RUnlock()
if cb != nil && len(codes) > 0 {
cb(codes)
}
type interim struct {
code string
cntrStr float64
prev float64
rising int
volDelta int64
volRatio float64
price *models.StockPrice
}
// Phase 1: 현재가/체결강도/거래량 이력 순차 수집
// GetCurrentPrice 내부에서 getCntrStr(ka10003)을 이미 호출하므로 중복 호출 없음
items := make([]interim, 0, len(codes))
for _, code := range codes {
// 현재가 조회 (캐시 활용, 내부적으로 체결강도도 포함)
sp, err := s.stockSvc.GetCurrentPrice(code)
if err != nil {
log.Printf("관심종목 현재가 조회 실패 [%s]: %v", code, err)
continue
}
cntrStr := sp.CntrStr
// 체결강도 이력 업데이트
s.mu.Lock()
h, ok := s.watchlistHistory[code]
if !ok {
h = &cntrHistory{}
s.watchlistHistory[code] = h
}
h.push(cntrStr)
rising := h.risingCount()
prev := float64(0)
if len(h.values) >= 2 {
prev = h.values[len(h.values)-2]
}
// 거래량 이력 업데이트
vh, ok := s.watchlistVolHistory[code]
if !ok {
vh = &volumeHist{}
s.watchlistVolHistory[code] = vh
}
volDelta, volRatio := vh.push(sp.Volume)
s.mu.Unlock()
items = append(items, interim{
code: code,
cntrStr: cntrStr,
prev: prev,
rising: rising,
volDelta: volDelta,
volRatio: volRatio,
price: sp,
})
}
// Phase 2: SignalStock 슬라이스 구성
signals := make([]SignalStock, 0, len(items))
for _, it := range items {
sp := it.price
upperWick, pricePos := 0.5, 50.0
if sp.High > sp.Low {
upperWick = float64(sp.High-sp.CurrentPrice) / float64(sp.High-sp.Low)
pricePos = float64(sp.CurrentPrice-sp.Low) / float64(sp.High-sp.Low) * 100
}
signals = append(signals, SignalStock{
StockPrice: *sp,
PrevCntrStr: it.prev,
RisingCount: it.rising,
DetectedAt: time.Now(),
VolDelta: it.volDelta,
VolRatio: it.volRatio,
UpperWick: upperWick,
PricePos: pricePos,
})
}
// Phase 3: 호가잔량 병렬 조회
if len(signals) > 0 {
var wg sync.WaitGroup
for i := range signals {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ask, bid, _, err := s.kiwoom.getOrderBook(signals[idx].Code)
if err != nil {
return
}
signals[idx].TotalAskVol = ask
signals[idx].TotalBidVol = bid
if bid > 0 {
signals[idx].AskBidRatio = float64(ask) / float64(bid)
}
}(i)
}
wg.Wait()
}
// Phase 4: 스코어 및 신호 유형 계산
for i := range signals {
sig := &signals[i]
sig.RiseScore = calcRiseScore(
sig.CntrStr, sig.RisingCount, sig.ChangeRate,
sig.High, sig.Low, sig.CurrentPrice,
sig.VolRatio, sig.TotalAskVol, sig.TotalBidVol,
)
sig.SignalType = classifySignalType(sig)
switch {
case sig.RiseScore >= 70:
sig.RiseLabel = "매우 높음"
case sig.RiseScore >= 50:
sig.RiseLabel = "높음"
default:
sig.RiseLabel = ""
}
}
// Phase 5: 1분 TTL 캐시 병합 (LLM 결과 재사용)
const signalTTL = time.Minute
now := time.Now()
s.mu.Lock()
for i := range signals {
code := signals[i].Code
s.watchlistSignalExpiry[code] = now.Add(signalTTL)
if cached, ok := s.watchlistSignalCache[code]; ok && cached.Sentiment != "" {
signals[i].Sentiment = cached.Sentiment
signals[i].SentimentReason = cached.SentimentReason
signals[i].TargetPrice = cached.TargetPrice
signals[i].TargetReason = cached.TargetReason
signals[i].NextDayTrend = cached.NextDayTrend
signals[i].NextDayConf = cached.NextDayConf
signals[i].NextDayReason = cached.NextDayReason
}
}
// 만료된 캐시 항목 정리
for code, expiry := range s.watchlistSignalExpiry {
if expiry.Before(now) {
delete(s.watchlistSignalExpiry, code)
delete(s.watchlistSignalCache, code)
}
}
s.mu.Unlock()
// Phase 6: LLM 병렬 분석 (shouldAnalyze 통과 종목만, 5초 타임아웃)
if len(signals) > 0 {
var wg sync.WaitGroup
done := make(chan struct{})
for i := range signals {
if !shouldAnalyze(&signals[i]) {
continue
}
wg.Add(1)
go func(idx int) {
defer wg.Done()
sig := &signals[idx]
sentiment, reason := s.analysis.Analyze(sig.Code, sig.Name)
sig.Sentiment = sentiment
sig.SentimentReason = reason
targetPrice, targetReason := s.analysis.PredictTargetPriceFromSignal(
sig.Code, sig.Name,
sig.CurrentPrice, sig.High, sig.Low, sig.Open,
sig.ChangeRate, sig.CntrStr, sig.PrevCntrStr, sig.RisingCount,
sentiment, reason,
)
sig.TargetPrice = targetPrice
sig.TargetReason = targetReason
trend, conf, trendReason := s.analysis.PredictNextDayTrend(
sig.Code, sig.Name,
sig.CurrentPrice, sig.High, sig.Low, sig.Open,
sig.ChangeRate, sig.CntrStr, sig.RisingCount,
sentiment, reason,
)
sig.NextDayTrend = trend
sig.NextDayConf = conf
sig.NextDayReason = trendReason
}(i)
}
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
log.Printf("관심종목 분석: 감성 분석 5초 타임아웃")
}
}
// Phase 7: 결과 캐시 저장
s.mu.Lock()
for _, sig := range signals {
s.watchlistSignalCache[sig.Code] = sig
}
s.mu.Unlock()
return signals
}