package services import ( "crypto/rand" "fmt" "log" "strconv" "sync" "sync/atomic" "time" "stocksearch/models" ) // newUUID UUID v4 생성 func newUUID() string { b := make([]byte, 16) _, _ = rand.Read(b) b[6] = (b[6] & 0x0f) | 0x40 b[8] = (b[8] & 0x3f) | 0x80 return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) } const ( maxLogEntries = 300 // 최대 로그 보관 건수 (debug 로그 빈도 대비) cooldownMinutes = 5 // 동일 종목 재진입 쿨다운(분) exitLoopSec = 5 // 청산 루프 주기(초) entryLoopSec = 10 // 진입 루프 주기(초) pendingCheckSec = 30 // pending 확인 주기(초) ) // AutoTradeService 자동매매 엔진 서비스 type AutoTradeService struct { scanner *ScannerService orderSvc *OrderService accountSvc *AccountService stockSvc *StockService themeSvc *ThemeService mu sync.RWMutex running int32 // atomic: 1=실행, 0=중지 rules []models.AutoTradeRule positions map[string]*models.AutoTradePosition // code → 포지션 logs []models.AutoTradeLog // 최근 maxLogEntries건 cooldown map[string]time.Time // code → 마지막 진입 시각 watchSource models.AutoTradeWatchSource // 감시 소스 설정 logBroadcaster func(models.AutoTradeLog) // WS 브로드캐스트 콜백 } var autoTradeService *AutoTradeService // GetAutoTradeService 자동매매 서비스 싱글턴 반환 func GetAutoTradeService() *AutoTradeService { if autoTradeService == nil { autoTradeService = &AutoTradeService{ scanner: GetScannerService(), orderSvc: GetOrderService(), accountSvc: GetAccountService(), stockSvc: GetStockService(), themeSvc: GetThemeService(), positions: make(map[string]*models.AutoTradePosition), cooldown: make(map[string]time.Time), watchSource: models.AutoTradeWatchSource{ UseScanner: true, SelectedThemes: []models.ThemeRef{}, }, } } return autoTradeService } // SetLogBroadcaster WS 브로드캐스트 콜백 등록 (main.go에서 Hub 주입 시 호출) func (s *AutoTradeService) SetLogBroadcaster(fn func(models.AutoTradeLog)) { s.mu.Lock() s.logBroadcaster = fn s.mu.Unlock() } // GetWatchSource 현재 감시 소스 설정 반환 func (s *AutoTradeService) GetWatchSource() models.AutoTradeWatchSource { s.mu.RLock() defer s.mu.RUnlock() ws := s.watchSource themes := make([]models.ThemeRef, len(ws.SelectedThemes)) copy(themes, ws.SelectedThemes) ws.SelectedThemes = themes return ws } // SetWatchSource 감시 소스 설정 업데이트 func (s *AutoTradeService) SetWatchSource(ws models.AutoTradeWatchSource) { s.mu.Lock() s.watchSource = ws s.mu.Unlock() sources := "없음" if ws.UseScanner && len(ws.SelectedThemes) > 0 { names := make([]string, len(ws.SelectedThemes)) for i, t := range ws.SelectedThemes { names[i] = t.Name } sources = fmt.Sprintf("자동감지+테마(%s)", joinStrings(names, ",")) } else if ws.UseScanner { sources = "체결강도 자동감지" } else if len(ws.SelectedThemes) > 0 { names := make([]string, len(ws.SelectedThemes)) for i, t := range ws.SelectedThemes { names[i] = t.Name } sources = fmt.Sprintf("테마(%s)", joinStrings(names, ",")) } s.addLog("info", "", fmt.Sprintf("감시 소스 변경: %s", sources)) } // joinStrings 문자열 슬라이스를 구분자로 결합 func joinStrings(ss []string, sep string) string { result := "" for i, s := range ss { if i > 0 { result += sep } result += s } return result } // getWatchSignals 설정된 감시 소스에서 신호 목록 수집 func (s *AutoTradeService) getWatchSignals() []SignalStock { s.mu.RLock() ws := s.watchSource themes := make([]models.ThemeRef, len(ws.SelectedThemes)) copy(themes, ws.SelectedThemes) s.mu.RUnlock() var result []SignalStock seen := make(map[string]bool) // 체결강도 자동감지 신호 수집 if ws.UseScanner { scannerSigs := s.scanner.GetSignals() s.addLog("debug", "", fmt.Sprintf("스캐너 신호 수신: %d개", len(scannerSigs))) for _, sig := range scannerSigs { s.addLog("debug", sig.Code, fmt.Sprintf("스캐너 [%s] 현재가=%s원 체결강도=%.1f RiseScore=%d 유형=%s 등락=%.2f%%", sig.Name, formatComma(sig.CurrentPrice), sig.CntrStr, sig.RiseScore, sig.SignalType, sig.ChangeRate)) if !seen[sig.Code] { result = append(result, sig) seen[sig.Code] = true } } } // 선택된 테마 종목 분석 (테마당 최대 15종목으로 제한해 API 호출량 억제) const maxThemeStocks = 15 for _, theme := range themes { detail, err := s.themeSvc.GetThemeStocks(theme.Code, "D") if err != nil { s.addLog("warn", "", fmt.Sprintf("테마 종목 조회 실패 [%s]: %v", theme.Name, err)) continue } codes := make([]string, 0, maxThemeStocks) for _, st := range detail.Stocks { if !seen[st.Code] { codes = append(codes, st.Code) if len(codes) >= maxThemeStocks { break } } } if len(codes) == 0 { s.addLog("debug", "", fmt.Sprintf("테마[%s] 분석 대상 종목 없음 (중복 제외 후)", theme.Name)) continue } s.addLog("debug", "", fmt.Sprintf("테마[%s] %d종목 분석 시작", theme.Name, len(codes))) sigs := s.scanner.AnalyzeWatchlist(codes) s.addLog("debug", "", fmt.Sprintf("테마[%s] 분석 완료: %d개 신호", theme.Name, len(sigs))) for _, sig := range sigs { s.addLog("debug", sig.Code, fmt.Sprintf("테마검증 [%s] 현재가=%s원 체결강도=%.1f RiseScore=%d 유형=%s 등락=%.2f%%", sig.Name, formatComma(sig.CurrentPrice), sig.CntrStr, sig.RiseScore, sig.SignalType, sig.ChangeRate)) if !seen[sig.Code] { result = append(result, sig) seen[sig.Code] = true } } } return result } // Start 자동매매 엔진 시작 func (s *AutoTradeService) Start() { if !atomic.CompareAndSwapInt32(&s.running, 0, 1) { return // 이미 실행 중 } s.addLog("info", "", "자동매매 엔진 시작") go s.entryLoop() go s.exitLoop() } // Stop 자동매매 엔진 중지 func (s *AutoTradeService) Stop() { if atomic.CompareAndSwapInt32(&s.running, 1, 0) { s.addLog("info", "", "자동매매 엔진 중지") } } // IsRunning 엔진 실행 여부 확인 func (s *AutoTradeService) IsRunning() bool { return atomic.LoadInt32(&s.running) == 1 } // EmergencyStop 긴급 청산: 엔진 중지 후 모든 포지션 시장가 매도 func (s *AutoTradeService) EmergencyStop() { s.Stop() s.mu.Lock() codes := make([]string, 0, len(s.positions)) for code, p := range s.positions { if p.Status == "open" || p.Status == "pending" { codes = append(codes, code) } } s.mu.Unlock() for _, code := range codes { s.mu.RLock() pos, ok := s.positions[code] s.mu.RUnlock() if !ok { continue } if err := s.executeSell(pos, "긴급"); err != nil { s.addLog("error", code, fmt.Sprintf("긴급청산 실패: %v", err)) } } s.addLog("warn", "", "긴급 전량청산 완료") } // --- CRUD --- // AddRule 규칙 추가 func (s *AutoTradeService) AddRule(rule models.AutoTradeRule) models.AutoTradeRule { rule.ID = newUUID() rule.CreatedAt = time.Now() s.mu.Lock() s.rules = append(s.rules, rule) s.mu.Unlock() s.addLog("info", "", fmt.Sprintf("규칙 추가: %s", rule.Name)) return rule } // UpdateRule 규칙 수정 func (s *AutoTradeService) UpdateRule(id string, updated models.AutoTradeRule) bool { s.mu.Lock() defer s.mu.Unlock() for i, r := range s.rules { if r.ID == id { updated.ID = id updated.CreatedAt = r.CreatedAt s.rules[i] = updated return true } } return false } // DeleteRule 규칙 삭제 func (s *AutoTradeService) DeleteRule(id string) bool { s.mu.Lock() defer s.mu.Unlock() for i, r := range s.rules { if r.ID == id { s.rules = append(s.rules[:i], s.rules[i+1:]...) return true } } return false } // ToggleRule 규칙 활성화/비활성화 토글 func (s *AutoTradeService) ToggleRule(id string) (bool, bool) { s.mu.Lock() defer s.mu.Unlock() for i, r := range s.rules { if r.ID == id { s.rules[i].Enabled = !r.Enabled return true, s.rules[i].Enabled } } return false, false } // GetRules 규칙 목록 반환 func (s *AutoTradeService) GetRules() []models.AutoTradeRule { s.mu.RLock() defer s.mu.RUnlock() result := make([]models.AutoTradeRule, len(s.rules)) copy(result, s.rules) return result } // GetPositions 포지션 목록 반환 func (s *AutoTradeService) GetPositions() []*models.AutoTradePosition { s.mu.RLock() defer s.mu.RUnlock() result := make([]*models.AutoTradePosition, 0, len(s.positions)) for _, p := range s.positions { cp := *p result = append(result, &cp) } return result } // GetLogs 최근 로그 반환 func (s *AutoTradeService) GetLogs() []models.AutoTradeLog { s.mu.RLock() defer s.mu.RUnlock() result := make([]models.AutoTradeLog, len(s.logs)) copy(result, s.logs) return result } // GetStats 오늘 통계 반환 (매매 횟수, 손익) func (s *AutoTradeService) GetStats() (tradeCount int, totalPL int64) { today := time.Now().Truncate(24 * time.Hour) s.mu.RLock() defer s.mu.RUnlock() for _, p := range s.positions { if p.Status == "closed" && !p.ExitTime.Before(today) { tradeCount++ totalPL += (p.ExitPrice - p.BuyPrice) * p.Qty } } return } // --- 내부 고루틴 --- // entryLoop 10초 주기 진입 루프 func (s *AutoTradeService) entryLoop() { ticker := time.NewTicker(entryLoopSec * time.Second) defer ticker.Stop() for { if atomic.LoadInt32(&s.running) == 0 { return } <-ticker.C if atomic.LoadInt32(&s.running) == 0 { return } s.checkEntries() } } // exitLoop 5초 주기 청산 루프 func (s *AutoTradeService) exitLoop() { ticker := time.NewTicker(exitLoopSec * time.Second) defer ticker.Stop() pendingTicker := time.NewTicker(pendingCheckSec * time.Second) defer pendingTicker.Stop() for { if atomic.LoadInt32(&s.running) == 0 { return } select { case <-ticker.C: if atomic.LoadInt32(&s.running) == 0 { return } s.checkExits() case <-pendingTicker.C: if atomic.LoadInt32(&s.running) == 0 { return } s.checkPending() } } } // checkEntries 진입 조건 체크 및 매수 주문 func (s *AutoTradeService) checkEntries() { signals := s.getWatchSignals() s.mu.RLock() rules := make([]models.AutoTradeRule, len(s.rules)) copy(rules, s.rules) s.mu.RUnlock() // 활성 규칙 수 계산 activeRules := 0 for _, r := range rules { if r.Enabled { activeRules++ } } s.addLog("debug", "", fmt.Sprintf("진입 스캔: 신호 %d개, 활성규칙 %d개", len(signals), activeRules)) if len(signals) == 0 { return } for _, rule := range rules { if !rule.Enabled { continue } for _, sig := range signals { code := sig.Code if code == "" { continue } s.mu.RLock() _, alreadyHeld := s.positions[code] lastEntry, hasCooldown := s.cooldown[code] posCount := s.countActivePositions() s.mu.RUnlock() // 신호 검토 로그 sentimentStr := sig.Sentiment if sentimentStr == "" { sentimentStr = "중립" } s.addLog("debug", code, fmt.Sprintf("검토 [%s] RiseScore=%d 체결강도=%.1f 감정=%s 유형=%s", sig.Name, sig.RiseScore, sig.CntrStr, sentimentStr, sig.SignalType)) // 이미 보유 중인 종목 스킵 if alreadyHeld { s.addLog("debug", code, "스킵: 이미 보유 중") continue } // 쿨다운 체크 (5분) if hasCooldown && time.Since(lastEntry) < cooldownMinutes*time.Minute { remaining := cooldownMinutes*time.Minute - time.Since(lastEntry) s.addLog("debug", code, fmt.Sprintf("스킵: 쿨다운 %.1f분 남음", remaining.Minutes())) continue } // 최대 보유 종목 수 초과 스킵 if posCount >= rule.MaxPositions { s.addLog("debug", code, fmt.Sprintf("스킵: 최대 포지션 초과 (%d/%d)", posCount, rule.MaxPositions)) continue } // 진입 조건 체크 if sig.RiseScore < rule.MinRiseScore { s.addLog("debug", code, fmt.Sprintf("스킵: RiseScore 미달 (%d < %d)", sig.RiseScore, rule.MinRiseScore)) continue } if sig.CntrStr < rule.MinCntrStr { s.addLog("debug", code, fmt.Sprintf("스킵: 체결강도 미달 (%.1f < %.1f)", sig.CntrStr, rule.MinCntrStr)) continue } if rule.RequireBullish && sig.Sentiment != "호재" { s.addLog("debug", code, fmt.Sprintf("스킵: AI 호재 없음 (%s)", sentimentStr)) continue } // 주문가능금액 확인 curPriceStr := strconv.FormatInt(sig.CurrentPrice, 10) orderable, err := s.accountSvc.GetOrderable(code, curPriceStr, "2") if err != nil { s.addLog("warn", code, fmt.Sprintf("주문가능금액 조회 실패: %v", err)) continue } avail, _ := strconv.ParseInt(orderable.OrdAlowa, 10, 64) if avail < rule.OrderAmount { s.addLog("warn", code, fmt.Sprintf("주문가능금액 부족: 필요 %d원, 가용 %d원", rule.OrderAmount, avail)) continue } // 주문 수량 계산 if sig.CurrentPrice <= 0 { continue } qty := rule.OrderAmount / sig.CurrentPrice if qty <= 0 { s.addLog("warn", code, fmt.Sprintf("주문수량 계산 오류: 주문금액 %d원, 현재가 %d원", rule.OrderAmount, sig.CurrentPrice)) continue } // 매수 주문 실행 (시장가) result, err := s.orderSvc.Buy(OrderRequest{ Exchange: "KRX", Code: code, Qty: strconv.FormatInt(qty, 10), Price: "", TradeTP: "3", // 시장가 }) if err != nil { s.addLog("error", code, fmt.Sprintf("매수주문 실패: %v", err)) continue } // 포지션 등록 pos := &models.AutoTradePosition{ Code: code, Name: sig.Name, Qty: qty, OrderNo: result.OrderNo, EntryTime: time.Now(), RuleID: rule.ID, Status: "pending", } s.mu.Lock() s.positions[code] = pos s.cooldown[code] = time.Now() s.mu.Unlock() s.addLog("info", code, fmt.Sprintf("매수 주문 접수: %s %d주 (주문번호: %s, RiseScore: %d)", sig.Name, qty, result.OrderNo, sig.RiseScore)) } } } // checkExits 청산 조건 체크 func (s *AutoTradeService) checkExits() { s.mu.RLock() codes := make([]string, 0, len(s.positions)) for code, p := range s.positions { if p.Status == "open" { codes = append(codes, code) } } s.mu.RUnlock() now := time.Now() // 장 마감 전 청산 기준: KST 15:20 loc, _ := time.LoadLocation("Asia/Seoul") kstNow := now.In(loc) closeTime := time.Date(kstNow.Year(), kstNow.Month(), kstNow.Day(), 15, 20, 0, 0, loc) for _, code := range codes { s.mu.RLock() pos, ok := s.positions[code] if !ok || pos.Status != "open" { s.mu.RUnlock() continue } // 포지션 복사 posCopy := *pos s.mu.RUnlock() // 현재가 조회 price, err := s.stockSvc.GetCurrentPrice(code) if err != nil { log.Printf("[자동매매] 현재가 조회 실패 [%s]: %v", code, err) continue } curPrice := price.CurrentPrice // 포지션 모니터링 debug 로그 if posCopy.BuyPrice > 0 { pl := (float64(curPrice) - float64(posCopy.BuyPrice)) / float64(posCopy.BuyPrice) * 100 s.addLog("debug", code, fmt.Sprintf("모니터링 [%s] 현재가=%s원 손익=%+.2f%% (손절=%s 익절=%s)", posCopy.Name, formatComma(curPrice), pl, formatComma(posCopy.StopLoss), formatComma(posCopy.TakeProfit))) } var reason string switch { case curPrice <= posCopy.StopLoss: reason = "손절" case curPrice >= posCopy.TakeProfit: reason = "익절" case exitBeforeCloseRule(s.rules, &posCopy) && kstNow.After(closeTime): reason = "장마감" case maxHoldExpired(s.rules, &posCopy, now): reason = "시간초과" } if reason != "" { if err := s.executeSell(&posCopy, reason); err != nil { s.addLog("error", code, fmt.Sprintf("%s 매도 실패: %v", reason, err)) } } } } // checkPending pending 포지션 체결 확인 func (s *AutoTradeService) checkPending() { s.mu.RLock() pending := make([]*models.AutoTradePosition, 0) for _, p := range s.positions { if p.Status == "pending" { cp := *p pending = append(pending, &cp) } } s.mu.RUnlock() if len(pending) == 0 { return } balance, err := s.accountSvc.GetBalance() if err != nil { log.Printf("[자동매매] 잔고 조회 실패: %v", err) return } for _, pos := range pending { for _, stock := range balance.Stocks { if stock.StkCd == pos.Code { buyPrice, _ := strconv.ParseInt(stock.PurPric, 10, 64) qty, _ := strconv.ParseInt(stock.RmndQty, 10, 64) if qty <= 0 { continue } // 청산가 계산 rule := s.findRule(pos.RuleID) var stopLoss, takeProfit int64 if rule != nil && buyPrice > 0 { stopLoss = int64(float64(buyPrice) * (1 + rule.StopLossPct/100)) takeProfit = int64(float64(buyPrice) * (1 + rule.TakeProfitPct/100)) } s.mu.Lock() if p, ok := s.positions[pos.Code]; ok && p.Status == "pending" { p.BuyPrice = buyPrice p.Qty = qty p.StopLoss = stopLoss p.TakeProfit = takeProfit p.Status = "open" // ExitBeforeClose 규칙 값 저장 if rule != nil { // 포지션 자체에 규칙 정보가 없으므로 로그만 기록 } } s.mu.Unlock() s.addLog("info", pos.Code, fmt.Sprintf("매수 체결 확인: %s %d주 @ %d원 (손절: %d, 익절: %d)", pos.Name, qty, buyPrice, stopLoss, takeProfit)) break } } } } // executeSell 매도 주문 실행 및 포지션 업데이트 func (s *AutoTradeService) executeSell(pos *models.AutoTradePosition, reason string) error { if pos.Qty <= 0 { return fmt.Errorf("매도 수량 없음") } result, err := s.orderSvc.Sell(OrderRequest{ Exchange: "KRX", Code: pos.Code, Qty: strconv.FormatInt(pos.Qty, 10), Price: "", TradeTP: "3", // 시장가 }) if err != nil { return err } // 현재가 조회 (청산가 근사치) exitPrice := pos.BuyPrice if p, err := s.stockSvc.GetCurrentPrice(pos.Code); err == nil { exitPrice = p.CurrentPrice } s.mu.Lock() if p, ok := s.positions[pos.Code]; ok { p.Status = "closed" p.ExitTime = time.Now() p.ExitPrice = exitPrice p.ExitReason = reason } s.mu.Unlock() pl := (exitPrice - pos.BuyPrice) * pos.Qty s.addLog("info", pos.Code, fmt.Sprintf("%s 매도 완료: %s @ %d원 (손익: %+d원, 주문번호: %s)", reason, pos.Name, exitPrice, pl, result.OrderNo)) return nil } // countActivePositions pending+open 포지션 수 func (s *AutoTradeService) countActivePositions() int { count := 0 for _, p := range s.positions { if p.Status == "pending" || p.Status == "open" { count++ } } return count } // findRule ID로 규칙 조회 func (s *AutoTradeService) findRule(id string) *models.AutoTradeRule { for _, r := range s.rules { if r.ID == id { cp := r return &cp } } return nil } // addLog 로그 추가 (최대 maxLogEntries 유지) + WS 브로드캐스트 func (s *AutoTradeService) addLog(level, code, message string) { entry := models.AutoTradeLog{ At: time.Now(), Level: level, Code: code, Message: message, } log.Printf("[자동매매][%s] %s %s", level, code, message) s.mu.Lock() s.logs = append([]models.AutoTradeLog{entry}, s.logs...) if len(s.logs) > maxLogEntries { s.logs = s.logs[:maxLogEntries] } broadcaster := s.logBroadcaster s.mu.Unlock() // WS 브로드캐스트 (락 밖에서 호출해 데드락 방지) if broadcaster != nil { broadcaster(entry) } } // formatComma 천 단위 콤마 포맷 (서비스 내부용) func formatComma(n int64) string { if n == 0 { return "0" } negative := n < 0 if negative { n = -n } s := strconv.FormatInt(n, 10) result := make([]byte, 0, len(s)+len(s)/3) for i, c := range s { if i > 0 && (len(s)-i)%3 == 0 { result = append(result, ',') } result = append(result, byte(c)) } if negative { return "-" + string(result) } return string(result) } // --- 포지션 헬퍼 함수 (규칙 정보 조회용) --- // exitBeforeCloseRule 포지션이 속한 규칙의 ExitBeforeClose 반환 func exitBeforeCloseRule(rules []models.AutoTradeRule, p *models.AutoTradePosition) bool { for _, r := range rules { if r.ID == p.RuleID { return r.ExitBeforeClose } } return false } // maxHoldExpired 최대 보유 시간 초과 여부 반환 func maxHoldExpired(rules []models.AutoTradeRule, p *models.AutoTradePosition, now time.Time) bool { for _, r := range rules { if r.ID == p.RuleID { if r.MaxHoldMinutes <= 0 { return false } return now.Sub(p.EntryTime) >= time.Duration(r.MaxHoldMinutes)*time.Minute } } return false }