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 }