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:
19
Jenkinsfile
vendored
19
Jenkinsfile
vendored
@@ -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}
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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`"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user