프론트엔드 추가 및 자동매매 로직 개선:
Some checks failed
Build Push and Restart Compose / deploy (push) Failing after 1m42s

- Svelte 기반 프론트엔드 프로젝트 초기 설정 추가 (`vite`, `tailwindcss` 등 포함).
- "자동매매" 주요 상태 및 규칙 관리 페이지 구현.
- 1차/2차 손절 및 익절 조건 평가 로직 추가(`calcStopTargets`, `evalExitReason` 등).
- 포지션 상세 로그 및 WebSocket 기반 실시간 로그 스트림 추가.
- API 서비스 및 Frontend 간 Proxy 설정(Vite 서버).
- 세션 체크를 위한 `CheckSession` 핸들러 추가.
This commit is contained in:
hayato5246
2026-04-05 20:30:52 +09:00
parent f10a1ede3b
commit 00ffc6b54c
58 changed files with 6425 additions and 104 deletions

View File

@@ -380,11 +380,11 @@ func (s *AutoTradeService) exitLoop() {
// checkEntries 진입 조건 체크 및 매수 주문
func (s *AutoTradeService) checkEntries() {
signals := s.getWatchSignals()
// 규칙 및 현재 포지션 수 선조회
s.mu.RLock()
rules := make([]models.AutoTradeRule, len(s.rules))
copy(rules, s.rules)
posCount := s.countActivePositions()
s.mu.RUnlock()
// 활성 규칙 수 계산
@@ -395,6 +395,21 @@ func (s *AutoTradeService) checkEntries() {
}
}
// 모든 활성 규칙이 최대 포지션에 도달했으면 신호 조회 생략 → 보유 종목 감시만 진행
hasRoom := false
for _, r := range rules {
if r.Enabled && posCount < r.MaxPositions {
hasRoom = true
break
}
}
if !hasRoom && activeRules > 0 {
s.addLog("debug", "", fmt.Sprintf("진입 스캔 생략: 최대 포지션 도달 (%d개 보유 중, 청산 감시만 진행)", posCount))
return
}
signals := s.getWatchSignals()
s.addLog("debug", "", fmt.Sprintf("진입 스캔: 신호 %d개, 활성규칙 %d개", len(signals), activeRules))
if len(signals) == 0 {
@@ -497,13 +512,13 @@ func (s *AutoTradeService) checkEntries() {
}
// 포지션 등록 (현재가 기준 예상 손절/익절가 미리 계산 → UI에 0원 방지)
estStop := int64(float64(sig.CurrentPrice) * (1 + rule.StopLossPct/100))
estProfit := int64(float64(sig.CurrentPrice) * (1 + rule.TakeProfitPct/100))
estStop1, estStop, estProfit := calcStopTargets(sig.CurrentPrice, &rule)
pos := &models.AutoTradePosition{
Code: code,
Name: sig.Name,
Qty: qty,
BuyPrice: sig.CurrentPrice,
StopLoss1: estStop1,
StopLoss: estStop,
TakeProfit: estProfit,
OrderNo: result.OrderNo,
@@ -561,21 +576,13 @@ func (s *AutoTradeService) checkExits() {
// 포지션 모니터링 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)))
s.addLog("debug", code, fmt.Sprintf("모니터링 [%s] 현재가=%s원 손익=%+.2f%% (1차손절=%s[%d/%d회] 2차손절=%s 익절=%s)",
posCopy.Name, formatComma(curPrice), pl,
formatComma(posCopy.StopLoss1), posCopy.StopLoss1Touches, s.stopLoss1Limit(posCopy.RuleID),
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 = "시간초과"
}
reason := s.evalExitReason(code, &posCopy, curPrice, kstNow, closeTime, now)
if reason != "" {
if err := s.executeSell(&posCopy, reason); err != nil {
@@ -629,23 +636,27 @@ func (s *AutoTradeService) checkPending() {
}
rule := s.findRule(pos.RuleID)
var stopLoss, takeProfit int64
var stopLoss1, stopLoss, takeProfit int64
if rule != nil && buyPrice > 0 {
stopLoss = int64(float64(buyPrice) * (1 + rule.StopLossPct/100))
takeProfit = int64(float64(buyPrice) * (1 + rule.TakeProfitPct/100))
stopLoss1, stopLoss, takeProfit = calcStopTargets(buyPrice, rule)
}
s.mu.Lock()
if p, ok := s.positions[pos.Code]; ok && p.Status == "pending" {
p.BuyPrice = buyPrice
p.StopLoss1 = stopLoss1
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))
sl1Count := 0
if rule != nil {
sl1Count = rule.StopLoss1Count
}
s.addLog("info", pos.Code, fmt.Sprintf("매수 체결 확인: %s %d주 @ %d원 (1차손절: %d[%d회], 2차손절: %d, 익절: %d)",
pos.Name, pos.Qty, buyPrice, stopLoss1, sl1Count, stopLoss, takeProfit))
}
}
@@ -706,6 +717,20 @@ func (s *AutoTradeService) executeSell(pos *models.AutoTradePosition, reason str
return nil
}
// ClosePosition 개별 포지션 수동 청산
func (s *AutoTradeService) ClosePosition(code string) error {
s.mu.RLock()
pos, ok := s.positions[code]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("포지션을 찾을 수 없습니다: %s", code)
}
if pos.Status != "open" && pos.Status != "pending" {
return fmt.Errorf("이미 청산된 포지션입니다: %s", code)
}
return s.executeSell(pos, "수동청산")
}
// countActivePositions pending+open 포지션 수
func (s *AutoTradeService) countActivePositions() int {
count := 0
@@ -799,3 +824,80 @@ func maxHoldExpired(rules []models.AutoTradeRule, p *models.AutoTradePosition, n
}
return false
}
// calcStopTargets 매수가 기준으로 1차 손절가 / 2차 손절가 / 익절가 계산
func calcStopTargets(buyPrice int64, rule *models.AutoTradeRule) (stopLoss1, stopLoss, takeProfit int64) {
if rule == nil || buyPrice <= 0 {
return 0, 0, 0
}
if rule.StopLoss1Count > 0 && rule.StopLoss1Pct != 0 {
stopLoss1 = int64(float64(buyPrice) * (1 + rule.StopLoss1Pct/100))
}
stopLoss = int64(float64(buyPrice) * (1 + rule.StopLossPct/100))
takeProfit = int64(float64(buyPrice) * (1 + rule.TakeProfitPct/100))
return
}
// stopLoss1Limit 규칙의 1차 손절 터치 임계 반환 (규칙 없으면 0)
func (s *AutoTradeService) stopLoss1Limit(ruleID string) int {
s.mu.RLock()
defer s.mu.RUnlock()
for _, r := range s.rules {
if r.ID == ruleID {
return r.StopLoss1Count
}
}
return 0
}
// evalExitReason 청산 조건 평가 — 2중 손절 포함
// 2차 손절(즉시) → 1차 손절(터치 카운트) → 익절 → 장마감 → 시간초과 순으로 체크
func (s *AutoTradeService) evalExitReason(code string, pos *models.AutoTradePosition, curPrice int64,
kstNow time.Time, closeTime time.Time, now time.Time) string {
// 2차 손절: 즉시 매도
if curPrice <= pos.StopLoss {
return "2차손절"
}
// 1차 손절: X회 터치 시 매도
if pos.StopLoss1 > 0 && curPrice <= pos.StopLoss1 {
rule := s.findRule(pos.RuleID)
limit := 0
if rule != nil {
limit = rule.StopLoss1Count
}
s.mu.Lock()
var touches int
if p, ok := s.positions[code]; ok && p.Status == "open" {
p.StopLoss1Touches++
touches = p.StopLoss1Touches
}
s.mu.Unlock()
if limit > 0 && touches >= limit {
return "1차손절"
}
s.addLog("warn", code, fmt.Sprintf("1차손절 터치 %d/%d회 (현재가: %s원, 1차손절가: %s원)",
touches, limit, formatComma(curPrice), formatComma(pos.StopLoss1)))
return ""
}
// 익절
if curPrice >= pos.TakeProfit {
return "익절"
}
// 장마감 전 청산
s.mu.RLock()
rules := s.rules
s.mu.RUnlock()
if exitBeforeCloseRule(rules, pos) && kstNow.After(closeTime) {
return "장마감"
}
if maxHoldExpired(rules, pos, now) {
return "시간초과"
}
return ""
}