first commit
This commit is contained in:
767
services/scanner_service.go
Normal file
767
services/scanner_service.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user