421 lines
13 KiB
Markdown
421 lines
13 KiB
Markdown
# 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.py`에 `discord_webhook_url` 필드 추가
|
||
|
||
`notion_token`, `notion_database_id` 필드를 제거하고 `discord_webhook_url` 추가:
|
||
|
||
```python
|
||
# 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` 생성
|
||
|
||
```python
|
||
# 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 복사하여 `.env`의 `DISCORD_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` 전체를 아래로 교체:
|
||
|
||
```python
|
||
# 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` 삭제
|
||
|
||
```bash
|
||
rm src/database.py
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: 의존성 정리
|
||
|
||
**Files:**
|
||
- Modify: `requirements.txt` (또는 `pyproject.toml`)
|
||
|
||
### Step 1: 현재 의존성 파일 확인
|
||
|
||
```bash
|
||
cat requirements.txt
|
||
# 또는
|
||
cat pyproject.toml
|
||
```
|
||
|
||
### Step 2: `notion-client` 제거, `httpx` 추가
|
||
|
||
`requirements.txt`에서 `notion-client` 줄 삭제 후 `httpx` 추가:
|
||
|
||
```
|
||
httpx>=0.27.0
|
||
```
|
||
|
||
### Step 3: 의존성 재설치
|
||
|
||
```bash
|
||
pip install httpx
|
||
pip uninstall notion-client -y
|
||
```
|
||
|
||
또는 venv 사용 시:
|
||
|
||
```bash
|
||
.venv/bin/pip install httpx
|
||
.venv/bin/pip uninstall notion-client -y
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: 동작 검증
|
||
|
||
**Files:**
|
||
- 수정 없음 (실행 테스트)
|
||
|
||
### Step 1: 봇 시작 전 환경변수 확인
|
||
|
||
```bash
|
||
grep DISCORD_WEBHOOK_URL .env
|
||
```
|
||
예상 출력: `DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...` (빈 값이면 알림 비활성화)
|
||
|
||
### Step 2: 봇 실행
|
||
|
||
```bash
|
||
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 잔재 없는지 확인
|
||
|
||
```bash
|
||
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` |
|