feat: add JSONL trade persistence + separate MTF deploy pipeline

- mtf_bot: ExecutionManager saves entry/exit records to
  data/trade_history/mtf_{symbol}.jsonl on every close
- Jenkinsfile: split MTF_CHANGED from BOT_CHANGED so mtf_bot.py-only
  changes restart mtf-bot service without touching cointrader
- docker-compose: mount ./data:/app/data on mtf-bot for persistence

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
21in7
2026-03-30 21:07:05 +09:00
parent b8a371992f
commit e31c4bf080
3 changed files with 69 additions and 19 deletions

19
Jenkinsfile vendored
View File

@@ -47,10 +47,15 @@ pipeline {
if (changes == 'ALL') { if (changes == 'ALL') {
// 첫 빌드이거나 diff 실패 시 전체 빌드 // 첫 빌드이거나 diff 실패 시 전체 빌드
env.BOT_CHANGED = 'true' env.BOT_CHANGED = 'true'
env.MTF_CHANGED = 'true'
env.DASH_API_CHANGED = 'true' env.DASH_API_CHANGED = 'true'
env.DASH_UI_CHANGED = 'true' env.DASH_UI_CHANGED = 'true'
} else { } else {
env.BOT_CHANGED = (changes =~ /(?m)^(src\/|scripts\/|main\.py|requirements\.txt|Dockerfile)/).find() ? 'true' : 'false' // mtf_bot.py 변경 감지 (mtf-bot 서비스만 재시작)
env.MTF_CHANGED = (changes =~ /(?m)^src\/mtf_bot\.py/).find() ? 'true' : 'false'
// src/ 변경 중 mtf_bot.py만 바뀐 경우 메인 봇은 재시작 불필요
def botFiles = changes.split('\n').findAll { it =~ /^(src\/(?!mtf_bot\.py)|scripts\/|main\.py|requirements\.txt|Dockerfile)/ }
env.BOT_CHANGED = botFiles.size() > 0 ? 'true' : 'false'
env.DASH_API_CHANGED = (changes =~ /(?m)^dashboard\/api\//).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' env.DASH_UI_CHANGED = (changes =~ /(?m)^dashboard\/ui\//).find() ? 'true' : 'false'
} }
@@ -62,7 +67,7 @@ pipeline {
env.COMPOSE_CHANGED = 'false' 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}" echo "BOT_CHANGED=${env.BOT_CHANGED}, MTF_CHANGED=${env.MTF_CHANGED}, DASH_API_CHANGED=${env.DASH_API_CHANGED}, DASH_UI_CHANGED=${env.DASH_UI_CHANGED}, COMPOSE_CHANGED=${env.COMPOSE_CHANGED}"
} }
} }
} }
@@ -70,7 +75,7 @@ pipeline {
stage('Build Docker Images') { stage('Build Docker Images') {
parallel { parallel {
stage('Bot') { stage('Bot') {
when { expression { env.BOT_CHANGED == 'true' } } when { expression { env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true' } }
steps { steps {
sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ." sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ."
} }
@@ -95,7 +100,7 @@ pipeline {
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"
script { script {
if (env.BOT_CHANGED == 'true') { if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') {
sh "docker push ${FULL_IMAGE}" sh "docker push ${FULL_IMAGE}"
sh "docker push ${LATEST_IMAGE}" sh "docker push ${LATEST_IMAGE}"
} }
@@ -126,6 +131,8 @@ pipeline {
if (env.BOT_CHANGED == 'true') { if (env.BOT_CHANGED == 'true') {
services.add('cointrader') services.add('cointrader')
services.add('ls-ratio-collector') services.add('ls-ratio-collector')
}
if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') {
services.add('mtf-bot') services.add('mtf-bot')
} }
if (env.DASH_API_CHANGED == 'true') services.add('dashboard-api') if (env.DASH_API_CHANGED == 'true') services.add('dashboard-api')
@@ -145,7 +152,7 @@ pipeline {
stage('Cleanup') { stage('Cleanup') {
steps { steps {
script { script {
if (env.BOT_CHANGED == 'true') { if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') {
sh "docker rmi ${FULL_IMAGE} || true" sh "docker rmi ${FULL_IMAGE} || true"
sh "docker rmi ${LATEST_IMAGE} || true" sh "docker rmi ${LATEST_IMAGE} || true"
} }
@@ -168,7 +175,7 @@ pipeline {
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- 🤖 봇: ${env.BOT_CHANGED}\\n- 📊 API: ${env.DASH_API_CHANGED}\\n- 🖥️ UI: ${env.DASH_UI_CHANGED}"}' \ -d '{"content": "✅ **[배포 성공]** `cointrader` (Build #${env.BUILD_NUMBER}) 운영 서버(10.1.10.24) 배포 완료!\\n- 🤖 봇: ${env.BOT_CHANGED}\\n- 📈 MTF: ${env.MTF_CHANGED}\\n- 📊 API: ${env.DASH_API_CHANGED}\\n- 🖥️ UI: ${env.DASH_UI_CHANGED}"}' \
${DISCORD_WEBHOOK} ${DISCORD_WEBHOOK}
""" """
} }

View File

@@ -63,6 +63,7 @@ services:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
volumes: volumes:
- ./logs:/app/logs - ./logs:/app/logs
- ./data:/app/data
entrypoint: ["python", "main_mtf.py"] entrypoint: ["python", "main_mtf.py"]
logging: logging:
driver: "json-file" driver: "json-file"

View File

@@ -15,10 +15,12 @@ Module 4: ExecutionManager (Dry-run 가상 주문 + SL/TP 관리)
""" """
import asyncio import asyncio
import json
import os import os
import time as _time import time as _time
from datetime import datetime, timezone from datetime import datetime, timezone
from collections import deque from collections import deque
from pathlib import Path
from typing import Optional, Dict, List from typing import Optional, Dict, List
import pandas as pd import pandas as pd
@@ -399,6 +401,9 @@ class TriggerStrategy:
# Module 4: ExecutionManager # Module 4: ExecutionManager
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
_MTF_TRADE_DIR = Path("data/trade_history")
class ExecutionManager: class ExecutionManager:
""" """
TriggerStrategy의 신호를 받아 포지션 상태를 관리하고 TriggerStrategy의 신호를 받아 포지션 상태를 관리하고
@@ -408,11 +413,14 @@ class ExecutionManager:
ATR_SL_MULT = 1.5 ATR_SL_MULT = 1.5
ATR_TP_MULT = 2.3 ATR_TP_MULT = 2.3
def __init__(self): def __init__(self, symbol: str = "XRPUSDT"):
self.symbol = symbol
self.current_position: Optional[str] = None # None | 'LONG' | 'SHORT' self.current_position: Optional[str] = None # None | 'LONG' | 'SHORT'
self._entry_price: Optional[float] = None self._entry_price: Optional[float] = None
self._entry_ts: Optional[str] = None
self._sl_price: Optional[float] = None self._sl_price: Optional[float] = None
self._tp_price: Optional[float] = None self._tp_price: Optional[float] = None
self._atr_at_entry: Optional[float] = None
def execute(self, signal: str, current_price: float, atr_value: Optional[float]) -> Optional[Dict]: def execute(self, signal: str, current_price: float, atr_value: Optional[float]) -> Optional[Dict]:
""" """
@@ -455,8 +463,10 @@ class ExecutionManager:
self.current_position = side self.current_position = side
self._entry_price = entry_price self._entry_price = entry_price
self._entry_ts = datetime.now(timezone.utc).isoformat()
self._sl_price = sl_price self._sl_price = sl_price
self._tp_price = tp_price self._tp_price = tp_price
self._atr_at_entry = atr_value
sl_dist = abs(entry_price - sl_price) sl_dist = abs(entry_price - sl_price)
tp_dist = abs(tp_price - entry_price) tp_dist = abs(tp_price - entry_price)
@@ -492,8 +502,8 @@ class ExecutionManager:
"risk_reward": round(rr_ratio, 2), "risk_reward": round(rr_ratio, 2),
} }
def close_position(self, reason: str) -> None: def close_position(self, reason: str, exit_price: float = 0.0, pnl_bps: float = 0.0) -> None:
"""포지션 청산 (상태 초기화).""" """포지션 청산 + JSONL 기록 (상태 초기화)."""
if self.current_position is None: if self.current_position is None:
logger.debug("[ExecutionManager] 청산할 포지션 없음") logger.debug("[ExecutionManager] 청산할 포지션 없음")
return return
@@ -503,6 +513,9 @@ class ExecutionManager:
f"(진입: {self._entry_price:.4f}) | 사유: {reason}" f"(진입: {self._entry_price:.4f}) | 사유: {reason}"
) )
# JSONL에 기록
self._save_trade(reason, exit_price, pnl_bps)
# ── 실주문 (프로덕션 전환 시 주석 해제) ── # ── 실주문 (프로덕션 전환 시 주석 해제) ──
# if self.current_position == "LONG": # if self.current_position == "LONG":
# await self.exchange.create_market_sell_order(symbol, amount) # await self.exchange.create_market_sell_order(symbol, amount)
@@ -511,8 +524,34 @@ class ExecutionManager:
self.current_position = None self.current_position = None
self._entry_price = None self._entry_price = None
self._entry_ts = None
self._sl_price = None self._sl_price = None
self._tp_price = None self._tp_price = None
self._atr_at_entry = None
def _save_trade(self, reason: str, exit_price: float, pnl_bps: float) -> None:
"""거래 기록을 JSONL 파일에 append."""
record = {
"symbol": self.symbol,
"side": self.current_position,
"entry_price": self._entry_price,
"entry_ts": self._entry_ts,
"exit_price": exit_price,
"exit_ts": datetime.now(timezone.utc).isoformat(),
"sl_price": self._sl_price,
"tp_price": self._tp_price,
"atr": self._atr_at_entry,
"pnl_bps": round(pnl_bps, 1),
"reason": reason,
}
try:
_MTF_TRADE_DIR.mkdir(parents=True, exist_ok=True)
path = _MTF_TRADE_DIR / f"mtf_{self.symbol.replace('/', '').replace(':', '').lower()}.jsonl"
with open(path, "a") as f:
f.write(json.dumps(record) + "\n")
logger.info(f"[ExecutionManager] 거래 기록 저장: {path.name}")
except Exception as e:
logger.warning(f"[ExecutionManager] 거래 기록 저장 실패: {e}")
def get_position_info(self) -> Dict: def get_position_info(self) -> Dict:
"""현재 포지션 정보 반환.""" """현재 포지션 정보 반환."""
@@ -543,7 +582,7 @@ class MTFPullbackBot:
self.fetcher = DataFetcher(symbol=symbol) self.fetcher = DataFetcher(symbol=symbol)
self.meta = MetaFilter(self.fetcher) self.meta = MetaFilter(self.fetcher)
self.trigger = TriggerStrategy() self.trigger = TriggerStrategy()
self.executor = ExecutionManager() self.executor = ExecutionManager(symbol=symbol)
self.notifier = DiscordNotifier( self.notifier = DiscordNotifier(
webhook_url=os.getenv("DISCORD_WEBHOOK_URL", ""), webhook_url=os.getenv("DISCORD_WEBHOOK_URL", ""),
) )
@@ -689,32 +728,35 @@ class MTFPullbackBot:
if hit_sl and hit_tp: if hit_sl and hit_tp:
exit_price = sl exit_price = sl
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
logger.info(f"[MTFBot] SL+TP 동시 히트 → SL 우선 청산 | PnL: {pnl*10000:+.1f}bps") pnl_bps = pnl * 10000
self.executor.close_position(f"SL 히트 ({exit_price:.4f})") logger.info(f"[MTFBot] SL+TP 동시 히트 → SL 우선 청산 | PnL: {pnl_bps:+.1f}bps")
self.executor.close_position(f"SL 히트 ({exit_price:.4f})", exit_price, pnl_bps)
self.notifier._send( self.notifier._send(
f"❌ **[MTF Dry-run] {pos} SL 청산**\n" f"❌ **[MTF Dry-run] {pos} SL 청산**\n"
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n" f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
f"PnL: `{pnl*10000:+.1f}bps`" f"PnL: `{pnl_bps:+.1f}bps`"
) )
elif hit_sl: elif hit_sl:
exit_price = sl exit_price = sl
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
logger.info(f"[MTFBot] SL 히트 | 청산가: {exit_price:.4f} | PnL: {pnl*10000:+.1f}bps") pnl_bps = pnl * 10000
self.executor.close_position(f"SL 히트 ({exit_price:.4f})") logger.info(f"[MTFBot] SL 히트 | 청산가: {exit_price:.4f} | PnL: {pnl_bps:+.1f}bps")
self.executor.close_position(f"SL 히트 ({exit_price:.4f})", exit_price, pnl_bps)
self.notifier._send( self.notifier._send(
f"❌ **[MTF Dry-run] {pos} SL 청산**\n" f"❌ **[MTF Dry-run] {pos} SL 청산**\n"
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n" f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
f"PnL: `{pnl*10000:+.1f}bps`" f"PnL: `{pnl_bps:+.1f}bps`"
) )
elif hit_tp: elif hit_tp:
exit_price = tp exit_price = tp
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
logger.info(f"[MTFBot] TP 히트 | 청산가: {exit_price:.4f} | PnL: {pnl*10000:+.1f}bps") pnl_bps = pnl * 10000
self.executor.close_position(f"TP 히트 ({exit_price:.4f})") logger.info(f"[MTFBot] TP 히트 | 청산가: {exit_price:.4f} | PnL: {pnl_bps:+.1f}bps")
self.executor.close_position(f"TP 히트 ({exit_price:.4f})", exit_price, pnl_bps)
self.notifier._send( self.notifier._send(
f"✅ **[MTF Dry-run] {pos} TP 청산**\n" f"✅ **[MTF Dry-run] {pos} TP 청산**\n"
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n" f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
f"PnL: `{pnl*10000:+.1f}bps`" f"PnL: `{pnl_bps:+.1f}bps`"
) )