프론트엔드 추가 및 자동매매 로직 개선:
Some checks failed
Build Push and Restart Compose / deploy (push) Failing after 1m42s
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:
@@ -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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user