From 3d05806155bb7f3b34996c4ede244da18027259b Mon Sep 17 00:00:00 2001 From: 21in7 Date: Sun, 1 Mar 2026 15:57:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Discord=20=EC=95=8C=EB=A6=BC,=20?= =?UTF-8?q?=ED=8F=AC=EC=A7=80=EC=85=98=20=EB=B3=B5=EA=B5=AC,=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20docs=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- .env.example | 3 +- ...-discord-notifier-and-position-recovery.md | 420 ++++++ docs/plans/2026-03-01-upload-to-gitea.md | 251 ++++ .../2026-03-01-xrp-futures-autotrader.md | 1325 +++++++++++++++++ requirements.txt | 2 +- src/bot.py | 59 +- src/config.py | 6 +- src/database.py | 72 - src/exchange.py | 12 +- src/notifier.py | 63 + 10 files changed, 2119 insertions(+), 94 deletions(-) create mode 100644 docs/plans/2026-03-01-discord-notifier-and-position-recovery.md create mode 100644 docs/plans/2026-03-01-upload-to-gitea.md create mode 100644 docs/plans/2026-03-01-xrp-futures-autotrader.md delete mode 100644 src/database.py create mode 100644 src/notifier.py 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}")