768 lines
22 KiB
Go
768 lines
22 KiB
Go
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
|
|
}
|