diff --git a/Jenkinsfile b/Jenkinsfile index 7cdb3fc..d79714c 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -47,10 +47,15 @@ pipeline { if (changes == 'ALL') { // 첫 빌드이거나 diff 실패 시 전체 빌드 env.BOT_CHANGED = 'true' + env.MTF_CHANGED = 'true' env.DASH_API_CHANGED = 'true' env.DASH_UI_CHANGED = 'true' } 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_UI_CHANGED = (changes =~ /(?m)^dashboard\/ui\//).find() ? 'true' : 'false' } @@ -62,7 +67,7 @@ pipeline { 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') { parallel { stage('Bot') { - when { expression { env.BOT_CHANGED == 'true' } } + when { expression { env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true' } } steps { 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')]) { sh "echo \$GITEA_TOKEN | docker login ${REGISTRY} -u \$GITEA_USER --password-stdin" script { - if (env.BOT_CHANGED == 'true') { + if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') { sh "docker push ${FULL_IMAGE}" sh "docker push ${LATEST_IMAGE}" } @@ -126,6 +131,8 @@ pipeline { if (env.BOT_CHANGED == 'true') { services.add('cointrader') services.add('ls-ratio-collector') + } + if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') { services.add('mtf-bot') } if (env.DASH_API_CHANGED == 'true') services.add('dashboard-api') @@ -145,7 +152,7 @@ pipeline { stage('Cleanup') { steps { script { - if (env.BOT_CHANGED == 'true') { + if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') { sh "docker rmi ${FULL_IMAGE} || true" sh "docker rmi ${LATEST_IMAGE} || true" } @@ -168,7 +175,7 @@ pipeline { sh """ curl -H "Content-Type: application/json" \ -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} """ } diff --git a/docker-compose.yml b/docker-compose.yml index 532ae87..7ee25d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,6 +63,7 @@ services: - PYTHONUNBUFFERED=1 volumes: - ./logs:/app/logs + - ./data:/app/data entrypoint: ["python", "main_mtf.py"] logging: driver: "json-file" diff --git a/src/mtf_bot.py b/src/mtf_bot.py index c398f1d..9f776ad 100644 --- a/src/mtf_bot.py +++ b/src/mtf_bot.py @@ -15,10 +15,12 @@ Module 4: ExecutionManager (Dry-run 가상 주문 + SL/TP 관리) """ import asyncio +import json import os import time as _time from datetime import datetime, timezone from collections import deque +from pathlib import Path from typing import Optional, Dict, List import pandas as pd @@ -399,6 +401,9 @@ class TriggerStrategy: # Module 4: ExecutionManager # ═══════════════════════════════════════════════════════════════════ +_MTF_TRADE_DIR = Path("data/trade_history") + + class ExecutionManager: """ TriggerStrategy의 신호를 받아 포지션 상태를 관리하고 @@ -408,11 +413,14 @@ class ExecutionManager: ATR_SL_MULT = 1.5 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._entry_price: Optional[float] = None + self._entry_ts: Optional[str] = None self._sl_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]: """ @@ -455,8 +463,10 @@ class ExecutionManager: self.current_position = side self._entry_price = entry_price + self._entry_ts = datetime.now(timezone.utc).isoformat() self._sl_price = sl_price self._tp_price = tp_price + self._atr_at_entry = atr_value sl_dist = abs(entry_price - sl_price) tp_dist = abs(tp_price - entry_price) @@ -492,8 +502,8 @@ class ExecutionManager: "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: logger.debug("[ExecutionManager] 청산할 포지션 없음") return @@ -503,6 +513,9 @@ class ExecutionManager: f"(진입: {self._entry_price:.4f}) | 사유: {reason}" ) + # JSONL에 기록 + self._save_trade(reason, exit_price, pnl_bps) + # ── 실주문 (프로덕션 전환 시 주석 해제) ── # if self.current_position == "LONG": # await self.exchange.create_market_sell_order(symbol, amount) @@ -511,8 +524,34 @@ class ExecutionManager: self.current_position = None self._entry_price = None + self._entry_ts = None self._sl_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: """현재 포지션 정보 반환.""" @@ -543,7 +582,7 @@ class MTFPullbackBot: self.fetcher = DataFetcher(symbol=symbol) self.meta = MetaFilter(self.fetcher) self.trigger = TriggerStrategy() - self.executor = ExecutionManager() + self.executor = ExecutionManager(symbol=symbol) self.notifier = DiscordNotifier( webhook_url=os.getenv("DISCORD_WEBHOOK_URL", ""), ) @@ -689,32 +728,35 @@ class MTFPullbackBot: if hit_sl and hit_tp: exit_price = sl 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") - self.executor.close_position(f"SL 히트 ({exit_price:.4f})") + pnl_bps = pnl * 10000 + 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( f"❌ **[MTF Dry-run] {pos} SL 청산**\n" f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n" - f"PnL: `{pnl*10000:+.1f}bps`" + f"PnL: `{pnl_bps:+.1f}bps`" ) elif hit_sl: exit_price = sl 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") - self.executor.close_position(f"SL 히트 ({exit_price:.4f})") + pnl_bps = pnl * 10000 + 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( f"❌ **[MTF Dry-run] {pos} SL 청산**\n" f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n" - f"PnL: `{pnl*10000:+.1f}bps`" + f"PnL: `{pnl_bps:+.1f}bps`" ) elif hit_tp: exit_price = tp 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") - self.executor.close_position(f"TP 히트 ({exit_price:.4f})") + pnl_bps = pnl * 10000 + 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( f"✅ **[MTF Dry-run] {pos} TP 청산**\n" f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n" - f"PnL: `{pnl*10000:+.1f}bps`" + f"PnL: `{pnl_bps:+.1f}bps`" )