feat: add Discord webhook notifications to MTF dry-run bot

Sends alerts for: bot start, virtual entry (LONG/SHORT with SL/TP),
and SL/TP exits with PnL. Uses existing DiscordNotifier via
DISCORD_WEBHOOK_URL from .env. Also added env_file to mtf-bot
docker-compose service.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
21in7
2026-03-30 16:31:12 +09:00
parent 82f4977dff
commit a0990c5fd5
3 changed files with 41 additions and 1 deletions

View File

@@ -56,6 +56,8 @@ services:
image: git.gihyeon.com/gihyeon/cointrader:latest image: git.gihyeon.com/gihyeon/cointrader:latest
container_name: mtf-bot container_name: mtf-bot
restart: unless-stopped restart: unless-stopped
env_file:
- .env
environment: environment:
- TZ=Asia/Seoul - TZ=Asia/Seoul
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1

View File

@@ -2,10 +2,13 @@
import asyncio import asyncio
import signal as sig import signal as sig
from dotenv import load_dotenv
from loguru import logger from loguru import logger
from src.mtf_bot import MTFPullbackBot from src.mtf_bot import MTFPullbackBot
from src.logger_setup import setup_logger from src.logger_setup import setup_logger
load_dotenv()
async def main(): async def main():
setup_logger(log_level="INFO") setup_logger(log_level="INFO")

View File

@@ -15,6 +15,7 @@ Module 4: ExecutionManager (Dry-run 가상 주문 + SL/TP 관리)
""" """
import asyncio import asyncio
import os
from datetime import datetime, timezone from datetime import datetime, timezone
from collections import deque from collections import deque
from typing import Optional, Dict, List from typing import Optional, Dict, List
@@ -23,6 +24,7 @@ import pandas as pd
import pandas_ta as ta import pandas_ta as ta
import ccxt.async_support as ccxt import ccxt.async_support as ccxt
from loguru import logger from loguru import logger
from src.notifier import DiscordNotifier
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
@@ -540,6 +542,9 @@ class MTFPullbackBot:
self.meta = MetaFilter(self.fetcher) self.meta = MetaFilter(self.fetcher)
self.trigger = TriggerStrategy() self.trigger = TriggerStrategy()
self.executor = ExecutionManager() self.executor = ExecutionManager()
self.notifier = DiscordNotifier(
webhook_url=os.getenv("DISCORD_WEBHOOK_URL", ""),
)
self._last_15m_check_ts: int = 0 # 중복 체크 방지 self._last_15m_check_ts: int = 0 # 중복 체크 방지
async def run(self): async def run(self):
@@ -552,6 +557,11 @@ class MTFPullbackBot:
meta_state = self.meta.get_market_state() meta_state = self.meta.get_market_state()
atr = self.meta.get_current_atr() atr = self.meta.get_current_atr()
logger.info(f"[MTFBot] 초기 상태: Meta={meta_state}, ATR={atr}") logger.info(f"[MTFBot] 초기 상태: Meta={meta_state}, ATR={atr}")
self.notifier.notify_info(
f"**[MTF Dry-run] 봇 시작**\n"
f"심볼: `{self.symbol}` | Meta: `{meta_state}` | ATR: `{atr:.6f}`" if atr else
f"**[MTF Dry-run] 봇 시작**\n심볼: `{self.symbol}` | Meta: `{meta_state}` | ATR: N/A"
)
try: try:
while True: while True:
@@ -615,6 +625,17 @@ class MTFPullbackBot:
result = self.executor.execute(signal, current_price, atr) result = self.executor.execute(signal, current_price, atr)
if result: if result:
logger.info(f"[MTFBot] 거래 기록: {result}") logger.info(f"[MTFBot] 거래 기록: {result}")
side = result["action"]
sl_dist = abs(result["entry_price"] - result["sl_price"])
tp_dist = abs(result["tp_price"] - result["entry_price"])
self.notifier._send(
f"📌 **[MTF Dry-run] 가상 {side} 진입**\n"
f"진입가: `{result['entry_price']:.4f}` | ATR: `{result['atr']:.6f}`\n"
f"SL: `{result['sl_price']:.4f}` ({sl_dist:.4f}) | "
f"TP: `{result['tp_price']:.4f}` ({tp_dist:.4f})\n"
f"R:R = `1:{result['risk_reward']}` | Meta: `{meta_state}`\n"
f"사유: {info.get('reason', '')}"
)
else: else:
logger.debug(f"[MTFBot] HOLD | {info.get('reason', '')}") logger.debug(f"[MTFBot] HOLD | {info.get('reason', '')}")
@@ -642,21 +663,35 @@ class MTFPullbackBot:
hit_tp = last["low"] <= tp hit_tp = last["low"] <= tp
if hit_sl and hit_tp: if hit_sl and hit_tp:
# 보수적: 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+TP 동시 히트 → SL 우선 청산 | PnL: {pnl*10000:+.1f}bps") logger.info(f"[MTFBot] SL+TP 동시 히트 → SL 우선 청산 | PnL: {pnl*10000:+.1f}bps")
self.executor.close_position(f"SL 히트 ({exit_price:.4f})") self.executor.close_position(f"SL 히트 ({exit_price:.4f})")
self.notifier._send(
f"❌ **[MTF Dry-run] {pos} SL 청산**\n"
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
f"PnL: `{pnl*10000:+.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") logger.info(f"[MTFBot] SL 히트 | 청산가: {exit_price:.4f} | PnL: {pnl*10000:+.1f}bps")
self.executor.close_position(f"SL 히트 ({exit_price:.4f})") self.executor.close_position(f"SL 히트 ({exit_price:.4f})")
self.notifier._send(
f"❌ **[MTF Dry-run] {pos} SL 청산**\n"
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
f"PnL: `{pnl*10000:+.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") logger.info(f"[MTFBot] TP 히트 | 청산가: {exit_price:.4f} | PnL: {pnl*10000:+.1f}bps")
self.executor.close_position(f"TP 히트 ({exit_price:.4f})") self.executor.close_position(f"TP 히트 ({exit_price:.4f})")
self.notifier._send(
f"✅ **[MTF Dry-run] {pos} TP 청산**\n"
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
f"PnL: `{pnl*10000:+.1f}bps`"
)
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════