feat: Discord 알림, 포지션 복구, 설정 개선 및 docs 추가

Made-with: Cursor
This commit is contained in:
21in7
2026-03-01 15:57:08 +09:00
parent 117fd9e6bc
commit 3d05806155
10 changed files with 2119 additions and 94 deletions

View File

@@ -3,5 +3,4 @@ BINANCE_API_SECRET=
SYMBOL=XRPUSDT
LEVERAGE=10
RISK_PER_TRADE=0.02
NOTION_TOKEN=
NOTION_DATABASE_ID=
DISCORD_WEBHOOK_URL=

View File

@@ -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` |

View File

@@ -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"` |

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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,

View File

@@ -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", "")

View File

@@ -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", [])

View File

@@ -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()

63
src/notifier.py Normal file
View File

@@ -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}")