first commit
This commit is contained in:
772
services/autotrade_service.go
Normal file
772
services/autotrade_service.go
Normal file
@@ -0,0 +1,772 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user