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 SYMBOL=XRPUSDT
LEVERAGE=10 LEVERAGE=10
RISK_PER_TRADE=0.02 RISK_PER_TRADE=0.02
NOTION_TOKEN= DISCORD_WEBHOOK_URL=
NOTION_DATABASE_ID=

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>=2.2.0
pandas-ta==0.4.71b0 pandas-ta==0.4.71b0
python-dotenv==1.0.0 python-dotenv==1.0.0
notion-client==2.2.1 httpx>=0.27.0
pytest>=8.1.0 pytest>=8.1.0
pytest-asyncio>=0.24.0 pytest-asyncio>=0.24.0
aiohttp==3.9.3 aiohttp==3.9.3

View File

@@ -1,11 +1,10 @@
import asyncio import asyncio
import os
from loguru import logger from loguru import logger
from src.config import Config from src.config import Config
from src.exchange import BinanceFuturesClient from src.exchange import BinanceFuturesClient
from src.indicators import Indicators from src.indicators import Indicators
from src.data_stream import KlineStream from src.data_stream import KlineStream
from src.database import TradeRepository from src.notifier import DiscordNotifier
from src.risk_manager import RiskManager from src.risk_manager import RiskManager
@@ -13,12 +12,9 @@ class TradingBot:
def __init__(self, config: Config): def __init__(self, config: Config):
self.config = config self.config = config
self.exchange = BinanceFuturesClient(config) self.exchange = BinanceFuturesClient(config)
self.db = TradeRepository( self.notifier = DiscordNotifier(config.discord_webhook_url)
token=config.notion_token,
database_id=config.notion_database_id,
)
self.risk = RiskManager(config) self.risk = RiskManager(config)
self.current_trade_id: str | None = None self.current_trade_side: str | None = None # "LONG" | "SHORT"
self.stream = KlineStream( self.stream = KlineStream(
symbol=config.symbol, symbol=config.symbol,
interval="1m", interval="1m",
@@ -30,6 +26,24 @@ class TradingBot:
if df is not None: if df is not None:
asyncio.create_task(self.process_candle(df)) 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): async def process_candle(self, df):
if not self.risk.is_trading_allowed(): if not self.risk.is_trading_allowed():
logger.warning("리스크 한도 초과 - 거래 중단") logger.warning("리스크 한도 초과 - 거래 중단")
@@ -38,11 +52,13 @@ class TradingBot:
ind = Indicators(df) ind = Indicators(df)
df_with_indicators = ind.calculate_all() df_with_indicators = ind.calculate_all()
signal = ind.get_signal(df_with_indicators) 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() position = await self.exchange.get_position()
if position is None and signal != "HOLD": if position is None and signal != "HOLD":
self.current_trade_side = None
if not self.risk.can_open_new_position(): if not self.risk.can_open_new_position():
logger.info("최대 포지션 수 도달") logger.info("최대 포지션 수 도달")
return return
@@ -62,6 +78,14 @@ class TradingBot:
) )
stop_loss, take_profit = Indicators(df).get_atr_stop(df, signal, price) 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" side = "BUY" if signal == "LONG" else "SELL"
await self.exchange.set_leverage(self.config.leverage) await self.exchange.set_leverage(self.config.leverage)
await self.exchange.place_order(side=side, quantity=quantity) await self.exchange.place_order(side=side, quantity=quantity)
@@ -72,15 +96,18 @@ class TradingBot:
"macd_hist": float(last_row.get("macd_hist", 0)), "macd_hist": float(last_row.get("macd_hist", 0)),
"atr": float(last_row.get("atr", 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, symbol=self.config.symbol,
side=signal, side=signal,
entry_price=price, entry_price=price,
quantity=quantity, quantity=quantity,
leverage=self.config.leverage, leverage=self.config.leverage,
stop_loss=stop_loss,
take_profit=take_profit,
signal_data=signal_snapshot, signal_data=signal_snapshot,
) )
self.current_trade_id = trade["id"]
logger.success( logger.success(
f"{signal} 진입: 가격={price}, 수량={quantity}, " f"{signal} 진입: 가격={price}, 수량={quantity}, "
f"SL={stop_loss:.4f}, TP={take_profit:.4f}" f"SL={stop_loss:.4f}, TP={take_profit:.4f}"
@@ -105,6 +132,7 @@ class TradingBot:
async def _close_position(self, position: dict): async def _close_position(self, position: dict):
amt = abs(float(position["positionAmt"])) amt = abs(float(position["positionAmt"]))
side = "SELL" if float(position["positionAmt"]) > 0 else "BUY" 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.cancel_all_orders()
await self.exchange.place_order(side=side, quantity=amt, reduce_only=True) await self.exchange.place_order(side=side, quantity=amt, reduce_only=True)
@@ -112,14 +140,19 @@ class TradingBot:
mark = float(position["markPrice"]) mark = float(position["markPrice"])
pnl = (mark - entry) * amt if side == "SELL" else (entry - mark) * amt pnl = (mark - entry) * amt if side == "SELL" else (entry - mark) * amt
if self.current_trade_id: self.notifier.notify_close(
self.db.close_trade(self.current_trade_id, exit_price=mark, pnl=pnl) symbol=self.config.symbol,
side=pos_side,
exit_price=mark,
pnl=pnl,
)
self.risk.record_pnl(pnl) self.risk.record_pnl(pnl)
self.current_trade_id = None self.current_trade_side = None
logger.success(f"포지션 청산: PnL={pnl:.4f} USDT") logger.success(f"포지션 청산: PnL={pnl:.4f} USDT")
async def run(self): async def run(self):
logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x") logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x")
await self._recover_position()
await self.stream.start( await self.stream.start(
api_key=self.config.api_key, api_key=self.config.api_key,
api_secret=self.config.api_secret, api_secret=self.config.api_secret,

View File

@@ -16,8 +16,7 @@ class Config:
stop_loss_pct: float = 0.015 # 1.5% stop_loss_pct: float = 0.015 # 1.5%
take_profit_pct: float = 0.045 # 4.5% (3:1 RR) take_profit_pct: float = 0.045 # 4.5% (3:1 RR)
trailing_stop_pct: float = 0.01 # 1% trailing_stop_pct: float = 0.01 # 1%
notion_token: str = "" discord_webhook_url: str = ""
notion_database_id: str = ""
def __post_init__(self): def __post_init__(self):
self.api_key = os.getenv("BINANCE_API_KEY", "") self.api_key = os.getenv("BINANCE_API_KEY", "")
@@ -25,5 +24,4 @@ class Config:
self.symbol = os.getenv("SYMBOL", "XRPUSDT") self.symbol = os.getenv("SYMBOL", "XRPUSDT")
self.leverage = int(os.getenv("LEVERAGE", "10")) self.leverage = int(os.getenv("LEVERAGE", "10"))
self.risk_per_trade = float(os.getenv("RISK_PER_TRADE", "0.02")) self.risk_per_trade = float(os.getenv("RISK_PER_TRADE", "0.02"))
self.notion_token = os.getenv("NOTION_TOKEN", "") self.discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL", "")
self.notion_database_id = os.getenv("NOTION_DATABASE_ID", "")

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, api_secret=config.api_secret,
) )
MIN_NOTIONAL = 5.0 # 바이낸스 선물 최소 명목금액 (USDT)
def calculate_quantity(self, balance: float, price: float, leverage: int) -> float: def calculate_quantity(self, balance: float, price: float, leverage: int) -> float:
"""리스크 기반 포지션 크기 계산""" """리스크 기반 포지션 크기 계산 (최소 명목금액 $5 보장)"""
risk_amount = balance * self.config.risk_per_trade risk_amount = balance * self.config.risk_per_trade
notional = risk_amount * leverage notional = risk_amount * leverage
if notional < self.MIN_NOTIONAL:
notional = self.MIN_NOTIONAL
quantity = notional / price 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: async def set_leverage(self, leverage: int) -> dict:
loop = asyncio.get_event_loop() 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}")