Files
cointrader/docs/plans/2026-03-01-discord-notifier-and-position-recovery.md
2026-03-01 15:57:08 +09:00

13 KiB
Raw Blame History

Discord 알림 전환 및 포지션 복구 구현 계획

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Notion 연동을 제거하고 Discord 웹훅으로 거래 알림을 전송하며, 봇 재시작 시 기존 포지션을 감지하여 정상 작동하도록 한다.

Architecture:

  • TradeRepository (Notion 기반)를 DiscordNotifier (Discord 웹훅 기반)로 교체한다.
  • 거래 상태(현재 포지션 ID 등)는 메모리 대신 로컬 JSON 파일(state.json)에 저장하여 재시작 후에도 복구 가능하게 한다.
  • 봇 시작 시 바이낸스 API로 실제 포지션을 조회하여 current_trade_id를 복구한다.

Tech Stack: Python 3.13, httpx (Discord 웹훅 HTTP 요청), python-binance, loguru


Task 1: Discord 웹훅 알림 모듈 생성

Files:

  • Create: src/notifier.py
  • Modify: src/config.py
  • Modify: .env.env.example

Step 1: .env.env.example에 Discord 웹훅 URL 추가

.env에 아래 줄 추가:

DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN

.env.example에 아래 줄 추가:

DISCORD_WEBHOOK_URL=

Step 2: src/config.pydiscord_webhook_url 필드 추가

notion_token, notion_database_id 필드를 제거하고 discord_webhook_url 추가:

# src/config.py
import os
from dataclasses import dataclass
from dotenv import load_dotenv

load_dotenv()


@dataclass
class Config:
    api_key: str = ""
    api_secret: str = ""
    symbol: str = "XRPUSDT"
    leverage: int = 10
    risk_per_trade: float = 0.02
    max_positions: int = 3
    stop_loss_pct: float = 0.015
    take_profit_pct: float = 0.045
    trailing_stop_pct: float = 0.01
    discord_webhook_url: str = ""

    def __post_init__(self):
        self.api_key = os.getenv("BINANCE_API_KEY", "")
        self.api_secret = os.getenv("BINANCE_API_SECRET", "")
        self.symbol = os.getenv("SYMBOL", "XRPUSDT")
        self.leverage = int(os.getenv("LEVERAGE", "10"))
        self.risk_per_trade = float(os.getenv("RISK_PER_TRADE", "0.02"))
        self.discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL", "")

Step 3: src/notifier.py 생성

# src/notifier.py
import httpx
from loguru import logger


class DiscordNotifier:
    """Discord 웹훅으로 거래 알림을 전송하는 노티파이어."""

    def __init__(self, webhook_url: str):
        self.webhook_url = webhook_url
        self._enabled = bool(webhook_url)

    def _send(self, content: str) -> None:
        if not self._enabled:
            logger.debug("Discord 웹훅 URL 미설정 - 알림 건너뜀")
            return
        try:
            resp = httpx.post(
                self.webhook_url,
                json={"content": content},
                timeout=10,
            )
            resp.raise_for_status()
        except Exception as e:
            logger.warning(f"Discord 알림 전송 실패: {e}")

    def notify_open(
        self,
        symbol: str,
        side: str,
        entry_price: float,
        quantity: float,
        leverage: int,
        stop_loss: float,
        take_profit: float,
        signal_data: dict = None,
    ) -> None:
        rsi = (signal_data or {}).get("rsi", 0)
        macd = (signal_data or {}).get("macd_hist", 0)
        atr = (signal_data or {}).get("atr", 0)
        msg = (
            f"**[{symbol}] {side} 진입**\n"
            f"진입가: `{entry_price:.4f}` | 수량: `{quantity}` | 레버리지: `{leverage}x`\n"
            f"SL: `{stop_loss:.4f}` | TP: `{take_profit:.4f}`\n"
            f"RSI: `{rsi:.2f}` | MACD Hist: `{macd:.6f}` | ATR: `{atr:.6f}`"
        )
        self._send(msg)

    def notify_close(
        self,
        symbol: str,
        side: str,
        exit_price: float,
        pnl: float,
    ) -> None:
        emoji = "✅" if pnl >= 0 else "❌"
        msg = (
            f"{emoji} **[{symbol}] {side} 청산**\n"
            f"청산가: `{exit_price:.4f}` | PnL: `{pnl:+.4f} USDT`"
        )
        self._send(msg)

    def notify_info(self, message: str) -> None:
        self._send(f" {message}")

Step 4: 웹훅 URL 실제 값을 .env에 입력

Discord 서버 → 채널 설정 → 연동 → 웹훅 생성 후 URL 복사하여 .envDISCORD_WEBHOOK_URL= 뒤에 붙여넣기.


Task 2: src/database.py 제거 및 src/bot.py 교체

Files:

  • Delete: src/database.py
  • Modify: src/bot.py

Step 1: src/bot.py에서 Notion 관련 코드를 DiscordNotifier로 교체

src/bot.py 전체를 아래로 교체:

# src/bot.py
import asyncio
import os
from loguru import logger
from src.config import Config
from src.exchange import BinanceFuturesClient
from src.indicators import Indicators
from src.data_stream import KlineStream
from src.notifier import DiscordNotifier
from src.risk_manager import RiskManager


class TradingBot:
    def __init__(self, config: Config):
        self.config = config
        self.exchange = BinanceFuturesClient(config)
        self.notifier = DiscordNotifier(config.discord_webhook_url)
        self.risk = RiskManager(config)
        self.current_trade_side: str | None = None  # "LONG" | "SHORT"
        self.stream = KlineStream(
            symbol=config.symbol,
            interval="1m",
            on_candle=self._on_candle_closed,
        )

    def _on_candle_closed(self, candle: dict):
        df = self.stream.get_dataframe()
        if df is not None:
            asyncio.create_task(self.process_candle(df))

    async def _recover_position(self) -> None:
        """재시작 시 바이낸스에서 현재 포지션을 조회하여 상태 복구."""
        position = await self.exchange.get_position()
        if position is not None:
            amt = float(position["positionAmt"])
            self.current_trade_side = "LONG" if amt > 0 else "SHORT"
            entry = float(position["entryPrice"])
            logger.info(
                f"기존 포지션 복구: {self.current_trade_side} | "
                f"진입가={entry:.4f} | 수량={abs(amt)}"
            )
            self.notifier.notify_info(
                f"봇 재시작 - 기존 포지션 감지: {self.current_trade_side} "
                f"진입가={entry:.4f} 수량={abs(amt)}"
            )
        else:
            logger.info("기존 포지션 없음 - 신규 진입 대기")

    async def process_candle(self, df):
        if not self.risk.is_trading_allowed():
            logger.warning("리스크 한도 초과 - 거래 중단")
            return

        ind = Indicators(df)
        df_with_indicators = ind.calculate_all()
        signal = ind.get_signal(df_with_indicators)
        current_price = df_with_indicators["close"].iloc[-1]
        logger.info(f"신호: {signal} | 현재가: {current_price:.4f} USDT")

        position = await self.exchange.get_position()

        if position is None and signal != "HOLD":
            self.current_trade_side = None
            if not self.risk.can_open_new_position():
                logger.info("최대 포지션 수 도달")
                return
            await self._open_position(signal, df_with_indicators)

        elif position is not None:
            pos_side = "LONG" if float(position["positionAmt"]) > 0 else "SHORT"
            if (pos_side == "LONG" and signal == "SHORT") or \
               (pos_side == "SHORT" and signal == "LONG"):
                await self._close_position(position)

    async def _open_position(self, signal: str, df):
        balance = await self.exchange.get_balance()
        price = df["close"].iloc[-1]
        quantity = self.exchange.calculate_quantity(
            balance=balance, price=price, leverage=self.config.leverage
        )
        stop_loss, take_profit = Indicators(df).get_atr_stop(df, signal, price)

        notional = quantity * price
        if quantity <= 0 or notional < self.exchange.MIN_NOTIONAL:
            logger.warning(
                f"주문 건너뜀: 명목금액 {notional:.2f} USDT < 최소 {self.exchange.MIN_NOTIONAL} USDT "
                f"(잔고={balance:.2f}, 수량={quantity})"
            )
            return

        side = "BUY" if signal == "LONG" else "SELL"
        await self.exchange.set_leverage(self.config.leverage)
        await self.exchange.place_order(side=side, quantity=quantity)

        last_row = df.iloc[-1]
        signal_snapshot = {
            "rsi":       float(last_row.get("rsi", 0)),
            "macd_hist": float(last_row.get("macd_hist", 0)),
            "atr":       float(last_row.get("atr", 0)),
        }

        self.current_trade_side = signal
        self.notifier.notify_open(
            symbol=self.config.symbol,
            side=signal,
            entry_price=price,
            quantity=quantity,
            leverage=self.config.leverage,
            stop_loss=stop_loss,
            take_profit=take_profit,
            signal_data=signal_snapshot,
        )
        logger.success(
            f"{signal} 진입: 가격={price}, 수량={quantity}, "
            f"SL={stop_loss:.4f}, TP={take_profit:.4f}"
        )

        sl_side = "SELL" if signal == "LONG" else "BUY"
        await self.exchange.place_order(
            side=sl_side,
            quantity=quantity,
            order_type="STOP_MARKET",
            stop_price=round(stop_loss, 4),
            reduce_only=True,
        )
        await self.exchange.place_order(
            side=sl_side,
            quantity=quantity,
            order_type="TAKE_PROFIT_MARKET",
            stop_price=round(take_profit, 4),
            reduce_only=True,
        )

    async def _close_position(self, position: dict):
        amt = abs(float(position["positionAmt"]))
        side = "SELL" if float(position["positionAmt"]) > 0 else "BUY"
        pos_side = "LONG" if side == "SELL" else "SHORT"
        await self.exchange.cancel_all_orders()
        await self.exchange.place_order(side=side, quantity=amt, reduce_only=True)

        entry = float(position["entryPrice"])
        mark  = float(position["markPrice"])
        pnl   = (mark - entry) * amt if side == "SELL" else (entry - mark) * amt

        self.notifier.notify_close(
            symbol=self.config.symbol,
            side=pos_side,
            exit_price=mark,
            pnl=pnl,
        )
        self.risk.record_pnl(pnl)
        self.current_trade_side = None
        logger.success(f"포지션 청산: PnL={pnl:.4f} USDT")

    async def run(self):
        logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x")
        await self._recover_position()
        await self.stream.start(
            api_key=self.config.api_key,
            api_secret=self.config.api_secret,
        )

Step 2: src/database.py 삭제

rm src/database.py

Task 3: 의존성 정리

Files:

  • Modify: requirements.txt (또는 pyproject.toml)

Step 1: 현재 의존성 파일 확인

cat requirements.txt
# 또는
cat pyproject.toml

Step 2: notion-client 제거, httpx 추가

requirements.txt에서 notion-client 줄 삭제 후 httpx 추가:

httpx>=0.27.0

Step 3: 의존성 재설치

pip install httpx
pip uninstall notion-client -y

또는 venv 사용 시:

.venv/bin/pip install httpx
.venv/bin/pip uninstall notion-client -y

Task 4: 동작 검증

Files:

  • 수정 없음 (실행 테스트)

Step 1: 봇 시작 전 환경변수 확인

grep DISCORD_WEBHOOK_URL .env

예상 출력: DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... (빈 값이면 알림 비활성화)

Step 2: 봇 실행

python main.py
# 또는
.venv/bin/python main.py

Step 3: 재시작 시 포지션 복구 확인

봇 실행 중 바이낸스에 포지션이 있는 경우 로그에서 아래 메시지 확인:

기존 포지션 복구: LONG | 진입가=X.XXXX | 수량=X.X

포지션이 없는 경우:

기존 포지션 없음 - 신규 진입 대기

Step 4: Discord 알림 테스트 (선택)

Discord 채널에서 봇 재시작 알림 메시지 확인:

  • 포지션 있을 때: 봇 재시작 - 기존 포지션 감지: LONG 진입가=X.XXXX 수량=X.X
  • 진입 시: [XRPUSDT] LONG 진입 메시지
  • 청산 시: ✅ [XRPUSDT] LONG 청산 메시지

Step 5: Notion 관련 import 잔재 없는지 확인

grep -r "notion" src/ --include="*.py"

예상 출력: (아무것도 없음)


주요 변경 요약

항목 이전 이후
알림 수단 Notion API Discord 웹훅
거래 ID 추적 Notion 페이지 ID 불필요 (바이낸스 포지션 직접 조회)
재시작 복구 없음 _recover_position() 으로 바이낸스 조회
환경변수 NOTION_TOKEN, NOTION_DATABASE_ID DISCORD_WEBHOOK_URL
외부 의존성 notion-client httpx