feat: Discord 알림, 포지션 복구, 설정 개선 및 docs 추가
Made-with: Cursor
This commit is contained in:
59
src/bot.py
59
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,
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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", [])
|
||||
@@ -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
63
src/notifier.py
Normal 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}")
|
||||
Reference in New Issue
Block a user