Files
stocksearch/services/autotrade_service.go
2026-04-01 20:50:16 +09:00

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
}