diff --git a/.env.example b/.env.example
index 4ba3f68..aeab2ba 100644
--- a/.env.example
+++ b/.env.example
@@ -3,5 +3,4 @@ BINANCE_API_SECRET=
SYMBOL=XRPUSDT
LEVERAGE=10
RISK_PER_TRADE=0.02
-NOTION_TOKEN=
-NOTION_DATABASE_ID=
+DISCORD_WEBHOOK_URL=
diff --git a/docs/plans/2026-03-01-discord-notifier-and-position-recovery.md b/docs/plans/2026-03-01-discord-notifier-and-position-recovery.md
new file mode 100644
index 0000000..66b661b
--- /dev/null
+++ b/docs/plans/2026-03-01-discord-notifier-and-position-recovery.md
@@ -0,0 +1,420 @@
+# 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` |
diff --git a/docs/plans/2026-03-01-upload-to-gitea.md b/docs/plans/2026-03-01-upload-to-gitea.md
new file mode 100644
index 0000000..72951f8
--- /dev/null
+++ b/docs/plans/2026-03-01-upload-to-gitea.md
@@ -0,0 +1,251 @@
+# Gitea 셀프호스팅 서버 업로드 구현 계획
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** 현재 cointrader 프로젝트를 셀프호스팅 Gitea 서버(10.1.10.28:3000)에 업로드한다.
+
+**Architecture:** 기존 로컬 git 저장소에 Gitea 원격 저장소를 추가하고, 미커밋 변경사항을 정리한 뒤 전체 히스토리를 push한다. `.env` 파일은 절대 포함하지 않으며, `.gitignore`가 올바르게 설정되어 있는지 확인 후 진행한다.
+
+**Tech Stack:** git, Gitea REST API (또는 웹 UI), zsh
+
+---
+
+## 사전 확인 사항
+
+### 현재 git 상태 요약
+
+- **브랜치:** `main`
+- **기존 커밋:** 10개 (b1a7632 ~ 117fd9e)
+- **미커밋 변경사항 (modified):**
+ - `.env.example`
+ - `requirements.txt`
+ - `src/bot.py`
+ - `src/config.py`
+ - `src/exchange.py`
+- **삭제된 파일:** `src/database.py`
+- **추적되지 않는 파일 (untracked):**
+ - `docs/` (전체 디렉토리)
+ - `src/notifier.py`
+- **`.gitignore`에 의해 제외됨:** `.env`, `__pycache__/`, `*.pyc`, `.pytest_cache/`, `logs/`, `*.log`, `.venv/`
+
+---
+
+## Task 1: 미커밋 변경사항 스테이징 및 커밋
+
+**Files:**
+- Modify: `.env.example`
+- Modify: `requirements.txt`
+- Modify: `src/bot.py`
+- Modify: `src/config.py`
+- Modify: `src/exchange.py`
+- Delete: `src/database.py`
+- Create: `src/notifier.py`
+- Create: `docs/` (전체)
+
+**Step 1: git 상태 최종 확인**
+
+```bash
+git -C /Users/gihyeon/github/cointrader status
+```
+
+Expected: modified 파일들과 untracked 파일들이 목록에 표시됨
+
+**Step 2: 모든 변경사항 스테이징**
+
+```bash
+cd /Users/gihyeon/github/cointrader
+git add -A
+```
+
+> `-A` 옵션은 수정, 삭제, 신규 파일을 모두 스테이징한다. `.env`는 `.gitignore`에 있으므로 자동 제외된다.
+
+**Step 3: 스테이징 내용 검토 (`.env` 포함 여부 반드시 확인)**
+
+```bash
+git diff --cached --name-only
+```
+
+Expected: `.env` 파일이 목록에 **없어야** 한다. 만약 있다면 즉시 `git reset HEAD .env` 실행 후 중단.
+
+**Step 4: 커밋**
+
+```bash
+git commit -m "feat: Discord 알림, 포지션 복구, 설정 개선 및 docs 추가"
+```
+
+Expected: `main` 브랜치에 새 커밋 생성
+
+**Step 5: 커밋 확인**
+
+```bash
+git log --oneline -3
+```
+
+Expected: 방금 만든 커밋이 최상단에 표시됨
+
+---
+
+## Task 2: Gitea에 원격 저장소 생성
+
+**Step 1: Gitea 웹 UI 접속**
+
+브라우저에서 `http://10.1.10.28:3000` 접속 후 로그인
+
+**Step 2: 새 저장소 생성**
+
+- 우상단 `+` 버튼 → `New Repository` 클릭
+- **Repository Name:** `cointrader`
+- **Visibility:** Private (권장) 또는 Public
+- **Initialize this repository:** **체크 해제** (로컬에 이미 히스토리가 있으므로 빈 저장소로 생성해야 함)
+- `Create Repository` 클릭
+
+**Step 3: 저장소 URL 확인**
+
+생성 후 표시되는 URL 메모:
+```
+http://10.1.10.28:3000/<사용자명>/cointrader.git
+```
+
+---
+
+## Task 3: 로컬 저장소에 Gitea 원격 추가 및 Push
+
+**Step 1: 현재 원격 저장소 확인**
+
+```bash
+cd /Users/gihyeon/github/cointrader
+git remote -v
+```
+
+Expected: 아무것도 없거나 기존 origin이 있을 수 있음
+
+**Step 2: Gitea 원격 추가**
+
+기존 origin이 없는 경우:
+```bash
+git remote add origin http://10.1.10.28:3000/<사용자명>/cointrader.git
+```
+
+기존 origin이 있는 경우 (다른 URL):
+```bash
+git remote set-url origin http://10.1.10.28:3000/<사용자명>/cointrader.git
+```
+
+> `<사용자명>`은 Gitea 로그인 계정명으로 교체
+
+**Step 3: 원격 추가 확인**
+
+```bash
+git remote -v
+```
+
+Expected:
+```
+origin http://10.1.10.28:3000/<사용자명>/cointrader.git (fetch)
+origin http://10.1.10.28:3000/<사용자명>/cointrader.git (push)
+```
+
+**Step 4: main 브랜치 push**
+
+```bash
+git push -u origin main
+```
+
+> Gitea 계정의 사용자명과 비밀번호(또는 액세스 토큰)를 입력하라는 프롬프트가 나타남
+
+Expected:
+```
+Enumerating objects: ...
+Counting objects: ...
+Writing objects: 100% ...
+Branch 'main' set up to track remote branch 'main' from 'origin'.
+```
+
+**Step 5: Push 결과 확인**
+
+```bash
+git log --oneline origin/main
+```
+
+Expected: 로컬 커밋 히스토리와 동일하게 표시됨
+
+---
+
+## Task 4: Gitea 웹 UI에서 업로드 검증
+
+**Step 1: 브라우저에서 저장소 확인**
+
+`http://10.1.10.28:3000/<사용자명>/cointrader` 접속
+
+**Step 2: 파일 목록 확인**
+
+다음 파일/폴더가 있어야 함:
+- `src/` (bot.py, config.py, exchange.py, notifier.py, indicators.py, risk_manager.py, logger_setup.py, data_stream.py, config.py 등)
+- `tests/`
+- `docs/`
+- `main.py`
+- `requirements.txt`
+- `.env.example`
+- `.gitignore`
+
+다음 파일이 **없어야** 함:
+- `.env`
+- `__pycache__/`
+- `.venv/`
+- `logs/`
+
+**Step 3: 커밋 히스토리 확인**
+
+Gitea UI에서 `Commits` 탭 클릭 → 11개 커밋이 모두 표시되는지 확인
+
+---
+
+## 선택 사항: SSH 키 설정 (비밀번호 없이 push하려면)
+
+매번 비밀번호 입력이 번거롭다면 SSH 키를 등록할 수 있다.
+
+**Step 1: SSH 키 생성 (없는 경우)**
+
+```bash
+ssh-keygen -t ed25519 -C "cointrader@gitea" -f ~/.ssh/id_gitea
+```
+
+**Step 2: 공개 키 복사**
+
+```bash
+cat ~/.ssh/id_gitea.pub
+```
+
+**Step 3: Gitea에 SSH 키 등록**
+
+Gitea 웹 UI → 우상단 프로필 → `Settings` → `SSH / GPG Keys` → `Add Key` → 공개 키 붙여넣기
+
+**Step 4: SSH config 설정**
+
+```bash
+cat >> ~/.ssh/config << 'EOF'
+Host gitea-local
+ HostName 10.1.10.28
+ Port 22
+ User git
+ IdentityFile ~/.ssh/id_gitea
+EOF
+```
+
+**Step 5: 원격 URL을 SSH로 변경**
+
+```bash
+git remote set-url origin git@gitea-local:<사용자명>/cointrader.git
+```
+
+---
+
+## 트러블슈팅
+
+| 문제 | 원인 | 해결 |
+|------|------|------|
+| `Connection refused` | Gitea 서버 미실행 또는 방화벽 | `http://10.1.10.28:3000` 접속 가능한지 브라우저로 먼저 확인 |
+| `Repository not found` | 저장소 미생성 또는 URL 오타 | Task 2 재확인, URL의 사용자명 확인 |
+| `Authentication failed` | 잘못된 계정 정보 | Gitea 웹 UI 로그인 테스트 후 동일 계정 사용 |
+| `non-fast-forward` | 원격에 이미 커밋 존재 | `git push --force origin main` (단, 원격 데이터 덮어씌워짐 주의) |
+| `.env` 파일이 push됨 | `.gitignore` 미적용 | `git rm --cached .env && git commit -m "chore: remove .env from tracking"` |
diff --git a/docs/plans/2026-03-01-xrp-futures-autotrader.md b/docs/plans/2026-03-01-xrp-futures-autotrader.md
new file mode 100644
index 0000000..4eff426
--- /dev/null
+++ b/docs/plans/2026-03-01-xrp-futures-autotrader.md
@@ -0,0 +1,1325 @@
+# XRP 선물 자동매매 시스템 구현 계획
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** 바이낸스 API를 이용해 XRP/USDT 선물 시장에서 다중 기술 지표 기반 공격적 자동매매 봇을 구축한다.
+
+**Architecture:** WebSocket으로 실시간 가격/캔들 데이터를 수신하고, RSI·MACD·볼린저밴드·EMA·ATR 등 복합 지표를 계산해 진입/청산 신호를 생성한다. 레버리지 5~20배, 분할 진입/청산, 손절/익절 자동화를 포함하며 Supabase에 거래 이력을 저장한다.
+
+**Tech Stack:** Python 3.11+, python-binance, pandas, pandas-ta, asyncio, websockets, supabase-py, python-dotenv, pytest, pytest-asyncio
+
+---
+
+## 사전 준비
+
+### 환경 변수 설정 (`.env`)
+```
+BINANCE_API_KEY=your_api_key
+BINANCE_API_SECRET=your_api_secret
+BINANCE_TESTNET=true # 처음엔 테스트넷으로
+SUPABASE_URL=your_supabase_url
+SUPABASE_KEY=your_supabase_anon_key
+SYMBOL=XRPUSDT
+LEVERAGE=10
+RISK_PER_TRADE=0.02 # 계좌의 2%
+```
+
+---
+
+## Task 1: 프로젝트 초기 설정
+
+**Files:**
+- Create: `requirements.txt`
+- Create: `src/__init__.py`
+- Create: `tests/__init__.py`
+- Create: `.env.example`
+- Create: `.gitignore`
+
+**Step 1: requirements.txt 작성**
+
+```
+python-binance==1.0.19
+pandas==2.2.0
+pandas-ta==0.3.14b
+python-dotenv==1.0.0
+supabase==2.3.4
+pytest==8.0.0
+pytest-asyncio==0.23.4
+aiohttp==3.9.3
+websockets==12.0
+loguru==0.7.2
+```
+
+**Step 2: 디렉토리 구조 생성**
+
+```bash
+mkdir -p src tests
+touch src/__init__.py tests/__init__.py
+```
+
+**Step 3: .gitignore 작성**
+
+```
+.env
+__pycache__/
+*.pyc
+.pytest_cache/
+logs/
+*.log
+```
+
+**Step 4: .env.example 작성**
+
+```
+BINANCE_API_KEY=
+BINANCE_API_SECRET=
+BINANCE_TESTNET=true
+SUPABASE_URL=
+SUPABASE_KEY=
+SYMBOL=XRPUSDT
+LEVERAGE=10
+RISK_PER_TRADE=0.02
+```
+
+**Step 5: 의존성 설치**
+
+```bash
+pip install -r requirements.txt
+```
+Expected: 모든 패키지 설치 성공
+
+**Step 6: Commit**
+
+```bash
+git init
+git add .
+git commit -m "chore: 프로젝트 초기 설정 및 의존성 추가"
+```
+
+---
+
+## Task 2: 설정(Config) 모듈
+
+**Files:**
+- Create: `src/config.py`
+- Create: `tests/test_config.py`
+
+**Step 1: 실패 테스트 작성**
+
+```python
+# tests/test_config.py
+import os
+import pytest
+from src.config import Config
+
+def test_config_loads_symbol():
+ os.environ["SYMBOL"] = "XRPUSDT"
+ os.environ["LEVERAGE"] = "10"
+ os.environ["RISK_PER_TRADE"] = "0.02"
+ cfg = Config()
+ assert cfg.symbol == "XRPUSDT"
+ assert cfg.leverage == 10
+ assert cfg.risk_per_trade == 0.02
+
+def test_config_testnet_default_true():
+ os.environ["BINANCE_TESTNET"] = "true"
+ cfg = Config()
+ assert cfg.testnet is True
+```
+
+**Step 2: 테스트 실패 확인**
+
+```bash
+pytest tests/test_config.py -v
+```
+Expected: FAIL - "cannot import name 'Config'"
+
+**Step 3: Config 구현**
+
+```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 = ""
+ testnet: bool = True
+ symbol: str = "XRPUSDT"
+ leverage: int = 10
+ risk_per_trade: float = 0.02
+ max_positions: int = 3
+ stop_loss_pct: float = 0.015 # 1.5%
+ take_profit_pct: float = 0.045 # 4.5% (3:1 RR)
+ trailing_stop_pct: float = 0.01 # 1%
+
+ def __post_init__(self):
+ self.api_key = os.getenv("BINANCE_API_KEY", "")
+ self.api_secret = os.getenv("BINANCE_API_SECRET", "")
+ self.testnet = os.getenv("BINANCE_TESTNET", "true").lower() == "true"
+ 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"))
+```
+
+**Step 4: 테스트 통과 확인**
+
+```bash
+pytest tests/test_config.py -v
+```
+Expected: PASS
+
+**Step 5: Commit**
+
+```bash
+git add src/config.py tests/test_config.py
+git commit -m "feat: Config 모듈 추가"
+```
+
+---
+
+## Task 3: 바이낸스 클라이언트 래퍼
+
+**Files:**
+- Create: `src/exchange.py`
+- Create: `tests/test_exchange.py`
+
+**Step 1: 실패 테스트 작성**
+
+```python
+# tests/test_exchange.py
+import pytest
+from unittest.mock import AsyncMock, MagicMock, patch
+from src.exchange import BinanceFuturesClient
+from src.config import Config
+
+@pytest.fixture
+def config():
+ import os
+ os.environ.update({
+ "BINANCE_API_KEY": "test_key",
+ "BINANCE_API_SECRET": "test_secret",
+ "BINANCE_TESTNET": "true",
+ "SYMBOL": "XRPUSDT",
+ "LEVERAGE": "10",
+ "RISK_PER_TRADE": "0.02",
+ })
+ return Config()
+
+@pytest.mark.asyncio
+async def test_set_leverage(config):
+ client = BinanceFuturesClient(config)
+ with patch.object(client.client.futures_change_leverage, '__call__',
+ return_value={"leverage": 10}):
+ result = await client.set_leverage(10)
+ assert result is not None
+
+def test_calculate_quantity(config):
+ client = BinanceFuturesClient(config)
+ # 잔고 1000 USDT, 리스크 2%, 레버리지 10, 가격 0.5
+ qty = client.calculate_quantity(
+ balance=1000.0, price=0.5, leverage=10
+ )
+ # 1000 * 0.02 * 10 / 0.5 = 400
+ assert qty == pytest.approx(400.0, rel=0.01)
+```
+
+**Step 2: 테스트 실패 확인**
+
+```bash
+pytest tests/test_exchange.py -v
+```
+Expected: FAIL
+
+**Step 3: BinanceFuturesClient 구현**
+
+```python
+# src/exchange.py
+import asyncio
+from binance.client import Client
+from binance.exceptions import BinanceAPIException
+from loguru import logger
+from src.config import Config
+
+
+class BinanceFuturesClient:
+ def __init__(self, config: Config):
+ self.config = config
+ self.client = Client(
+ api_key=config.api_key,
+ api_secret=config.api_secret,
+ testnet=config.testnet,
+ )
+
+ def calculate_quantity(self, balance: float, price: float, leverage: int) -> float:
+ """리스크 기반 포지션 크기 계산"""
+ risk_amount = balance * self.config.risk_per_trade
+ notional = risk_amount * leverage
+ quantity = notional / price
+ return round(quantity, 1)
+
+ async def set_leverage(self, leverage: int) -> dict:
+ loop = asyncio.get_event_loop()
+ return await loop.run_in_executor(
+ None,
+ lambda: self.client.futures_change_leverage(
+ symbol=self.config.symbol, leverage=leverage
+ ),
+ )
+
+ async def get_balance(self) -> float:
+ loop = asyncio.get_event_loop()
+ balances = await loop.run_in_executor(
+ None, self.client.futures_account_balance
+ )
+ for b in balances:
+ if b["asset"] == "USDT":
+ return float(b["balance"])
+ return 0.0
+
+ async def place_order(
+ self,
+ side: str, # "BUY" | "SELL"
+ quantity: float,
+ order_type: str = "MARKET",
+ price: float = None,
+ stop_price: float = None,
+ reduce_only: bool = False,
+ ) -> dict:
+ loop = asyncio.get_event_loop()
+ params = dict(
+ symbol=self.config.symbol,
+ side=side,
+ type=order_type,
+ quantity=quantity,
+ reduceOnly=reduce_only,
+ )
+ if price:
+ params["price"] = price
+ params["timeInForce"] = "GTC"
+ if stop_price:
+ params["stopPrice"] = stop_price
+ try:
+ return await loop.run_in_executor(
+ None, lambda: self.client.futures_create_order(**params)
+ )
+ except BinanceAPIException as e:
+ logger.error(f"주문 실패: {e}")
+ raise
+
+ async def get_position(self) -> dict | None:
+ loop = asyncio.get_event_loop()
+ positions = await loop.run_in_executor(
+ None, lambda: self.client.futures_position_information(
+ symbol=self.config.symbol
+ )
+ )
+ for p in positions:
+ if float(p["positionAmt"]) != 0:
+ return p
+ return None
+
+ async def cancel_all_orders(self):
+ loop = asyncio.get_event_loop()
+ return await loop.run_in_executor(
+ None, lambda: self.client.futures_cancel_all_open_orders(
+ symbol=self.config.symbol
+ )
+ )
+```
+
+**Step 4: 테스트 통과 확인**
+
+```bash
+pytest tests/test_exchange.py -v
+```
+Expected: PASS
+
+**Step 5: Commit**
+
+```bash
+git add src/exchange.py tests/test_exchange.py
+git commit -m "feat: BinanceFuturesClient 구현"
+```
+
+---
+
+## Task 4: 기술 지표 계산 모듈
+
+**Files:**
+- Create: `src/indicators.py`
+- Create: `tests/test_indicators.py`
+
+**Step 1: 실패 테스트 작성**
+
+```python
+# tests/test_indicators.py
+import pandas as pd
+import numpy as np
+import pytest
+from src.indicators import Indicators
+
+@pytest.fixture
+def sample_df():
+ """100개 캔들 샘플 데이터"""
+ np.random.seed(42)
+ n = 100
+ close = np.cumsum(np.random.randn(n) * 0.01) + 0.5
+ df = pd.DataFrame({
+ "open": close * (1 + np.random.randn(n) * 0.001),
+ "high": close * (1 + np.abs(np.random.randn(n)) * 0.005),
+ "low": close * (1 - np.abs(np.random.randn(n)) * 0.005),
+ "close": close,
+ "volume": np.random.randint(100000, 1000000, n).astype(float),
+ })
+ return df
+
+def test_rsi_range(sample_df):
+ ind = Indicators(sample_df)
+ df = ind.calculate_all()
+ assert "rsi" in df.columns
+ valid = df["rsi"].dropna()
+ assert (valid >= 0).all() and (valid <= 100).all()
+
+def test_macd_columns(sample_df):
+ ind = Indicators(sample_df)
+ df = ind.calculate_all()
+ assert "macd" in df.columns
+ assert "macd_signal" in df.columns
+ assert "macd_hist" in df.columns
+
+def test_bollinger_bands(sample_df):
+ ind = Indicators(sample_df)
+ df = ind.calculate_all()
+ assert "bb_upper" in df.columns
+ assert "bb_lower" in df.columns
+ valid = df.dropna()
+ assert (valid["bb_upper"] >= valid["bb_lower"]).all()
+
+def test_signal_returns_direction(sample_df):
+ ind = Indicators(sample_df)
+ df = ind.calculate_all()
+ signal = ind.get_signal(df)
+ assert signal in ("LONG", "SHORT", "HOLD")
+```
+
+**Step 2: 테스트 실패 확인**
+
+```bash
+pytest tests/test_indicators.py -v
+```
+Expected: FAIL
+
+**Step 3: Indicators 구현**
+
+```python
+# src/indicators.py
+import pandas as pd
+import pandas_ta as ta
+from loguru import logger
+
+
+class Indicators:
+ """
+ 복합 기술 지표 계산 및 매매 신호 생성.
+ 공격적 전략: 여러 지표가 동시에 같은 방향을 가리킬 때 진입.
+ """
+
+ def __init__(self, df: pd.DataFrame):
+ self.df = df.copy()
+
+ def calculate_all(self) -> pd.DataFrame:
+ df = self.df
+
+ # RSI (14)
+ df["rsi"] = ta.rsi(df["close"], length=14)
+
+ # MACD (12, 26, 9)
+ macd = ta.macd(df["close"], fast=12, slow=26, signal=9)
+ df["macd"] = macd["MACD_12_26_9"]
+ df["macd_signal"] = macd["MACDs_12_26_9"]
+ df["macd_hist"] = macd["MACDh_12_26_9"]
+
+ # 볼린저 밴드 (20, 2)
+ bb = ta.bbands(df["close"], length=20, std=2)
+ df["bb_upper"] = bb["BBU_20_2.0"]
+ df["bb_mid"] = bb["BBM_20_2.0"]
+ df["bb_lower"] = bb["BBL_20_2.0"]
+
+ # EMA (9, 21, 50)
+ df["ema9"] = ta.ema(df["close"], length=9)
+ df["ema21"] = ta.ema(df["close"], length=21)
+ df["ema50"] = ta.ema(df["close"], length=50)
+
+ # ATR (14) - 변동성 기반 손절 계산용
+ df["atr"] = ta.atr(df["high"], df["low"], df["close"], length=14)
+
+ # Stochastic RSI
+ stoch = ta.stochrsi(df["close"], length=14)
+ df["stoch_k"] = stoch["STOCHRSIk_14_14_3_3"]
+ df["stoch_d"] = stoch["STOCHRSId_14_14_3_3"]
+
+ # 거래량 이동평균
+ df["vol_ma20"] = ta.sma(df["volume"], length=20)
+
+ return df
+
+ def get_signal(self, df: pd.DataFrame) -> str:
+ """
+ 복합 지표 기반 매매 신호 생성.
+ 공격적 전략: 3개 이상 지표 일치 시 진입.
+ """
+ last = df.iloc[-1]
+ prev = df.iloc[-2]
+
+ long_signals = 0
+ short_signals = 0
+
+ # 1. RSI
+ if last["rsi"] < 35:
+ long_signals += 1
+ elif last["rsi"] > 65:
+ short_signals += 1
+
+ # 2. MACD 크로스
+ if prev["macd"] < prev["macd_signal"] and last["macd"] > last["macd_signal"]:
+ long_signals += 2 # 크로스는 강한 신호
+ elif prev["macd"] > prev["macd_signal"] and last["macd"] < last["macd_signal"]:
+ short_signals += 2
+
+ # 3. 볼린저 밴드 돌파
+ if last["close"] < last["bb_lower"]:
+ long_signals += 1
+ elif last["close"] > last["bb_upper"]:
+ short_signals += 1
+
+ # 4. EMA 정배열/역배열
+ if last["ema9"] > last["ema21"] > last["ema50"]:
+ long_signals += 1
+ elif last["ema9"] < last["ema21"] < last["ema50"]:
+ short_signals += 1
+
+ # 5. Stochastic RSI 과매도/과매수
+ if last["stoch_k"] < 20 and last["stoch_k"] > last["stoch_d"]:
+ long_signals += 1
+ elif last["stoch_k"] > 80 and last["stoch_k"] < last["stoch_d"]:
+ short_signals += 1
+
+ # 6. 거래량 확인 (신호 강화)
+ vol_surge = last["volume"] > last["vol_ma20"] * 1.5
+
+ threshold = 3
+ if long_signals >= threshold and (vol_surge or long_signals >= 4):
+ return "LONG"
+ elif short_signals >= threshold and (vol_surge or short_signals >= 4):
+ return "SHORT"
+ return "HOLD"
+
+ def get_atr_stop(self, df: pd.DataFrame, side: str, entry_price: float) -> tuple[float, float]:
+ """ATR 기반 손절/익절 가격 반환 (stop_loss, take_profit)"""
+ atr = df["atr"].iloc[-1]
+ multiplier_sl = 1.5
+ multiplier_tp = 3.0
+ if side == "LONG":
+ stop_loss = entry_price - atr * multiplier_sl
+ take_profit = entry_price + atr * multiplier_tp
+ else:
+ stop_loss = entry_price + atr * multiplier_sl
+ take_profit = entry_price - atr * multiplier_tp
+ return stop_loss, take_profit
+```
+
+**Step 4: 테스트 통과 확인**
+
+```bash
+pytest tests/test_indicators.py -v
+```
+Expected: PASS
+
+**Step 5: Commit**
+
+```bash
+git add src/indicators.py tests/test_indicators.py
+git commit -m "feat: 복합 기술 지표 모듈 구현 (RSI/MACD/BB/EMA/ATR/StochRSI)"
+```
+
+---
+
+## Task 5: Supabase 거래 이력 저장 모듈
+
+**Files:**
+- Create: `src/database.py`
+- Create: `tests/test_database.py`
+- Create: `supabase/migrations/001_trades.sql`
+
+**Step 1: Supabase 테이블 마이그레이션 SQL 작성**
+
+```sql
+-- supabase/migrations/001_trades.sql
+create table if not exists trades (
+ id uuid primary key default gen_random_uuid(),
+ symbol text not null,
+ side text not null, -- 'LONG' | 'SHORT'
+ entry_price numeric(18,8) not null,
+ exit_price numeric(18,8),
+ quantity numeric(18,4) not null,
+ leverage int not null,
+ pnl numeric(18,4),
+ status text not null default 'OPEN', -- 'OPEN' | 'CLOSED' | 'CANCELLED'
+ signal_data jsonb, -- 진입 시 지표 스냅샷
+ opened_at timestamptz not null default now(),
+ closed_at timestamptz
+);
+
+create index on trades (symbol, status);
+create index on trades (opened_at desc);
+```
+
+Supabase 대시보드 SQL 에디터에서 위 SQL을 실행한다.
+
+**Step 2: 실패 테스트 작성**
+
+```python
+# tests/test_database.py
+import pytest
+from unittest.mock import MagicMock, patch
+from src.database import TradeRepository
+
+@pytest.fixture
+def mock_repo():
+ with patch("src.database.create_client") as mock_create:
+ mock_client = MagicMock()
+ mock_create.return_value = mock_client
+ repo = TradeRepository(url="http://test", key="test_key")
+ repo.client = mock_client
+ yield repo
+
+def test_save_trade(mock_repo):
+ mock_repo.client.table.return_value.insert.return_value.execute.return_value = MagicMock(
+ data=[{"id": "abc123"}]
+ )
+ result = mock_repo.save_trade(
+ symbol="XRPUSDT", side="LONG",
+ entry_price=0.5, quantity=400.0, leverage=10,
+ signal_data={"rsi": 32, "macd_hist": 0.001}
+ )
+ assert result["id"] == "abc123"
+
+def test_close_trade(mock_repo):
+ mock_repo.client.table.return_value.update.return_value.eq.return_value.execute.return_value = MagicMock(
+ data=[{"id": "abc123", "status": "CLOSED"}]
+ )
+ result = mock_repo.close_trade(trade_id="abc123", exit_price=0.55, pnl=20.0)
+ assert result["status"] == "CLOSED"
+```
+
+**Step 3: 테스트 실패 확인**
+
+```bash
+pytest tests/test_database.py -v
+```
+Expected: FAIL
+
+**Step 4: TradeRepository 구현**
+
+```python
+# src/database.py
+from datetime import datetime, timezone
+from supabase import create_client, Client
+from loguru import logger
+
+
+class TradeRepository:
+ def __init__(self, url: str, key: str):
+ self.client: Client = create_client(url, key)
+
+ def save_trade(
+ self,
+ symbol: str,
+ side: str,
+ entry_price: float,
+ quantity: float,
+ leverage: int,
+ signal_data: dict = None,
+ ) -> dict:
+ data = {
+ "symbol": symbol,
+ "side": side,
+ "entry_price": entry_price,
+ "quantity": quantity,
+ "leverage": leverage,
+ "signal_data": signal_data or {},
+ "status": "OPEN",
+ "opened_at": datetime.now(timezone.utc).isoformat(),
+ }
+ result = self.client.table("trades").insert(data).execute()
+ logger.info(f"거래 저장: {result.data[0]['id']}")
+ return result.data[0]
+
+ def close_trade(self, trade_id: str, exit_price: float, pnl: float) -> dict:
+ data = {
+ "exit_price": exit_price,
+ "pnl": pnl,
+ "status": "CLOSED",
+ "closed_at": datetime.now(timezone.utc).isoformat(),
+ }
+ result = (
+ self.client.table("trades")
+ .update(data)
+ .eq("id", trade_id)
+ .execute()
+ )
+ logger.info(f"거래 종료: {trade_id}, PnL: {pnl:.4f}")
+ return result.data[0]
+
+ def get_open_trades(self, symbol: str) -> list[dict]:
+ result = (
+ self.client.table("trades")
+ .select("*")
+ .eq("symbol", symbol)
+ .eq("status", "OPEN")
+ .execute()
+ )
+ return result.data
+```
+
+**Step 5: 테스트 통과 확인**
+
+```bash
+pytest tests/test_database.py -v
+```
+Expected: PASS
+
+**Step 6: Commit**
+
+```bash
+git add src/database.py tests/test_database.py supabase/
+git commit -m "feat: Supabase 거래 이력 저장 모듈 구현"
+```
+
+---
+
+## Task 6: 포지션 관리 및 리스크 매니저
+
+**Files:**
+- Create: `src/risk_manager.py`
+- Create: `tests/test_risk_manager.py`
+
+**Step 1: 실패 테스트 작성**
+
+```python
+# tests/test_risk_manager.py
+import pytest
+from src.risk_manager import RiskManager
+from src.config import Config
+import os
+
+@pytest.fixture
+def config():
+ os.environ.update({
+ "BINANCE_API_KEY": "k", "BINANCE_API_SECRET": "s",
+ "BINANCE_TESTNET": "true", "SYMBOL": "XRPUSDT",
+ "LEVERAGE": "10", "RISK_PER_TRADE": "0.02",
+ })
+ return Config()
+
+def test_max_drawdown_check(config):
+ rm = RiskManager(config, max_daily_loss_pct=0.05)
+ rm.daily_pnl = -60.0
+ rm.initial_balance = 1000.0
+ assert rm.is_trading_allowed() is False
+
+def test_trading_allowed_normal(config):
+ rm = RiskManager(config, max_daily_loss_pct=0.05)
+ rm.daily_pnl = -10.0
+ rm.initial_balance = 1000.0
+ assert rm.is_trading_allowed() is True
+
+def test_position_size_capped(config):
+ rm = RiskManager(config, max_daily_loss_pct=0.05)
+ # 최대 포지션 수 초과 시 False
+ rm.open_positions = ["pos1", "pos2", "pos3"]
+ assert rm.can_open_new_position() is False
+```
+
+**Step 2: 테스트 실패 확인**
+
+```bash
+pytest tests/test_risk_manager.py -v
+```
+Expected: FAIL
+
+**Step 3: RiskManager 구현**
+
+```python
+# src/risk_manager.py
+from loguru import logger
+from src.config import Config
+
+
+class RiskManager:
+ def __init__(self, config: Config, max_daily_loss_pct: float = 0.05):
+ self.config = config
+ self.max_daily_loss_pct = max_daily_loss_pct # 일일 최대 손실 5%
+ self.daily_pnl: float = 0.0
+ self.initial_balance: float = 0.0
+ self.open_positions: list = []
+
+ def is_trading_allowed(self) -> bool:
+ """일일 최대 손실 초과 시 거래 중단"""
+ if self.initial_balance <= 0:
+ return True
+ loss_pct = abs(self.daily_pnl) / self.initial_balance
+ if self.daily_pnl < 0 and loss_pct >= self.max_daily_loss_pct:
+ logger.warning(f"일일 손실 한도 초과: {loss_pct:.2%} >= {self.max_daily_loss_pct:.2%}")
+ return False
+ return True
+
+ def can_open_new_position(self) -> bool:
+ """최대 동시 포지션 수 체크"""
+ return len(self.open_positions) < self.config.max_positions
+
+ def record_pnl(self, pnl: float):
+ self.daily_pnl += pnl
+ logger.info(f"오늘 누적 PnL: {self.daily_pnl:.4f} USDT")
+
+ def reset_daily(self):
+ """매일 자정 초기화"""
+ self.daily_pnl = 0.0
+ logger.info("일일 PnL 초기화")
+```
+
+**Step 4: 테스트 통과 확인**
+
+```bash
+pytest tests/test_risk_manager.py -v
+```
+Expected: PASS
+
+**Step 5: Commit**
+
+```bash
+git add src/risk_manager.py tests/test_risk_manager.py
+git commit -m "feat: 리스크 매니저 구현 (일일 손실 한도, 포지션 수 제한)"
+```
+
+---
+
+## Task 7: 실시간 데이터 스트림 (WebSocket)
+
+**Files:**
+- Create: `src/data_stream.py`
+- Create: `tests/test_data_stream.py`
+
+**Step 1: 실패 테스트 작성**
+
+```python
+# tests/test_data_stream.py
+import pytest
+import asyncio
+from unittest.mock import AsyncMock, patch, MagicMock
+from src.data_stream import KlineStream
+
+@pytest.mark.asyncio
+async def test_kline_stream_parses_message():
+ stream = KlineStream(symbol="XRPUSDT", interval="1m")
+ raw_msg = {
+ "k": {
+ "t": 1700000000000,
+ "o": "0.5000", "h": "0.5100",
+ "l": "0.4900", "c": "0.5050",
+ "v": "100000", "x": True,
+ }
+ }
+ candle = stream.parse_kline(raw_msg)
+ assert candle["close"] == 0.5050
+ assert candle["is_closed"] is True
+
+@pytest.mark.asyncio
+async def test_callback_called_on_closed_candle():
+ received = []
+ stream = KlineStream(symbol="XRPUSDT", interval="1m",
+ on_candle=lambda c: received.append(c))
+ raw_msg = {
+ "k": {
+ "t": 1700000000000,
+ "o": "0.5", "h": "0.51",
+ "l": "0.49", "c": "0.505",
+ "v": "100000", "x": True,
+ }
+ }
+ stream.handle_message(raw_msg)
+ assert len(received) == 1
+```
+
+**Step 2: 테스트 실패 확인**
+
+```bash
+pytest tests/test_data_stream.py -v
+```
+Expected: FAIL
+
+**Step 3: KlineStream 구현**
+
+```python
+# src/data_stream.py
+import asyncio
+import json
+from collections import deque
+from typing import Callable
+import pandas as pd
+from binance import AsyncClient, BinanceSocketManager
+from loguru import logger
+
+
+class KlineStream:
+ def __init__(
+ self,
+ symbol: str,
+ interval: str = "1m",
+ buffer_size: int = 200,
+ on_candle: Callable = None,
+ ):
+ self.symbol = symbol.lower()
+ self.interval = interval
+ self.buffer: deque = deque(maxlen=buffer_size)
+ self.on_candle = on_candle
+
+ def parse_kline(self, msg: dict) -> dict:
+ k = msg["k"]
+ return {
+ "timestamp": k["t"],
+ "open": float(k["o"]),
+ "high": float(k["h"]),
+ "low": float(k["l"]),
+ "close": float(k["c"]),
+ "volume": float(k["v"]),
+ "is_closed": k["x"],
+ }
+
+ def handle_message(self, msg: dict):
+ candle = self.parse_kline(msg)
+ if candle["is_closed"]:
+ self.buffer.append(candle)
+ if self.on_candle:
+ self.on_candle(candle)
+
+ def get_dataframe(self) -> pd.DataFrame | None:
+ if len(self.buffer) < 50:
+ return None
+ df = pd.DataFrame(list(self.buffer))
+ df.set_index("timestamp", inplace=True)
+ return df
+
+ async def start(self, api_key: str, api_secret: str, testnet: bool = True):
+ client = await AsyncClient.create(
+ api_key=api_key,
+ api_secret=api_secret,
+ testnet=testnet,
+ )
+ bm = BinanceSocketManager(client)
+ stream_name = f"{self.symbol}@kline_{self.interval}"
+ logger.info(f"WebSocket 스트림 시작: {stream_name}")
+ async with bm.futures_kline_socket(
+ symbol=self.symbol.upper(), interval=self.interval
+ ) as stream:
+ while True:
+ msg = await stream.recv()
+ self.handle_message(msg)
+ await client.close_connection()
+```
+
+**Step 4: 테스트 통과 확인**
+
+```bash
+pytest tests/test_data_stream.py -v
+```
+Expected: PASS
+
+**Step 5: Commit**
+
+```bash
+git add src/data_stream.py tests/test_data_stream.py
+git commit -m "feat: WebSocket 실시간 캔들 스트림 구현"
+```
+
+---
+
+## Task 8: 트레이딩 봇 메인 루프
+
+**Files:**
+- Create: `src/bot.py`
+- Create: `tests/test_bot.py`
+
+**Step 1: 실패 테스트 작성**
+
+```python
+# tests/test_bot.py
+import pytest
+from unittest.mock import AsyncMock, MagicMock, patch
+import pandas as pd
+import numpy as np
+import os
+from src.bot import TradingBot
+from src.config import Config
+
+@pytest.fixture
+def config():
+ os.environ.update({
+ "BINANCE_API_KEY": "k", "BINANCE_API_SECRET": "s",
+ "BINANCE_TESTNET": "true", "SYMBOL": "XRPUSDT",
+ "LEVERAGE": "10", "RISK_PER_TRADE": "0.02",
+ })
+ return Config()
+
+@pytest.fixture
+def sample_df():
+ np.random.seed(0)
+ n = 100
+ close = np.cumsum(np.random.randn(n) * 0.01) + 0.5
+ return pd.DataFrame({
+ "open": close, "high": close * 1.005,
+ "low": close * 0.995, "close": close,
+ "volume": np.random.randint(100000, 1000000, n).astype(float),
+ })
+
+@pytest.mark.asyncio
+async def test_bot_processes_signal(config, sample_df):
+ bot = TradingBot(config)
+ bot.exchange = AsyncMock()
+ bot.exchange.get_balance = AsyncMock(return_value=1000.0)
+ bot.exchange.get_position = AsyncMock(return_value=None)
+ bot.exchange.place_order = AsyncMock(return_value={"orderId": "123"})
+ bot.exchange.set_leverage = AsyncMock(return_value={})
+ bot.db = MagicMock()
+ bot.db.save_trade = MagicMock(return_value={"id": "trade1"})
+
+ with patch("src.bot.Indicators") as MockInd:
+ mock_ind = MagicMock()
+ mock_ind.calculate_all.return_value = sample_df
+ mock_ind.get_signal.return_value = "LONG"
+ mock_ind.get_atr_stop.return_value = (0.48, 0.56)
+ MockInd.return_value = mock_ind
+ await bot.process_candle(sample_df)
+```
+
+**Step 2: 테스트 실패 확인**
+
+```bash
+pytest tests/test_bot.py -v
+```
+Expected: FAIL
+
+**Step 3: TradingBot 구현**
+
+```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.database import TradeRepository
+from src.risk_manager import RiskManager
+
+
+class TradingBot:
+ def __init__(self, config: Config):
+ self.config = config
+ self.exchange = BinanceFuturesClient(config)
+ self.db = TradeRepository(
+ url=os.getenv("SUPABASE_URL", ""),
+ key=os.getenv("SUPABASE_KEY", ""),
+ )
+ self.risk = RiskManager(config)
+ self.current_trade_id: str | None = None
+ 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 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)
+ logger.info(f"신호: {signal}")
+
+ position = await self.exchange.get_position()
+
+ # 포지션 없을 때 신규 진입
+ if position is None and signal != "HOLD":
+ 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)
+
+ side = "BUY" if signal == "LONG" else "SELL"
+ await self.exchange.set_leverage(self.config.leverage)
+ order = 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)),
+ }
+ trade = self.db.save_trade(
+ symbol=self.config.symbol,
+ side=signal,
+ entry_price=price,
+ quantity=quantity,
+ leverage=self.config.leverage,
+ signal_data=signal_snapshot,
+ )
+ self.current_trade_id = trade["id"]
+ logger.success(f"{signal} 진입: 가격={price}, 수량={quantity}, 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"
+ 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
+
+ if self.current_trade_id:
+ self.db.close_trade(self.current_trade_id, exit_price=mark, pnl=pnl)
+ self.risk.record_pnl(pnl)
+ self.current_trade_id = None
+ logger.success(f"포지션 청산: PnL={pnl:.4f} USDT")
+
+ async def run(self):
+ logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x")
+ await self.stream.start(
+ api_key=self.config.api_key,
+ api_secret=self.config.api_secret,
+ testnet=self.config.testnet,
+ )
+```
+
+**Step 4: 테스트 통과 확인**
+
+```bash
+pytest tests/test_bot.py -v
+```
+Expected: PASS
+
+**Step 5: Commit**
+
+```bash
+git add src/bot.py tests/test_bot.py
+git commit -m "feat: TradingBot 메인 루프 구현 (진입/청산/손절/익절)"
+```
+
+---
+
+## Task 9: 엔트리포인트 및 로깅 설정
+
+**Files:**
+- Create: `main.py`
+- Create: `src/logger_setup.py`
+
+**Step 1: 로거 설정**
+
+```python
+# src/logger_setup.py
+import sys
+from loguru import logger
+
+
+def setup_logger(log_level: str = "INFO"):
+ logger.remove()
+ logger.add(
+ sys.stdout,
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{line} - {message}",
+ level=log_level,
+ colorize=True,
+ )
+ logger.add(
+ "logs/bot_{time:YYYY-MM-DD}.log",
+ rotation="00:00",
+ retention="30 days",
+ level="DEBUG",
+ encoding="utf-8",
+ )
+```
+
+**Step 2: main.py 작성**
+
+```python
+# main.py
+import asyncio
+import os
+from dotenv import load_dotenv
+from src.config import Config
+from src.bot import TradingBot
+from src.logger_setup import setup_logger
+
+load_dotenv()
+
+async def main():
+ setup_logger(log_level="INFO")
+ config = Config()
+ bot = TradingBot(config)
+ await bot.run()
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+**Step 3: logs 디렉토리 생성**
+
+```bash
+mkdir -p logs
+echo "logs/*.log" >> .gitignore
+```
+
+**Step 4: 전체 테스트 실행**
+
+```bash
+pytest tests/ -v --tb=short
+```
+Expected: 모든 테스트 PASS
+
+**Step 5: Commit**
+
+```bash
+git add main.py src/logger_setup.py logs/.gitkeep
+git commit -m "feat: 엔트리포인트 및 로깅 설정 완료"
+```
+
+---
+
+## Task 10: 테스트넷 통합 테스트 및 검증
+
+**Step 1: .env 설정 확인**
+
+```bash
+# .env 파일에서 BINANCE_TESTNET=true 확인
+grep BINANCE_TESTNET .env
+```
+
+**Step 2: 바이낸스 테스트넷 API 키 발급**
+
+- https://testnet.binancefuture.com 접속
+- 계정 생성 후 API 키 발급
+- `.env` 파일에 키 입력
+
+**Step 3: 테스트넷 연결 확인**
+
+```bash
+python -c "
+from src.config import Config
+from src.exchange import BinanceFuturesClient
+import asyncio
+
+async def check():
+ cfg = Config()
+ client = BinanceFuturesClient(cfg)
+ bal = await client.get_balance()
+ print(f'잔고: {bal} USDT')
+
+asyncio.run(check())
+"
+```
+Expected: 잔고 출력 (테스트넷 기본 10,000 USDT)
+
+**Step 4: 레버리지 설정 확인**
+
+```bash
+python -c "
+from src.config import Config
+from src.exchange import BinanceFuturesClient
+import asyncio
+
+async def check():
+ cfg = Config()
+ client = BinanceFuturesClient(cfg)
+ result = await client.set_leverage(cfg.leverage)
+ print(f'레버리지 설정: {result}')
+
+asyncio.run(check())
+"
+```
+
+**Step 5: 봇 5분 시범 실행**
+
+```bash
+timeout 300 python main.py
+```
+Expected: 로그에 신호 생성 및 포지션 관리 메시지 출력
+
+**Step 6: 최종 Commit**
+
+```bash
+git add .
+git commit -m "feat: XRP 선물 자동매매 봇 완성 - 테스트넷 검증 완료"
+```
+
+---
+
+## 전체 테스트 실행 명령
+
+```bash
+pytest tests/ -v --tb=short --cov=src --cov-report=term-missing
+```
+
+## 실제 운영 전 체크리스트
+
+- [ ] 테스트넷에서 최소 48시간 시범 운영
+- [ ] `.env`에서 `BINANCE_TESTNET=false` 변경
+- [ ] 실제 API 키로 교체
+- [ ] `RISK_PER_TRADE=0.01` (초기 운영 시 1%로 낮춤)
+- [ ] `LEVERAGE=5` (초기 운영 시 5배로 낮춤)
+- [ ] Supabase 거래 이력 정상 저장 확인
+
+## 주의사항
+
+> **경고:** 선물 레버리지 거래는 원금 전액 손실 위험이 있습니다.
+> 반드시 테스트넷에서 충분히 검증 후 소액으로 시작하세요.
diff --git a/requirements.txt b/requirements.txt
index 1be4d38..0987638 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,7 +2,7 @@ python-binance==1.0.19
pandas>=2.2.0
pandas-ta==0.4.71b0
python-dotenv==1.0.0
-notion-client==2.2.1
+httpx>=0.27.0
pytest>=8.1.0
pytest-asyncio>=0.24.0
aiohttp==3.9.3
diff --git a/src/bot.py b/src/bot.py
index 491cdd3..b70fbbb 100644
--- a/src/bot.py
+++ b/src/bot.py
@@ -1,11 +1,10 @@
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.database import TradeRepository
+from src.notifier import DiscordNotifier
from src.risk_manager import RiskManager
@@ -13,12 +12,9 @@ class TradingBot:
def __init__(self, config: Config):
self.config = config
self.exchange = BinanceFuturesClient(config)
- self.db = TradeRepository(
- token=config.notion_token,
- database_id=config.notion_database_id,
- )
+ self.notifier = DiscordNotifier(config.discord_webhook_url)
self.risk = RiskManager(config)
- self.current_trade_id: str | None = None
+ self.current_trade_side: str | None = None # "LONG" | "SHORT"
self.stream = KlineStream(
symbol=config.symbol,
interval="1m",
@@ -30,6 +26,24 @@ class TradingBot:
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("리스크 한도 초과 - 거래 중단")
@@ -38,11 +52,13 @@ class TradingBot:
ind = Indicators(df)
df_with_indicators = ind.calculate_all()
signal = ind.get_signal(df_with_indicators)
- logger.info(f"신호: {signal}")
+ 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
@@ -62,6 +78,14 @@ class TradingBot:
)
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)
@@ -72,15 +96,18 @@ class TradingBot:
"macd_hist": float(last_row.get("macd_hist", 0)),
"atr": float(last_row.get("atr", 0)),
}
- trade = self.db.save_trade(
+
+ 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,
)
- self.current_trade_id = trade["id"]
logger.success(
f"{signal} 진입: 가격={price}, 수량={quantity}, "
f"SL={stop_loss:.4f}, TP={take_profit:.4f}"
@@ -105,6 +132,7 @@ class TradingBot:
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)
@@ -112,14 +140,19 @@ class TradingBot:
mark = float(position["markPrice"])
pnl = (mark - entry) * amt if side == "SELL" else (entry - mark) * amt
- if self.current_trade_id:
- self.db.close_trade(self.current_trade_id, exit_price=mark, pnl=pnl)
+ self.notifier.notify_close(
+ symbol=self.config.symbol,
+ side=pos_side,
+ exit_price=mark,
+ pnl=pnl,
+ )
self.risk.record_pnl(pnl)
- self.current_trade_id = None
+ 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,
diff --git a/src/config.py b/src/config.py
index 77bf5e6..744622b 100644
--- a/src/config.py
+++ b/src/config.py
@@ -16,8 +16,7 @@ class Config:
stop_loss_pct: float = 0.015 # 1.5%
take_profit_pct: float = 0.045 # 4.5% (3:1 RR)
trailing_stop_pct: float = 0.01 # 1%
- notion_token: str = ""
- notion_database_id: str = ""
+ discord_webhook_url: str = ""
def __post_init__(self):
self.api_key = os.getenv("BINANCE_API_KEY", "")
@@ -25,5 +24,4 @@ class Config:
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.notion_token = os.getenv("NOTION_TOKEN", "")
- self.notion_database_id = os.getenv("NOTION_DATABASE_ID", "")
+ self.discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL", "")
diff --git a/src/database.py b/src/database.py
deleted file mode 100644
index cafa1ea..0000000
--- a/src/database.py
+++ /dev/null
@@ -1,72 +0,0 @@
-import json
-from datetime import datetime, timezone
-from notion_client import Client
-from loguru import logger
-
-
-class TradeRepository:
- """Notion 데이터베이스에 거래 이력을 저장하는 레포지토리."""
-
- def __init__(self, token: str, database_id: str):
- self.client = Client(auth=token)
- self.database_id = database_id
-
- def save_trade(
- self,
- symbol: str,
- side: str,
- entry_price: float,
- quantity: float,
- leverage: int,
- signal_data: dict = None,
- ) -> dict:
- properties = {
- "Symbol": {"title": [{"text": {"content": symbol}}]},
- "Side": {"select": {"name": side}},
- "Entry Price": {"number": entry_price},
- "Quantity": {"number": quantity},
- "Leverage": {"number": leverage},
- "Status": {"select": {"name": "OPEN"}},
- "Signal Data": {
- "rich_text": [
- {"text": {"content": json.dumps(signal_data or {}, ensure_ascii=False)}}
- ]
- },
- "Opened At": {
- "date": {"start": datetime.now(timezone.utc).isoformat()}
- },
- }
- result = self.client.pages.create(
- parent={"database_id": self.database_id},
- properties=properties,
- )
- logger.info(f"거래 저장: {result['id']}")
- return result
-
- def close_trade(self, trade_id: str, exit_price: float, pnl: float) -> dict:
- properties = {
- "Exit Price": {"number": exit_price},
- "PnL": {"number": pnl},
- "Status": {"select": {"name": "CLOSED"}},
- "Closed At": {
- "date": {"start": datetime.now(timezone.utc).isoformat()}
- },
- }
- result = self.client.pages.update(
- page_id=trade_id,
- properties=properties,
- )
- logger.info(f"거래 종료: {trade_id}, PnL: {pnl:.4f}")
- return result
-
- def get_open_trades(self, symbol: str) -> list[dict]:
- response = self.client.databases.query(
- database_id=self.database_id,
- filter={
- "and": [
- {"property": "Symbol", "title": {"equals": symbol}},
- {"property": "Status", "select": {"equals": "OPEN"}},
- ]
- },
- )
- return response.get("results", [])
diff --git a/src/exchange.py b/src/exchange.py
index 60a307b..d200f32 100644
--- a/src/exchange.py
+++ b/src/exchange.py
@@ -13,12 +13,20 @@ class BinanceFuturesClient:
api_secret=config.api_secret,
)
+ MIN_NOTIONAL = 5.0 # 바이낸스 선물 최소 명목금액 (USDT)
+
def calculate_quantity(self, balance: float, price: float, leverage: int) -> float:
- """리스크 기반 포지션 크기 계산"""
+ """리스크 기반 포지션 크기 계산 (최소 명목금액 $5 보장)"""
risk_amount = balance * self.config.risk_per_trade
notional = risk_amount * leverage
+ if notional < self.MIN_NOTIONAL:
+ notional = self.MIN_NOTIONAL
quantity = notional / price
- return round(quantity, 1)
+ # XRP는 소수점 1자리, 단 최소 명목금액 충족 여부 재확인
+ qty_rounded = round(quantity, 1)
+ if qty_rounded * price < self.MIN_NOTIONAL:
+ qty_rounded = round(self.MIN_NOTIONAL / price + 0.05, 1)
+ return qty_rounded
async def set_leverage(self, leverage: int) -> dict:
loop = asyncio.get_event_loop()
diff --git a/src/notifier.py b/src/notifier.py
new file mode 100644
index 0000000..863b667
--- /dev/null
+++ b/src/notifier.py
@@ -0,0 +1,63 @@
+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}")