fix: log parser duplicate position bug and selective CI/CD deployment
- Log parser: fix entry dedup to check direction instead of price tolerance, preventing duplicate OPEN trades for the same position - Log parser: close all OPEN trades on position close, delete stale duplicates - Jenkinsfile: detect changed files and only build/deploy affected services, allowing dashboard-only changes without restarting the bot Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
128
Jenkinsfile
vendored
128
Jenkinsfile
vendored
@@ -11,12 +11,10 @@ pipeline {
|
|||||||
DASH_API_IMAGE = "${REGISTRY}/gihyeon/cointrader-dashboard-api"
|
DASH_API_IMAGE = "${REGISTRY}/gihyeon/cointrader-dashboard-api"
|
||||||
DASH_UI_IMAGE = "${REGISTRY}/gihyeon/cointrader-dashboard-ui"
|
DASH_UI_IMAGE = "${REGISTRY}/gihyeon/cointrader-dashboard-ui"
|
||||||
|
|
||||||
// 젠킨스 자격 증명에 저장해둔 디스코드 웹훅 주소를 불러옵니다.
|
|
||||||
DISCORD_WEBHOOK = credentials('discord-webhook')
|
DISCORD_WEBHOOK = credentials('discord-webhook')
|
||||||
}
|
}
|
||||||
|
|
||||||
stages {
|
stages {
|
||||||
// 빌드가 시작되자마자 알림을 보냅니다.
|
|
||||||
stage('Notify Build Start') {
|
stage('Notify Build Start') {
|
||||||
steps {
|
steps {
|
||||||
sh """
|
sh """
|
||||||
@@ -36,11 +34,55 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Build Docker Images') {
|
stage('Detect Changes') {
|
||||||
steps {
|
steps {
|
||||||
sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ."
|
script {
|
||||||
sh "docker build -t ${DASH_API_IMAGE}:${IMAGE_TAG} -t ${DASH_API_IMAGE}:latest ./dashboard/api"
|
def changes = sh(script: 'git diff --name-only HEAD~1 || echo "ALL"', returnStdout: true).trim()
|
||||||
sh "docker build -t ${DASH_UI_IMAGE}:${IMAGE_TAG} -t ${DASH_UI_IMAGE}:latest ./dashboard/ui"
|
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 {
|
steps {
|
||||||
withCredentials([usernamePassword(credentialsId: 'gitea-registry-cred', passwordVariable: 'GITEA_TOKEN', usernameVariable: 'GITEA_USER')]) {
|
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 "echo \$GITEA_TOKEN | docker login ${REGISTRY} -u \$GITEA_USER --password-stdin"
|
||||||
sh "docker push ${FULL_IMAGE}"
|
script {
|
||||||
sh "docker push ${LATEST_IMAGE}"
|
if (env.BOT_CHANGED == 'true') {
|
||||||
sh "docker push ${DASH_API_IMAGE}:${IMAGE_TAG}"
|
sh "docker push ${FULL_IMAGE}"
|
||||||
sh "docker push ${DASH_API_IMAGE}:latest"
|
sh "docker push ${LATEST_IMAGE}"
|
||||||
sh "docker push ${DASH_UI_IMAGE}:${IMAGE_TAG}"
|
}
|
||||||
sh "docker push ${DASH_UI_IMAGE}:latest"
|
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') {
|
stage('Deploy to Prod LXC') {
|
||||||
steps {
|
steps {
|
||||||
sh 'ssh root@10.1.10.24 "mkdir -p /root/cointrader"'
|
script {
|
||||||
sh 'scp docker-compose.yml root@10.1.10.24:/root/cointrader/'
|
// docker-compose.yml이 변경되었으면 항상 전송
|
||||||
sh '''
|
if (env.COMPOSE_CHANGED == 'true') {
|
||||||
ssh root@10.1.10.24 "cd /root/cointrader/ && \
|
sh 'ssh root@10.1.10.24 "mkdir -p /root/cointrader"'
|
||||||
docker compose down && \
|
sh 'scp docker-compose.yml root@10.1.10.24:/root/cointrader/'
|
||||||
docker compose pull && \
|
}
|
||||||
docker compose up -d"
|
|
||||||
'''
|
// 변경된 서비스만 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') {
|
stage('Cleanup') {
|
||||||
steps {
|
steps {
|
||||||
sh "docker rmi ${FULL_IMAGE} || true"
|
script {
|
||||||
sh "docker rmi ${LATEST_IMAGE} || true"
|
if (env.BOT_CHANGED == 'true') {
|
||||||
sh "docker rmi ${DASH_API_IMAGE}:${IMAGE_TAG} || true"
|
sh "docker rmi ${FULL_IMAGE} || true"
|
||||||
sh "docker rmi ${DASH_API_IMAGE}:latest || true"
|
sh "docker rmi ${LATEST_IMAGE} || true"
|
||||||
sh "docker rmi ${DASH_UI_IMAGE}:${IMAGE_TAG} || true"
|
}
|
||||||
sh "docker rmi ${DASH_UI_IMAGE}:latest || 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 {
|
post {
|
||||||
success {
|
success {
|
||||||
echo "Build #${env.BUILD_NUMBER} 성공: ${FULL_IMAGE} → 운영 LXC(10.1.10.24) 배포 완료"
|
echo "Build #${env.BUILD_NUMBER} 성공"
|
||||||
sh """
|
sh """
|
||||||
curl -H "Content-Type: application/json" \
|
curl -H "Content-Type: application/json" \
|
||||||
-X POST \
|
-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}
|
${DISCORD_WEBHOOK}
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,22 +385,20 @@ class LogParser:
|
|||||||
if leverage is None:
|
if leverage is None:
|
||||||
leverage = self._bot_config.get("leverage", 10)
|
leverage = self._bot_config.get("leverage", 10)
|
||||||
|
|
||||||
# 메모리 내 중복 체크
|
# 중복 체크 — 같은 방향의 OPEN 포지션이 이미 있으면 스킵
|
||||||
if self._current_position:
|
# (봇은 동시에 같은 방향 포지션을 2개 이상 열지 않음)
|
||||||
if abs(self._current_position["entry_price"] - entry_price) < 0.0001:
|
if self._current_position and self._current_position.get("direction") == direction:
|
||||||
return
|
return
|
||||||
|
|
||||||
# DB 내 중복 체크 — 같은 방향·가격의 OPEN 포지션이 이미 있으면 스킵
|
|
||||||
existing = self.conn.execute(
|
existing = self.conn.execute(
|
||||||
"SELECT id FROM trades WHERE status='OPEN' AND direction=? "
|
"SELECT id, entry_price FROM trades WHERE status='OPEN' AND direction=?",
|
||||||
"AND ABS(entry_price - ?) < 0.0001",
|
(direction,),
|
||||||
(direction, entry_price),
|
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
self._current_position = {
|
self._current_position = {
|
||||||
"id": existing["id"],
|
"id": existing["id"],
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
"entry_price": entry_price,
|
"entry_price": existing["entry_price"],
|
||||||
"entry_time": ts,
|
"entry_time": ts,
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -427,18 +425,17 @@ class LogParser:
|
|||||||
|
|
||||||
# ── 포지션 청산 핸들러 ───────────────────────────────────────
|
# ── 포지션 청산 핸들러 ───────────────────────────────────────
|
||||||
def _handle_close(self, ts, exit_price, expected_pnl, commission, net_pnl, reason):
|
def _handle_close(self, ts, exit_price, expected_pnl, commission, net_pnl, reason):
|
||||||
if not self._current_position:
|
# 모든 OPEN 거래를 닫음 (봇은 동시에 1개 포지션만 보유)
|
||||||
# 열린 포지션 없으면 DB에서 찾기
|
open_trades = self.conn.execute(
|
||||||
row = self.conn.execute(
|
"SELECT id FROM trades WHERE status='OPEN' ORDER BY id DESC"
|
||||||
"SELECT id, entry_price, direction FROM trades WHERE status='OPEN' ORDER BY id DESC LIMIT 1"
|
).fetchall()
|
||||||
).fetchone()
|
|
||||||
if row:
|
|
||||||
self._current_position = dict(row)
|
|
||||||
else:
|
|
||||||
print(f"[LogParser] 경고: 청산 감지했으나 열린 포지션 없음")
|
|
||||||
return
|
|
||||||
|
|
||||||
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(
|
self.conn.execute(
|
||||||
"""UPDATE trades SET
|
"""UPDATE trades SET
|
||||||
exit_time=?, exit_price=?, expected_pnl=?,
|
exit_time=?, exit_price=?, expected_pnl=?,
|
||||||
@@ -447,9 +444,18 @@ class LogParser:
|
|||||||
WHERE id=?""",
|
WHERE id=?""",
|
||||||
(ts, exit_price, expected_pnl,
|
(ts, exit_price, expected_pnl,
|
||||||
expected_pnl, commission, net_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]
|
day = ts[:10]
|
||||||
win = 1 if net_pnl > 0 else 0
|
win = 1 if net_pnl > 0 else 0
|
||||||
|
|||||||
Reference in New Issue
Block a user