diff --git a/Jenkinsfile b/Jenkinsfile index bd3ccab..f271307 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -11,12 +11,10 @@ pipeline { DASH_API_IMAGE = "${REGISTRY}/gihyeon/cointrader-dashboard-api" DASH_UI_IMAGE = "${REGISTRY}/gihyeon/cointrader-dashboard-ui" - // 젠킨스 자격 증명에 저장해둔 디스코드 웹훅 주소를 불러옵니다. DISCORD_WEBHOOK = credentials('discord-webhook') } stages { - // 빌드가 시작되자마자 알림을 보냅니다. stage('Notify Build Start') { steps { sh """ @@ -36,11 +34,55 @@ pipeline { } } - stage('Build Docker Images') { + stage('Detect Changes') { steps { - sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ." - sh "docker build -t ${DASH_API_IMAGE}:${IMAGE_TAG} -t ${DASH_API_IMAGE}:latest ./dashboard/api" - sh "docker build -t ${DASH_UI_IMAGE}:${IMAGE_TAG} -t ${DASH_UI_IMAGE}:latest ./dashboard/ui" + script { + def changes = sh(script: 'git diff --name-only HEAD~1 || echo "ALL"', returnStdout: true).trim() + echo "Changed files:\n${changes}" + + if (changes == 'ALL') { + // 첫 빌드이거나 diff 실패 시 전체 빌드 + env.BOT_CHANGED = 'true' + env.DASH_API_CHANGED = 'true' + env.DASH_UI_CHANGED = 'true' + } else { + env.BOT_CHANGED = (changes =~ /(?m)^(src\/|main\.py|requirements\.txt|Dockerfile)/).find() ? 'true' : 'false' + env.DASH_API_CHANGED = (changes =~ /(?m)^dashboard\/api\//).find() ? 'true' : 'false' + env.DASH_UI_CHANGED = (changes =~ /(?m)^dashboard\/ui\//).find() ? 'true' : 'false' + } + + // docker-compose.yml 변경 시에도 배포 필요 + if (changes.contains('docker-compose.yml') || changes.contains('Jenkinsfile')) { + env.COMPOSE_CHANGED = 'true' + } else { + env.COMPOSE_CHANGED = 'false' + } + + echo "BOT_CHANGED=${env.BOT_CHANGED}, DASH_API_CHANGED=${env.DASH_API_CHANGED}, DASH_UI_CHANGED=${env.DASH_UI_CHANGED}, COMPOSE_CHANGED=${env.COMPOSE_CHANGED}" + } + } + } + + stage('Build Docker Images') { + parallel { + stage('Bot') { + when { expression { env.BOT_CHANGED == 'true' } } + steps { + sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ." + } + } + stage('Dashboard API') { + when { expression { env.DASH_API_CHANGED == 'true' } } + steps { + sh "docker build -t ${DASH_API_IMAGE}:${IMAGE_TAG} -t ${DASH_API_IMAGE}:latest ./dashboard/api" + } + } + stage('Dashboard UI') { + when { expression { env.DASH_UI_CHANGED == 'true' } } + steps { + sh "docker build -t ${DASH_UI_IMAGE}:${IMAGE_TAG} -t ${DASH_UI_IMAGE}:latest ./dashboard/ui" + } + } } } @@ -48,49 +90,77 @@ pipeline { steps { withCredentials([usernamePassword(credentialsId: 'gitea-registry-cred', passwordVariable: 'GITEA_TOKEN', usernameVariable: 'GITEA_USER')]) { sh "echo \$GITEA_TOKEN | docker login ${REGISTRY} -u \$GITEA_USER --password-stdin" - sh "docker push ${FULL_IMAGE}" - sh "docker push ${LATEST_IMAGE}" - sh "docker push ${DASH_API_IMAGE}:${IMAGE_TAG}" - sh "docker push ${DASH_API_IMAGE}:latest" - sh "docker push ${DASH_UI_IMAGE}:${IMAGE_TAG}" - sh "docker push ${DASH_UI_IMAGE}:latest" + script { + if (env.BOT_CHANGED == 'true') { + sh "docker push ${FULL_IMAGE}" + sh "docker push ${LATEST_IMAGE}" + } + if (env.DASH_API_CHANGED == 'true') { + sh "docker push ${DASH_API_IMAGE}:${IMAGE_TAG}" + sh "docker push ${DASH_API_IMAGE}:latest" + } + if (env.DASH_UI_CHANGED == 'true') { + sh "docker push ${DASH_UI_IMAGE}:${IMAGE_TAG}" + sh "docker push ${DASH_UI_IMAGE}:latest" + } + } } } } stage('Deploy to Prod LXC') { steps { - sh 'ssh root@10.1.10.24 "mkdir -p /root/cointrader"' - sh 'scp docker-compose.yml root@10.1.10.24:/root/cointrader/' - sh ''' - ssh root@10.1.10.24 "cd /root/cointrader/ && \ - docker compose down && \ - docker compose pull && \ - docker compose up -d" - ''' + script { + // docker-compose.yml이 변경되었으면 항상 전송 + if (env.COMPOSE_CHANGED == 'true') { + sh 'ssh root@10.1.10.24 "mkdir -p /root/cointrader"' + sh 'scp docker-compose.yml root@10.1.10.24:/root/cointrader/' + } + + // 변경된 서비스만 pull & recreate (나머지는 중단 없음) + def services = [] + if (env.BOT_CHANGED == 'true') services.add('cointrader') + if (env.DASH_API_CHANGED == 'true') services.add('dashboard-api') + if (env.DASH_UI_CHANGED == 'true') services.add('dashboard-ui') + + if (env.COMPOSE_CHANGED == 'true' && services.isEmpty()) { + // compose만 변경된 경우 전체 재시작 + sh 'ssh root@10.1.10.24 "cd /root/cointrader/ && docker compose up -d"' + } else if (!services.isEmpty()) { + def svcList = services.join(' ') + sh "ssh root@10.1.10.24 \"cd /root/cointrader/ && docker compose pull ${svcList} && docker compose up -d ${svcList}\"" + } + } } } stage('Cleanup') { steps { - sh "docker rmi ${FULL_IMAGE} || true" - sh "docker rmi ${LATEST_IMAGE} || true" - sh "docker rmi ${DASH_API_IMAGE}:${IMAGE_TAG} || true" - sh "docker rmi ${DASH_API_IMAGE}:latest || true" - sh "docker rmi ${DASH_UI_IMAGE}:${IMAGE_TAG} || true" - sh "docker rmi ${DASH_UI_IMAGE}:latest || true" + script { + if (env.BOT_CHANGED == 'true') { + sh "docker rmi ${FULL_IMAGE} || true" + sh "docker rmi ${LATEST_IMAGE} || true" + } + if (env.DASH_API_CHANGED == 'true') { + sh "docker rmi ${DASH_API_IMAGE}:${IMAGE_TAG} || true" + sh "docker rmi ${DASH_API_IMAGE}:latest || true" + } + if (env.DASH_UI_CHANGED == 'true') { + sh "docker rmi ${DASH_UI_IMAGE}:${IMAGE_TAG} || true" + sh "docker rmi ${DASH_UI_IMAGE}:latest || true" + } + } } } } - // 파이프라인 결과에 따른 디스코드 알림 post { success { - echo "Build #${env.BUILD_NUMBER} 성공: ${FULL_IMAGE} → 운영 LXC(10.1.10.24) 배포 완료" + echo "Build #${env.BUILD_NUMBER} 성공" sh """ curl -H "Content-Type: application/json" \ -X POST \ - -d '{"content": "✅ **[배포 성공]** `cointrader` (Build #${env.BUILD_NUMBER}) 운영 서버(10.1.10.24) 배포 완료!\\n- 📦 이미지: `${FULL_IMAGE}`"}' \ + -d '{"content": "✅ **[배포 성공]** `cointrader` (Build #${env.BUILD_NUMBER}) 운영 서버(10.1.10.24) 배포 완료!\\n- 🤖 봇: ${env.BOT_CHANGED}\\n- 📊 API: ${env.DASH_API_CHANGED}\\n- 🖥️ UI: ${env.DASH_UI_CHANGED}"}' \ ${DISCORD_WEBHOOK} """ } @@ -104,4 +174,4 @@ pipeline { """ } } -} \ No newline at end of file +} diff --git a/dashboard/api/log_parser.py b/dashboard/api/log_parser.py index c2c84a9..577dd5b 100644 --- a/dashboard/api/log_parser.py +++ b/dashboard/api/log_parser.py @@ -385,22 +385,20 @@ class LogParser: if leverage is None: leverage = self._bot_config.get("leverage", 10) - # 메모리 내 중복 체크 - if self._current_position: - if abs(self._current_position["entry_price"] - entry_price) < 0.0001: - return + # 중복 체크 — 같은 방향의 OPEN 포지션이 이미 있으면 스킵 + # (봇은 동시에 같은 방향 포지션을 2개 이상 열지 않음) + if self._current_position and self._current_position.get("direction") == direction: + return - # DB 내 중복 체크 — 같은 방향·가격의 OPEN 포지션이 이미 있으면 스킵 existing = self.conn.execute( - "SELECT id FROM trades WHERE status='OPEN' AND direction=? " - "AND ABS(entry_price - ?) < 0.0001", - (direction, entry_price), + "SELECT id, entry_price FROM trades WHERE status='OPEN' AND direction=?", + (direction,), ).fetchone() if existing: self._current_position = { "id": existing["id"], "direction": direction, - "entry_price": entry_price, + "entry_price": existing["entry_price"], "entry_time": ts, } return @@ -427,18 +425,17 @@ class LogParser: # ── 포지션 청산 핸들러 ─────────────────────────────────────── def _handle_close(self, ts, exit_price, expected_pnl, commission, net_pnl, reason): - if not self._current_position: - # 열린 포지션 없으면 DB에서 찾기 - row = self.conn.execute( - "SELECT id, entry_price, direction FROM trades WHERE status='OPEN' ORDER BY id DESC LIMIT 1" - ).fetchone() - if row: - self._current_position = dict(row) - else: - print(f"[LogParser] 경고: 청산 감지했으나 열린 포지션 없음") - return + # 모든 OPEN 거래를 닫음 (봇은 동시에 1개 포지션만 보유) + open_trades = self.conn.execute( + "SELECT id FROM trades WHERE status='OPEN' ORDER BY id DESC" + ).fetchall() - trade_id = self._current_position["id"] + if not open_trades: + print(f"[LogParser] 경고: 청산 감지했으나 열린 포지션 없음") + return + + # 가장 최근 OPEN에 실제 PnL 기록 + primary_id = open_trades[0]["id"] self.conn.execute( """UPDATE trades SET exit_time=?, exit_price=?, expected_pnl=?, @@ -447,9 +444,18 @@ class LogParser: WHERE id=?""", (ts, exit_price, expected_pnl, expected_pnl, commission, net_pnl, - reason, trade_id) + reason, primary_id) ) + # 나머지 OPEN 거래는 중복이므로 삭제 + if len(open_trades) > 1: + stale_ids = [r["id"] for r in open_trades[1:]] + self.conn.execute( + f"DELETE FROM trades WHERE id IN ({','.join('?' * len(stale_ids))})", + stale_ids, + ) + print(f"[LogParser] 중복 OPEN 거래 {len(stale_ids)}건 삭제") + # 일별 요약 갱신 day = ts[:10] win = 1 if net_pnl > 0 else 0