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

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