802 lines
22 KiB
Go
802 lines
22 KiB
Go
package services
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"fmt"
|
|
"log"
|
|
"strconv"
|
|
"strings"
|
|
"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 = 5 // 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
|
|
}
|
|
|
|
// 포지션 등록 (현재가 기준 예상 손절/익절가 미리 계산 → UI에 0원 방지)
|
|
estStop := int64(float64(sig.CurrentPrice) * (1 + rule.StopLossPct/100))
|
|
estProfit := int64(float64(sig.CurrentPrice) * (1 + rule.TakeProfitPct/100))
|
|
pos := &models.AutoTradePosition{
|
|
Code: code,
|
|
Name: sig.Name,
|
|
Qty: qty,
|
|
BuyPrice: sig.CurrentPrice,
|
|
StopLoss: estStop,
|
|
TakeProfit: estProfit,
|
|
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 포지션 체결 확인
|
|
// 미체결 주문 조회(ka10075)에서 OrderNo가 사라지면 체결된 것으로 간주
|
|
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
|
|
}
|
|
|
|
// 미체결 주문 목록 조회 → 주문번호 집합 구성
|
|
unfilled, err := s.accountSvc.GetPendingOrders()
|
|
if err != nil {
|
|
log.Printf("[자동매매] 미체결 조회 실패: %v", err)
|
|
return
|
|
}
|
|
unfilledMap := make(map[string]struct{}, len(unfilled))
|
|
for _, o := range unfilled {
|
|
unfilledMap[strings.TrimSpace(o.OrdNo)] = struct{}{}
|
|
}
|
|
|
|
for _, pos := range pending {
|
|
orderNo := strings.TrimSpace(pos.OrderNo)
|
|
if _, stillPending := unfilledMap[orderNo]; stillPending {
|
|
// 아직 미체결 목록에 있음 → 대기 유지
|
|
s.addLog("debug", pos.Code, fmt.Sprintf("체결 대기 중 (주문번호: %s)", orderNo))
|
|
continue
|
|
}
|
|
|
|
// 미체결 목록에서 사라짐 → 체결(또는 취소) 완료
|
|
// 잔고에서 실제 매입단가 조회 시도
|
|
buyPrice := s.getBuyPriceFromBalance(pos.Code)
|
|
if buyPrice == 0 {
|
|
buyPrice = pos.BuyPrice // checkEntries에서 설정한 현재가 예상값
|
|
}
|
|
|
|
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.StopLoss = stopLoss
|
|
p.TakeProfit = takeProfit
|
|
p.Status = "open"
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
s.addLog("info", pos.Code, fmt.Sprintf("매수 체결 확인: %s %d주 @ %d원 (손절: %d, 익절: %d)",
|
|
pos.Name, pos.Qty, buyPrice, stopLoss, takeProfit))
|
|
}
|
|
}
|
|
|
|
// getBuyPriceFromBalance 잔고 조회로 종목 매입단가 반환 (없으면 0)
|
|
func (s *AutoTradeService) getBuyPriceFromBalance(code string) int64 {
|
|
balance, err := s.accountSvc.GetBalance()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
for _, stock := range balance.Stocks {
|
|
stkCd := strings.TrimSpace(stock.StkCd)
|
|
// 키움 API 코드 형식 A-prefix 처리 (예: "A005930" → "005930")
|
|
if strings.HasPrefix(stkCd, "A") {
|
|
stkCd = stkCd[1:]
|
|
}
|
|
if stkCd == code {
|
|
price, _ := strconv.ParseInt(strings.TrimSpace(stock.PurPric), 10, 64)
|
|
return price
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// 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
|
|
}
|