From 2aa433013bc0bf663615694c2dbdcdd3b02d4444 Mon Sep 17 00:00:00 2001 From: hayato5246 Date: Wed, 1 Apr 2026 20:50:16 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=90=EB=8F=99=ED=99=94=20=EA=B5=AC?= =?UTF-8?q?=EC=B6=95:=20Docker=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EB=A5=BC=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C,=20=ED=91=B8=EC=8B=9C,=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=ED=95=98=EB=8A=94=20GitHub=20Actions=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B3=A0=EB=8F=84=ED=99=94=EB=90=9C=20=EB=A7=A4=EC=88=98=20?= =?UTF-8?q?=EC=B2=B4=EA=B2=B0=20=EB=A1=9C=EC=A7=81=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- .gitea/workflows/workflows.yaml | 35 +++++++++ README.md | 0 services/autotrade_service.go | 127 ++++++++++++++++++++------------ 4 files changed, 115 insertions(+), 50 deletions(-) create mode 100644 .gitea/workflows/workflows.yaml create mode 100644 README.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7c9d373..12ec212 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -39,7 +39,8 @@ "Bash(docker build:*)", "Bash(docker compose:*)", "Bash(tree:*)", - "Bash(go vet:*)" + "Bash(go vet:*)", + "Bash(python3:*)" ] } } diff --git a/.gitea/workflows/workflows.yaml b/.gitea/workflows/workflows.yaml new file mode 100644 index 0000000..0267bfc --- /dev/null +++ b/.gitea/workflows/workflows.yaml @@ -0,0 +1,35 @@ +name: Build Push and Restart Compose + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + + env: + IMAGE_NAME: lshfly-registry.duckdns.org/stocksearch:latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to registry + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login lshfly-registry.duckdns.org -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + + - name: Build image + run: | + docker build -t ${IMAGE_NAME} . + + - name: Push image + run: | + docker push ${IMAGE_NAME} + + - name: Restart compose + run: | + cd /workspace/${{ gitea.repository }} + docker compose pull + docker compose up -d \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/services/autotrade_service.go b/services/autotrade_service.go index 56fe085..933a8cc 100644 --- a/services/autotrade_service.go +++ b/services/autotrade_service.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "strconv" + "strings" "sync" "sync/atomic" "time" @@ -26,7 +27,7 @@ const ( cooldownMinutes = 5 // 동일 종목 재진입 쿨다운(분) exitLoopSec = 5 // 청산 루프 주기(초) entryLoopSec = 10 // 진입 루프 주기(초) - pendingCheckSec = 30 // pending 확인 주기(초) + pendingCheckSec = 5 // pending 확인 주기(초) — 시장가 주문 즉시 체결 대응 ) // AutoTradeService 자동매매 엔진 서비스 @@ -37,14 +38,14 @@ type AutoTradeService struct { 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 브로드캐스트 콜백 + 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 @@ -495,15 +496,20 @@ func (s *AutoTradeService) checkEntries() { 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, - OrderNo: result.OrderNo, - EntryTime: time.Now(), - RuleID: rule.ID, - Status: "pending", + 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() @@ -580,6 +586,7 @@ func (s *AutoTradeService) checkExits() { } // checkPending pending 포지션 체결 확인 +// 미체결 주문 조회(ka10075)에서 OrderNo가 사라지면 체결된 것으로 간주 func (s *AutoTradeService) checkPending() { s.mu.RLock() pending := make([]*models.AutoTradePosition, 0) @@ -595,49 +602,71 @@ func (s *AutoTradeService) checkPending() { return } - balance, err := s.accountSvc.GetBalance() + // 미체결 주문 목록 조회 → 주문번호 집합 구성 + unfilled, err := s.accountSvc.GetPendingOrders() if err != nil { - log.Printf("[자동매매] 잔고 조회 실패: %v", err) + 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 { - 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 - } + orderNo := strings.TrimSpace(pos.OrderNo) + if _, stillPending := unfilledMap[orderNo]; stillPending { + // 아직 미체결 목록에 있음 → 대기 유지 + s.addLog("debug", pos.Code, fmt.Sprintf("체결 대기 중 (주문번호: %s)", orderNo)) + 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)) - } + // 미체결 목록에서 사라짐 → 체결(또는 취소) 완료 + // 잔고에서 실제 매입단가 조회 시도 + buyPrice := s.getBuyPriceFromBalance(pos.Code) + if buyPrice == 0 { + buyPrice = pos.BuyPrice // checkEntries에서 설정한 현재가 예상값 + } - 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" + 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)) + } - // ExitBeforeClose 규칙 값 저장 - if rule != nil { - // 포지션 자체에 규칙 정보가 없으므로 로그만 기록 - } - } - s.mu.Unlock() + 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, qty, buyPrice, stopLoss, takeProfit)) - break - } + 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 매도 주문 실행 및 포지션 업데이트