Compare commits
12 Commits
dcdaf9f90a
...
52affb5532
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52affb5532 | ||
|
|
05ae88dc61 | ||
|
|
6237efe4d3 | ||
|
|
4e8e61b5cf | ||
|
|
4ffee0ae8b | ||
|
|
7e7f0f4f22 | ||
|
|
c4f806fc35 | ||
|
|
22f1debb3d | ||
|
|
4f3183df47 | ||
|
|
223608bec0 | ||
|
|
e72126516b | ||
|
|
63c2eb8927 |
@@ -19,6 +19,10 @@ Binance Futures 자동매매 봇. 복합 기술 지표와 ML 필터(LightGBM / M
|
|||||||
- **반대 시그널 재진입**: 보유 포지션과 반대 신호 발생 시 즉시 청산 후 ML 필터 통과 시 반대 방향 재진입
|
- **반대 시그널 재진입**: 보유 포지션과 반대 신호 발생 시 즉시 청산 후 ML 필터 통과 시 반대 방향 재진입
|
||||||
- **리스크 관리**: 트레이드당 리스크 비율, 최대 포지션 수, 일일 손실 한도(5%) 제어
|
- **리스크 관리**: 트레이드당 리스크 비율, 최대 포지션 수, 일일 손실 한도(5%) 제어
|
||||||
- **포지션 복구**: 봇 재시작 시 기존 포지션 자동 감지 및 상태 복원
|
- **포지션 복구**: 봇 재시작 시 기존 포지션 자동 감지 및 상태 복원
|
||||||
|
- **실시간 TP/SL 감지**: Binance User Data Stream으로 TP/SL 작동을 즉시 감지 (캔들 마감 대기 없음)
|
||||||
|
- **순수익(Net PnL) 기록**: 바이낸스 `realizedProfit - commission`으로 정확한 순수익 계산
|
||||||
|
- **Discord 상세 청산 알림**: 예상 수익 vs 실제 순수익 + 슬리피지/수수료 차이 표시
|
||||||
|
- **listenKey 자동 갱신**: 30분 keepalive + 네트워크 단절 시 자동 재연결
|
||||||
- **Discord 알림**: 진입·청산·오류 이벤트 실시간 웹훅 알림
|
- **Discord 알림**: 진입·청산·오류 이벤트 실시간 웹훅 알림
|
||||||
- **CI/CD**: Jenkins + Gitea Container Registry 기반 Docker 이미지 자동 빌드·배포 (LXC 운영 서버 자동 적용)
|
- **CI/CD**: Jenkins + Gitea Container Registry 기반 Docker 이미지 자동 빌드·배포 (LXC 운영 서버 자동 적용)
|
||||||
|
|
||||||
|
|||||||
300
docs/plans/2026-03-02-user-data-stream-tp-sl-detection-design.md
Normal file
300
docs/plans/2026-03-02-user-data-stream-tp-sl-detection-design.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
# User Data Stream TP/SL 감지 설계
|
||||||
|
|
||||||
|
**날짜:** 2026-03-02
|
||||||
|
**목적:** Binance Futures User Data Stream을 도입하여 TP/SL 작동을 실시간 감지하고, 순수익(Net PnL)을 기록하며, Discord에 상세 청산 알림을 전송한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 배경 및 문제
|
||||||
|
|
||||||
|
기존 봇은 매 캔들 마감마다 `get_position()`을 폴링하여 포지션 소멸 여부를 확인하는 방식이었다. 이 구조의 한계:
|
||||||
|
|
||||||
|
1. **TP/SL 작동 후 최대 15분 지연** — 캔들 마감 전까지 감지 불가
|
||||||
|
2. **청산 원인 구분 불가** — TP인지 SL인지 수동 청산인지 알 수 없음
|
||||||
|
3. **PnL 기록 누락** — `_close_position()`을 봇이 직접 호출하지 않으면 `record_pnl()` 미실행
|
||||||
|
4. **Discord 알림 누락** — 동일 이유로 `notify_close()` 미호출
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 선택한 접근 방식
|
||||||
|
|
||||||
|
**방식 A: `python-binance` 내장 User Data Stream + 30분 수동 keepalive 보강**
|
||||||
|
|
||||||
|
- 기존 `BinanceSocketManager` 활용으로 추가 의존성 없음
|
||||||
|
- `futures_user_socket(listenKey)`로 User Data Stream 연결
|
||||||
|
- 별도 30분 keepalive 백그라운드 태스크로 안정성 보강
|
||||||
|
- `while True: try-except` 무한 재연결 루프로 네트워크 단절 복구
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 전체 아키텍처
|
||||||
|
|
||||||
|
### 파일 변경 목록
|
||||||
|
|
||||||
|
| 파일 | 변경 유형 | 내용 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `src/user_data_stream.py` | **신규** | User Data Stream 전담 클래스 |
|
||||||
|
| `src/bot.py` | 수정 | `UserDataStream` 초기화, `run()` 병렬 실행, `_on_position_closed()` 콜백, `_entry_price`/`_entry_quantity` 상태 추가 |
|
||||||
|
| `src/exchange.py` | 수정 | `create_listen_key()`, `keepalive_listen_key()`, `delete_listen_key()` 메서드 추가 |
|
||||||
|
| `src/notifier.py` | 수정 | `notify_close()`에 `close_reason`, `estimated_pnl`, `net_pnl` 파라미터 추가 |
|
||||||
|
| `src/risk_manager.py` | 수정 | `record_pnl()`이 net_pnl을 받도록 유지 (인터페이스 변경 없음) |
|
||||||
|
|
||||||
|
### 실행 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
bot.run()
|
||||||
|
└── AsyncClient 단일 인스턴스 생성
|
||||||
|
└── asyncio.gather()
|
||||||
|
├── MultiSymbolStream.start(client) ← 기존 캔들 스트림
|
||||||
|
└── UserDataStream.start() ← 신규
|
||||||
|
├── [백그라운드] _keepalive_loop() 30분마다 PUT /listenKey
|
||||||
|
└── [메인루프] while True:
|
||||||
|
try:
|
||||||
|
listenKey 발급
|
||||||
|
futures_user_socket() 연결
|
||||||
|
async for msg: _handle_message()
|
||||||
|
except CancelledError: break
|
||||||
|
except Exception: sleep(5) → 재연결
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 섹션 1: UserDataStream 클래스 (`src/user_data_stream.py`)
|
||||||
|
|
||||||
|
### 상수
|
||||||
|
|
||||||
|
```python
|
||||||
|
KEEPALIVE_INTERVAL = 30 * 60 # 30분 (listenKey 만료 60분의 절반)
|
||||||
|
RECONNECT_DELAY = 5 # 재연결 대기 초
|
||||||
|
```
|
||||||
|
|
||||||
|
### listenKey 생명주기
|
||||||
|
|
||||||
|
| 단계 | API | 시점 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 발급 | `POST /fapi/v1/listenKey` | 연결 시작 / 재연결 시 |
|
||||||
|
| 갱신 | `PUT /fapi/v1/listenKey` | 30분마다 (백그라운드 태스크) |
|
||||||
|
| 삭제 | `DELETE /fapi/v1/listenKey` | 봇 정상 종료 시 (`CancelledError`) |
|
||||||
|
|
||||||
|
### 재연결 로직
|
||||||
|
|
||||||
|
```python
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
listen_key = await exchange.create_listen_key()
|
||||||
|
keepalive_task = asyncio.create_task(_keepalive_loop(listen_key))
|
||||||
|
async with bm.futures_user_socket(listen_key):
|
||||||
|
async for msg:
|
||||||
|
await _handle_message(msg)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await exchange.delete_listen_key(listen_key)
|
||||||
|
keepalive_task.cancel()
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"User Data Stream 끊김: {e}, {RECONNECT_DELAY}초 후 재연결")
|
||||||
|
keepalive_task.cancel()
|
||||||
|
await asyncio.sleep(RECONNECT_DELAY)
|
||||||
|
# while True 상단으로 돌아가 listenKey 재발급
|
||||||
|
```
|
||||||
|
|
||||||
|
### keepalive 백그라운드 태스크
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _keepalive_loop(listen_key: str):
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(KEEPALIVE_INTERVAL)
|
||||||
|
try:
|
||||||
|
await exchange.keepalive_listen_key(listen_key)
|
||||||
|
logger.debug("listenKey 갱신 완료")
|
||||||
|
except Exception:
|
||||||
|
logger.warning("listenKey 갱신 실패 → 재연결 루프가 처리")
|
||||||
|
break # 재연결 루프가 새 태스크 생성
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 섹션 2: 이벤트 파싱 로직
|
||||||
|
|
||||||
|
### 페이로드 구조 (Binance Futures ORDER_TRADE_UPDATE)
|
||||||
|
|
||||||
|
주문 상세 정보는 최상위가 아닌 **내부 `"o"` 딕셔너리에 중첩**되어 있다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"e": "ORDER_TRADE_UPDATE",
|
||||||
|
"o": {
|
||||||
|
"x": "TRADE", // Execution Type
|
||||||
|
"X": "FILLED", // Order Status
|
||||||
|
"o": "TAKE_PROFIT_MARKET", // Order Type
|
||||||
|
"R": true, // reduceOnly
|
||||||
|
"rp": "0.48210000", // realizedProfit
|
||||||
|
"n": "0.02100000", // commission
|
||||||
|
"ap": "1.3393" // average price (체결가)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 판단 트리
|
||||||
|
|
||||||
|
```
|
||||||
|
msg["e"] == "ORDER_TRADE_UPDATE"?
|
||||||
|
└── order = msg["o"]
|
||||||
|
order["x"] == "TRADE" AND order["X"] == "FILLED"?
|
||||||
|
└── 청산 주문인가?
|
||||||
|
(order["R"] == true OR float(order["rp"]) != 0
|
||||||
|
OR order["o"] in {"TAKE_PROFIT_MARKET", "STOP_MARKET"})
|
||||||
|
├── NO → 무시 (진입 주문)
|
||||||
|
└── YES → close_reason 판별:
|
||||||
|
"TAKE_PROFIT_MARKET" → "TP"
|
||||||
|
"STOP_MARKET" → "SL"
|
||||||
|
그 외 → "MANUAL"
|
||||||
|
net_pnl = float(rp) - abs(float(n))
|
||||||
|
exit_price = float(order["ap"])
|
||||||
|
await on_order_filled(net_pnl, close_reason, exit_price)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 섹션 3: `_on_position_closed()` 콜백 (`src/bot.py`)
|
||||||
|
|
||||||
|
### 진입가 상태 저장
|
||||||
|
|
||||||
|
`_open_position()` 내부에서 진입가와 수량을 인스턴스 변수로 저장한다. 청산 시점에는 포지션이 이미 사라져 있으므로 사전 저장이 필수다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# __init__에 추가
|
||||||
|
self._entry_price: float | None = None
|
||||||
|
self._entry_quantity: float | None = None
|
||||||
|
|
||||||
|
# _open_position() 내부에서 저장
|
||||||
|
self._entry_price = price
|
||||||
|
self._entry_quantity = quantity
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예상 PnL 계산
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _calc_estimated_pnl(self, exit_price: float) -> float:
|
||||||
|
if self._entry_price is None or self._entry_quantity is None:
|
||||||
|
return 0.0
|
||||||
|
if self.current_trade_side == "LONG":
|
||||||
|
return (exit_price - self._entry_price) * self._entry_quantity
|
||||||
|
else: # SHORT
|
||||||
|
return (self._entry_price - exit_price) * self._entry_quantity
|
||||||
|
```
|
||||||
|
|
||||||
|
### 콜백 전체 흐름
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _on_position_closed(
|
||||||
|
self,
|
||||||
|
net_pnl: float,
|
||||||
|
close_reason: str, # "TP" | "SL" | "MANUAL"
|
||||||
|
exit_price: float,
|
||||||
|
):
|
||||||
|
estimated_pnl = self._calc_estimated_pnl(exit_price)
|
||||||
|
diff = net_pnl - estimated_pnl # 슬리피지 + 수수료 차이
|
||||||
|
|
||||||
|
# RiskManager에 순수익 기록
|
||||||
|
self.risk.record_pnl(net_pnl)
|
||||||
|
|
||||||
|
# Discord 알림
|
||||||
|
self.notifier.notify_close(
|
||||||
|
symbol=self.config.symbol,
|
||||||
|
side=self.current_trade_side or "UNKNOWN",
|
||||||
|
close_reason=close_reason,
|
||||||
|
exit_price=exit_price,
|
||||||
|
estimated_pnl=estimated_pnl,
|
||||||
|
net_pnl=net_pnl,
|
||||||
|
diff=diff,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
f"포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
|
||||||
|
f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 봇 상태 초기화 (Flat 상태로 복귀)
|
||||||
|
self.current_trade_side = None
|
||||||
|
self._entry_price = None
|
||||||
|
self._entry_quantity = None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 기존 `_close_position()` 변경
|
||||||
|
|
||||||
|
봇이 직접 청산하는 경우(`_close_and_reenter`)에도 User Data Stream의 `ORDER_TRADE_UPDATE`가 발생한다. **중복 처리 방지**를 위해 `_close_position()`에서 `notify_close()`와 `record_pnl()` 호출을 제거한다. 모든 청산 후처리는 `_on_position_closed()` 콜백 하나로 일원화한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 섹션 4: Discord 알림 포맷 (`src/notifier.py`)
|
||||||
|
|
||||||
|
### `notify_close()` 시그니처 변경
|
||||||
|
|
||||||
|
```python
|
||||||
|
def notify_close(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
side: str,
|
||||||
|
close_reason: str, # "TP" | "SL" | "MANUAL"
|
||||||
|
exit_price: float,
|
||||||
|
estimated_pnl: float,
|
||||||
|
net_pnl: float,
|
||||||
|
diff: float, # net_pnl - estimated_pnl
|
||||||
|
) -> None:
|
||||||
|
```
|
||||||
|
|
||||||
|
### 알림 포맷
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ [XRPUSDT] SHORT TP 청산
|
||||||
|
청산가: `1.3393`
|
||||||
|
예상 수익: `+0.4821 USDT`
|
||||||
|
실제 순수익: `+0.4612 USDT`
|
||||||
|
차이(슬리피지+수수료): `-0.0209 USDT`
|
||||||
|
```
|
||||||
|
|
||||||
|
| 청산 원인 | 이모지 |
|
||||||
|
|----------|--------|
|
||||||
|
| TP | ✅ |
|
||||||
|
| SL | ❌ |
|
||||||
|
| MANUAL | 🔶 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 섹션 5: `src/exchange.py` 추가 메서드
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def create_listen_key(self) -> str:
|
||||||
|
"""POST /fapi/v1/listenKey — listenKey 신규 발급"""
|
||||||
|
|
||||||
|
async def keepalive_listen_key(self, listen_key: str) -> None:
|
||||||
|
"""PUT /fapi/v1/listenKey — listenKey 만료 연장"""
|
||||||
|
|
||||||
|
async def delete_listen_key(self, listen_key: str) -> None:
|
||||||
|
"""DELETE /fapi/v1/listenKey — listenKey 삭제 (정상 종료 시)"""
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 흐름 요약
|
||||||
|
|
||||||
|
```
|
||||||
|
Binance WebSocket
|
||||||
|
→ ORDER_TRADE_UPDATE (FILLED, reduceOnly)
|
||||||
|
→ UserDataStream._handle_message()
|
||||||
|
→ net_pnl = rp - |commission|
|
||||||
|
→ bot._on_position_closed(net_pnl, close_reason, exit_price)
|
||||||
|
├── estimated_pnl = (exit - entry) × qty (봇 계산)
|
||||||
|
├── diff = net_pnl - estimated_pnl
|
||||||
|
├── risk.record_pnl(net_pnl) → 일일 PnL 누적
|
||||||
|
├── notifier.notify_close(...) → Discord 알림
|
||||||
|
└── 상태 초기화 (current_trade_side, _entry_price, _entry_quantity = None)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 제외 범위 (YAGNI)
|
||||||
|
|
||||||
|
- DB 영구 저장 (SQLite/Postgres) — 현재 로그 기반으로 충분
|
||||||
|
- 진입 주문 체결 알림 (`TRADE` + not reduceOnly) — 기존 `notify_open()`으로 커버
|
||||||
|
- 부분 청산(partial fill) 처리 — 현재 봇은 전량 청산만 사용
|
||||||
510
docs/plans/2026-03-02-user-data-stream-tp-sl-detection-plan.md
Normal file
510
docs/plans/2026-03-02-user-data-stream-tp-sl-detection-plan.md
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
# User Data Stream TP/SL 감지 Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Binance Futures User Data Stream을 도입하여 TP/SL 작동을 실시간 감지하고, 순수익(Net PnL)을 기록하며, Discord에 예상 수익 vs 실제 순수익 비교 알림을 전송한다.
|
||||||
|
|
||||||
|
**Architecture:** `python-binance`의 `futures_user_socket(listenKey)`로 User Data Stream에 연결하고, 30분 keepalive 백그라운드 태스크와 `while True: try-except` 무한 재연결 루프로 안정성을 확보한다. `ORDER_TRADE_UPDATE` 이벤트에서 청산 주문을 감지하면 `bot._on_position_closed()` 콜백을 호출하여 PnL 기록과 Discord 알림을 일원화한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, python-binance (AsyncClient, BinanceSocketManager), asyncio, loguru
|
||||||
|
|
||||||
|
**Design Doc:** `docs/plans/2026-03-02-user-data-stream-tp-sl-detection-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `exchange.py`에 listenKey 관리 메서드 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/exchange.py` (끝에 메서드 추가)
|
||||||
|
|
||||||
|
**Step 1: listenKey 3개 메서드 구현**
|
||||||
|
|
||||||
|
`src/exchange.py` 끝에 아래 메서드 3개를 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def create_listen_key(self) -> str:
|
||||||
|
"""POST /fapi/v1/listenKey — listenKey 신규 발급"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self.client.futures_stream_get_listen_key(),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def keepalive_listen_key(self, listen_key: str) -> None:
|
||||||
|
"""PUT /fapi/v1/listenKey — listenKey 만료 연장 (60분 → 리셋)"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self.client.futures_stream_keepalive(listenKey=listen_key),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_listen_key(self, listen_key: str) -> None:
|
||||||
|
"""DELETE /fapi/v1/listenKey — listenKey 삭제 (정상 종료 시)"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self.client.futures_stream_close(listenKey=listen_key),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"listenKey 삭제 실패 (무시): {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/exchange.py
|
||||||
|
git commit -m "feat: add listenKey create/keepalive/delete methods to exchange"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `notifier.py`의 `notify_close()` 시그니처 확장
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/notifier.py`
|
||||||
|
|
||||||
|
**Step 1: `notify_close()` 메서드 교체**
|
||||||
|
|
||||||
|
기존 `notify_close()`를 아래로 교체한다. `close_reason`, `estimated_pnl`, `net_pnl`, `diff` 파라미터가 추가된다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def notify_close(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
side: str,
|
||||||
|
close_reason: str, # "TP" | "SL" | "MANUAL"
|
||||||
|
exit_price: float,
|
||||||
|
estimated_pnl: float, # 봇 계산 (entry-exit 기반)
|
||||||
|
net_pnl: float, # 바이낸스 rp - |commission|
|
||||||
|
diff: float, # net_pnl - estimated_pnl (슬리피지+수수료)
|
||||||
|
) -> None:
|
||||||
|
emoji_map = {"TP": "✅", "SL": "❌", "MANUAL": "🔶"}
|
||||||
|
emoji = emoji_map.get(close_reason, "🔶")
|
||||||
|
msg = (
|
||||||
|
f"{emoji} **[{symbol}] {side} {close_reason} 청산**\n"
|
||||||
|
f"청산가: `{exit_price:.4f}`\n"
|
||||||
|
f"예상 수익: `{estimated_pnl:+.4f} USDT`\n"
|
||||||
|
f"실제 순수익: `{net_pnl:+.4f} USDT`\n"
|
||||||
|
f"차이(슬리피지+수수료): `{diff:+.4f} USDT`"
|
||||||
|
)
|
||||||
|
self._send(msg)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/notifier.py
|
||||||
|
git commit -m "feat: extend notify_close with close_reason, net_pnl, diff fields"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `src/user_data_stream.py` 신규 생성
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/user_data_stream.py`
|
||||||
|
|
||||||
|
**Step 1: 파일 전체 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from typing import Callable
|
||||||
|
from binance import AsyncClient, BinanceSocketManager
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
_KEEPALIVE_INTERVAL = 30 * 60 # 30분 (listenKey 만료 60분의 절반)
|
||||||
|
_RECONNECT_DELAY = 5 # 재연결 대기 초
|
||||||
|
|
||||||
|
_CLOSE_ORDER_TYPES = {"TAKE_PROFIT_MARKET", "STOP_MARKET"}
|
||||||
|
|
||||||
|
|
||||||
|
class UserDataStream:
|
||||||
|
"""
|
||||||
|
Binance Futures User Data Stream을 구독하여 주문 체결 이벤트를 처리한다.
|
||||||
|
|
||||||
|
- listenKey 30분 keepalive 백그라운드 태스크
|
||||||
|
- 네트워크 단절 시 무한 재연결 루프
|
||||||
|
- ORDER_TRADE_UPDATE 이벤트에서 청산 주문만 필터링하여 콜백 호출
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
exchange, # BinanceFuturesClient 인스턴스
|
||||||
|
on_order_filled: Callable, # bot._on_position_closed 콜백
|
||||||
|
):
|
||||||
|
self._exchange = exchange
|
||||||
|
self._on_order_filled = on_order_filled
|
||||||
|
self._listen_key: str | None = None
|
||||||
|
self._keepalive_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
async def start(self, api_key: str, api_secret: str) -> None:
|
||||||
|
"""User Data Stream 메인 루프 — 봇 종료 시까지 실행."""
|
||||||
|
client = await AsyncClient.create(
|
||||||
|
api_key=api_key,
|
||||||
|
api_secret=api_secret,
|
||||||
|
)
|
||||||
|
bm = BinanceSocketManager(client)
|
||||||
|
try:
|
||||||
|
await self._run_loop(bm)
|
||||||
|
finally:
|
||||||
|
await client.close_connection()
|
||||||
|
|
||||||
|
async def _run_loop(self, bm: BinanceSocketManager) -> None:
|
||||||
|
"""listenKey 발급 → 연결 → 재연결 무한 루프."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
self._listen_key = await self._exchange.create_listen_key()
|
||||||
|
logger.info(f"User Data Stream listenKey 발급: {self._listen_key[:8]}...")
|
||||||
|
|
||||||
|
self._keepalive_task = asyncio.create_task(
|
||||||
|
self._keepalive_loop(self._listen_key)
|
||||||
|
)
|
||||||
|
|
||||||
|
async with bm.futures_user_socket(self._listen_key) as stream:
|
||||||
|
logger.info("User Data Stream 연결 완료")
|
||||||
|
async for msg in stream:
|
||||||
|
await self._handle_message(msg)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("User Data Stream 정상 종료")
|
||||||
|
if self._listen_key:
|
||||||
|
await self._exchange.delete_listen_key(self._listen_key)
|
||||||
|
if self._keepalive_task:
|
||||||
|
self._keepalive_task.cancel()
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"User Data Stream 끊김: {e} — "
|
||||||
|
f"{_RECONNECT_DELAY}초 후 재연결"
|
||||||
|
)
|
||||||
|
if self._keepalive_task:
|
||||||
|
self._keepalive_task.cancel()
|
||||||
|
self._keepalive_task = None
|
||||||
|
await asyncio.sleep(_RECONNECT_DELAY)
|
||||||
|
|
||||||
|
async def _keepalive_loop(self, listen_key: str) -> None:
|
||||||
|
"""30분마다 listenKey를 갱신한다."""
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(_KEEPALIVE_INTERVAL)
|
||||||
|
try:
|
||||||
|
await self._exchange.keepalive_listen_key(listen_key)
|
||||||
|
logger.debug("listenKey 갱신 완료")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"listenKey 갱신 실패: {e} — 재연결 루프가 처리")
|
||||||
|
break
|
||||||
|
|
||||||
|
async def _handle_message(self, msg: dict) -> None:
|
||||||
|
"""ORDER_TRADE_UPDATE 이벤트에서 청산 주문을 필터링하여 콜백을 호출한다."""
|
||||||
|
if msg.get("e") != "ORDER_TRADE_UPDATE":
|
||||||
|
return
|
||||||
|
|
||||||
|
order = msg.get("o", {})
|
||||||
|
|
||||||
|
# x: Execution Type, X: Order Status
|
||||||
|
if order.get("x") != "TRADE" or order.get("X") != "FILLED":
|
||||||
|
return
|
||||||
|
|
||||||
|
order_type = order.get("o", "")
|
||||||
|
is_reduce = order.get("R", False)
|
||||||
|
realized_pnl = float(order.get("rp", "0"))
|
||||||
|
|
||||||
|
# 청산 주문 판별: reduceOnly이거나, TP/SL 타입이거나, rp != 0
|
||||||
|
is_close = is_reduce or order_type in _CLOSE_ORDER_TYPES or realized_pnl != 0
|
||||||
|
if not is_close:
|
||||||
|
return
|
||||||
|
|
||||||
|
commission = abs(float(order.get("n", "0")))
|
||||||
|
net_pnl = realized_pnl - commission
|
||||||
|
exit_price = float(order.get("ap", "0"))
|
||||||
|
|
||||||
|
if order_type == "TAKE_PROFIT_MARKET":
|
||||||
|
close_reason = "TP"
|
||||||
|
elif order_type == "STOP_MARKET":
|
||||||
|
close_reason = "SL"
|
||||||
|
else:
|
||||||
|
close_reason = "MANUAL"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"청산 감지({close_reason}): exit={exit_price:.4f}, "
|
||||||
|
f"rp={realized_pnl:+.4f}, commission={commission:.4f}, "
|
||||||
|
f"net_pnl={net_pnl:+.4f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._on_order_filled(
|
||||||
|
net_pnl=net_pnl,
|
||||||
|
close_reason=close_reason,
|
||||||
|
exit_price=exit_price,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/user_data_stream.py
|
||||||
|
git commit -m "feat: add UserDataStream with keepalive and reconnect loop"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `bot.py` 수정 — 상태 변수 추가 및 `_open_position()` 저장
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py`
|
||||||
|
|
||||||
|
**Step 1: `__init__`에 상태 변수 추가**
|
||||||
|
|
||||||
|
`TradingBot.__init__()` 내부에서 `self.current_trade_side` 선언 바로 아래에 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._entry_price: float | None = None
|
||||||
|
self._entry_quantity: float | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: `_open_position()` 내부에서 진입가/수량 저장**
|
||||||
|
|
||||||
|
`self.current_trade_side = signal` 바로 아래에 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._entry_price = price
|
||||||
|
self._entry_quantity = quantity
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/bot.py
|
||||||
|
git commit -m "feat: store entry_price and entry_quantity on position open"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: `bot.py` 수정 — `_on_position_closed()` 콜백 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py`
|
||||||
|
|
||||||
|
**Step 1: `_calc_estimated_pnl()` 헬퍼 메서드 추가**
|
||||||
|
|
||||||
|
`_close_position()` 메서드 바로 위에 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _calc_estimated_pnl(self, exit_price: float) -> float:
|
||||||
|
"""진입가·수량 기반 예상 PnL 계산 (수수료 미반영)."""
|
||||||
|
if self._entry_price is None or self._entry_quantity is None:
|
||||||
|
return 0.0
|
||||||
|
if self.current_trade_side == "LONG":
|
||||||
|
return (exit_price - self._entry_price) * self._entry_quantity
|
||||||
|
return (self._entry_price - exit_price) * self._entry_quantity
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: `_on_position_closed()` 콜백 추가**
|
||||||
|
|
||||||
|
`_calc_estimated_pnl()` 바로 아래에 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _on_position_closed(
|
||||||
|
self,
|
||||||
|
net_pnl: float,
|
||||||
|
close_reason: str,
|
||||||
|
exit_price: float,
|
||||||
|
) -> None:
|
||||||
|
"""User Data Stream에서 청산 감지 시 호출되는 콜백."""
|
||||||
|
estimated_pnl = self._calc_estimated_pnl(exit_price)
|
||||||
|
diff = net_pnl - estimated_pnl
|
||||||
|
|
||||||
|
self.risk.record_pnl(net_pnl)
|
||||||
|
|
||||||
|
self.notifier.notify_close(
|
||||||
|
symbol=self.config.symbol,
|
||||||
|
side=self.current_trade_side or "UNKNOWN",
|
||||||
|
close_reason=close_reason,
|
||||||
|
exit_price=exit_price,
|
||||||
|
estimated_pnl=estimated_pnl,
|
||||||
|
net_pnl=net_pnl,
|
||||||
|
diff=diff,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
f"포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
|
||||||
|
f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Flat 상태로 초기화
|
||||||
|
self.current_trade_side = None
|
||||||
|
self._entry_price = None
|
||||||
|
self._entry_quantity = None
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/bot.py
|
||||||
|
git commit -m "feat: add _on_position_closed callback with net PnL and discord alert"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: `bot.py` 수정 — `_close_position()`에서 중복 후처리 제거
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py`
|
||||||
|
|
||||||
|
**배경:** 봇이 직접 청산(`_close_and_reenter`)하는 경우에도 User Data Stream의 `ORDER_TRADE_UPDATE`가 발생한다. 중복 방지를 위해 `_close_position()`에서 `notify_close()`와 `record_pnl()` 호출을 제거한다.
|
||||||
|
|
||||||
|
**Step 1: `_close_position()` 수정**
|
||||||
|
|
||||||
|
기존 코드:
|
||||||
|
```python
|
||||||
|
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")
|
||||||
|
```
|
||||||
|
|
||||||
|
수정 후 (`notify_close`, `record_pnl`, `current_trade_side = None` 제거 — User Data Stream 콜백이 처리):
|
||||||
|
```python
|
||||||
|
async def _close_position(self, position: dict):
|
||||||
|
"""포지션 청산 주문만 실행한다. PnL 기록/알림은 _on_position_closed 콜백이 담당."""
|
||||||
|
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)
|
||||||
|
logger.info(f"청산 주문 전송 완료 (side={side}, qty={amt})")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/bot.py
|
||||||
|
git commit -m "refactor: remove duplicate pnl/notify from _close_position (handled by callback)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: `bot.py` 수정 — `run()`에서 UserDataStream 병렬 실행
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py`
|
||||||
|
|
||||||
|
**Step 1: import 추가**
|
||||||
|
|
||||||
|
파일 상단 import 블록에 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.user_data_stream import UserDataStream
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: `run()` 메서드 수정**
|
||||||
|
|
||||||
|
기존:
|
||||||
|
```python
|
||||||
|
async def run(self):
|
||||||
|
logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x")
|
||||||
|
await self._recover_position()
|
||||||
|
balance = await self.exchange.get_balance()
|
||||||
|
self.risk.set_base_balance(balance)
|
||||||
|
logger.info(f"기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)")
|
||||||
|
await self.stream.start(
|
||||||
|
api_key=self.config.api_key,
|
||||||
|
api_secret=self.config.api_secret,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
수정 후:
|
||||||
|
```python
|
||||||
|
async def run(self):
|
||||||
|
logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x")
|
||||||
|
await self._recover_position()
|
||||||
|
balance = await self.exchange.get_balance()
|
||||||
|
self.risk.set_base_balance(balance)
|
||||||
|
logger.info(f"기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)")
|
||||||
|
|
||||||
|
user_stream = UserDataStream(
|
||||||
|
exchange=self.exchange,
|
||||||
|
on_order_filled=self._on_position_closed,
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
self.stream.start(
|
||||||
|
api_key=self.config.api_key,
|
||||||
|
api_secret=self.config.api_secret,
|
||||||
|
),
|
||||||
|
user_stream.start(
|
||||||
|
api_key=self.config.api_key,
|
||||||
|
api_secret=self.config.api_secret,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/bot.py
|
||||||
|
git commit -m "feat: run UserDataStream in parallel with candle stream"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: README.md 업데이트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
**Step 1: 기능 목록에 User Data Stream 항목 추가**
|
||||||
|
|
||||||
|
README의 주요 기능 섹션에 아래 내용을 추가한다.
|
||||||
|
|
||||||
|
- **실시간 TP/SL 감지**: Binance User Data Stream으로 TP/SL 작동을 즉시 감지 (캔들 마감 대기 없음)
|
||||||
|
- **순수익(Net PnL) 기록**: 바이낸스 `realizedProfit - commission`으로 정확한 순수익 계산
|
||||||
|
- **Discord 상세 청산 알림**: 예상 수익 vs 실제 순수익 + 슬리피지/수수료 차이 표시
|
||||||
|
- **listenKey 자동 갱신**: 30분 keepalive + 네트워크 단절 시 자동 재연결
|
||||||
|
|
||||||
|
**Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md
|
||||||
|
git commit -m "docs: update README with User Data Stream TP/SL detection feature"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 최종 검증
|
||||||
|
|
||||||
|
봇 실행 후 로그에서 아래 메시지가 순서대로 나타나면 정상 동작:
|
||||||
|
|
||||||
|
```
|
||||||
|
INFO | User Data Stream listenKey 발급: xxxxxxxx...
|
||||||
|
INFO | User Data Stream 연결 완료
|
||||||
|
DEBUG | listenKey 갱신 완료 ← 30분 후
|
||||||
|
INFO | 청산 감지(TP): exit=1.3393, rp=+0.4821, commission=0.0209, net_pnl=+0.4612
|
||||||
|
SUCCESS | 포지션 청산(TP): 예상=+0.4821, 순수익=+0.4612, 차이=-0.0209 USDT
|
||||||
|
```
|
||||||
|
|
||||||
|
Discord에는 아래 형식의 알림이 전송됨:
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ [XRPUSDT] SHORT TP 청산
|
||||||
|
청산가: 1.3393
|
||||||
|
예상 수익: +0.4821 USDT
|
||||||
|
실제 순수익: +0.4612 USDT
|
||||||
|
차이(슬리피지+수수료): -0.0209 USDT
|
||||||
|
```
|
||||||
86
src/bot.py
86
src/bot.py
@@ -9,6 +9,7 @@ from src.notifier import DiscordNotifier
|
|||||||
from src.risk_manager import RiskManager
|
from src.risk_manager import RiskManager
|
||||||
from src.ml_filter import MLFilter
|
from src.ml_filter import MLFilter
|
||||||
from src.ml_features import build_features
|
from src.ml_features import build_features
|
||||||
|
from src.user_data_stream import UserDataStream
|
||||||
|
|
||||||
|
|
||||||
class TradingBot:
|
class TradingBot:
|
||||||
@@ -19,6 +20,9 @@ class TradingBot:
|
|||||||
self.risk = RiskManager(config)
|
self.risk = RiskManager(config)
|
||||||
self.ml_filter = MLFilter()
|
self.ml_filter = MLFilter()
|
||||||
self.current_trade_side: str | None = None # "LONG" | "SHORT"
|
self.current_trade_side: str | None = None # "LONG" | "SHORT"
|
||||||
|
self._entry_price: float | None = None
|
||||||
|
self._entry_quantity: float | None = None
|
||||||
|
self._is_reentering: bool = False # _close_and_reenter 중 콜백 상태 초기화 방지
|
||||||
self._prev_oi: float | None = None # OI 변화율 계산용 이전 값
|
self._prev_oi: float | None = None # OI 변화율 계산용 이전 값
|
||||||
self.stream = MultiSymbolStream(
|
self.stream = MultiSymbolStream(
|
||||||
symbols=[config.symbol, "BTCUSDT", "ETHUSDT"],
|
symbols=[config.symbol, "BTCUSDT", "ETHUSDT"],
|
||||||
@@ -39,6 +43,8 @@ class TradingBot:
|
|||||||
if position is not None:
|
if position is not None:
|
||||||
amt = float(position["positionAmt"])
|
amt = float(position["positionAmt"])
|
||||||
self.current_trade_side = "LONG" if amt > 0 else "SHORT"
|
self.current_trade_side = "LONG" if amt > 0 else "SHORT"
|
||||||
|
self._entry_price = float(position["entryPrice"])
|
||||||
|
self._entry_quantity = abs(amt)
|
||||||
entry = float(position["entryPrice"])
|
entry = float(position["entryPrice"])
|
||||||
logger.info(
|
logger.info(
|
||||||
f"기존 포지션 복구: {self.current_trade_side} | "
|
f"기존 포지션 복구: {self.current_trade_side} | "
|
||||||
@@ -152,6 +158,8 @@ class TradingBot:
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.current_trade_side = signal
|
self.current_trade_side = signal
|
||||||
|
self._entry_price = price
|
||||||
|
self._entry_quantity = quantity
|
||||||
self.notifier.notify_open(
|
self.notifier.notify_open(
|
||||||
symbol=self.config.symbol,
|
symbol=self.config.symbol,
|
||||||
side=signal,
|
side=signal,
|
||||||
@@ -183,26 +191,57 @@ class TradingBot:
|
|||||||
reduce_only=True,
|
reduce_only=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _close_position(self, position: dict):
|
def _calc_estimated_pnl(self, exit_price: float) -> float:
|
||||||
amt = abs(float(position["positionAmt"]))
|
"""진입가·수량 기반 예상 PnL 계산 (수수료 미반영)."""
|
||||||
side = "SELL" if float(position["positionAmt"]) > 0 else "BUY"
|
if self._entry_price is None or self._entry_quantity is None or self.current_trade_side is None:
|
||||||
pos_side = "LONG" if side == "SELL" else "SHORT"
|
return 0.0
|
||||||
await self.exchange.cancel_all_orders()
|
if self.current_trade_side == "LONG":
|
||||||
await self.exchange.place_order(side=side, quantity=amt, reduce_only=True)
|
return (exit_price - self._entry_price) * self._entry_quantity
|
||||||
|
return (self._entry_price - exit_price) * self._entry_quantity
|
||||||
|
|
||||||
entry = float(position["entryPrice"])
|
async def _on_position_closed(
|
||||||
mark = float(position["markPrice"])
|
self,
|
||||||
pnl = (mark - entry) * amt if side == "SELL" else (entry - mark) * amt
|
net_pnl: float,
|
||||||
|
close_reason: str,
|
||||||
|
exit_price: float,
|
||||||
|
) -> None:
|
||||||
|
"""User Data Stream에서 청산 감지 시 호출되는 콜백."""
|
||||||
|
estimated_pnl = self._calc_estimated_pnl(exit_price)
|
||||||
|
diff = net_pnl - estimated_pnl
|
||||||
|
|
||||||
|
self.risk.record_pnl(net_pnl)
|
||||||
|
|
||||||
self.notifier.notify_close(
|
self.notifier.notify_close(
|
||||||
symbol=self.config.symbol,
|
symbol=self.config.symbol,
|
||||||
side=pos_side,
|
side=self.current_trade_side or "UNKNOWN",
|
||||||
exit_price=mark,
|
close_reason=close_reason,
|
||||||
pnl=pnl,
|
exit_price=exit_price,
|
||||||
|
estimated_pnl=estimated_pnl,
|
||||||
|
net_pnl=net_pnl,
|
||||||
|
diff=diff,
|
||||||
)
|
)
|
||||||
self.risk.record_pnl(pnl)
|
|
||||||
|
logger.success(
|
||||||
|
f"포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
|
||||||
|
f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT"
|
||||||
|
)
|
||||||
|
|
||||||
|
# _close_and_reenter 중이면 신규 포지션 상태를 덮어쓰지 않는다
|
||||||
|
if self._is_reentering:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Flat 상태로 초기화
|
||||||
self.current_trade_side = None
|
self.current_trade_side = None
|
||||||
logger.success(f"포지션 청산: PnL={pnl:.4f} USDT")
|
self._entry_price = None
|
||||||
|
self._entry_quantity = None
|
||||||
|
|
||||||
|
async def _close_position(self, position: dict):
|
||||||
|
"""포지션 청산 주문만 실행한다. PnL 기록/알림은 _on_position_closed 콜백이 담당."""
|
||||||
|
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)
|
||||||
|
logger.info(f"청산 주문 전송 완료 (side={side}, qty={amt})")
|
||||||
|
|
||||||
async def _close_and_reenter(
|
async def _close_and_reenter(
|
||||||
self,
|
self,
|
||||||
@@ -215,6 +254,9 @@ class TradingBot:
|
|||||||
funding_rate: float = 0.0,
|
funding_rate: float = 0.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다."""
|
"""기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다."""
|
||||||
|
# 재진입 플래그: User Data Stream 콜백이 신규 포지션 상태를 초기화하지 않도록 보호
|
||||||
|
self._is_reentering = True
|
||||||
|
try:
|
||||||
await self._close_position(position)
|
await self._close_position(position)
|
||||||
|
|
||||||
if not self.risk.can_open_new_position():
|
if not self.risk.can_open_new_position():
|
||||||
@@ -232,6 +274,8 @@ class TradingBot:
|
|||||||
return
|
return
|
||||||
|
|
||||||
await self._open_position(signal, df)
|
await self._open_position(signal, df)
|
||||||
|
finally:
|
||||||
|
self._is_reentering = False
|
||||||
|
|
||||||
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")
|
||||||
@@ -239,7 +283,19 @@ class TradingBot:
|
|||||||
balance = await self.exchange.get_balance()
|
balance = await self.exchange.get_balance()
|
||||||
self.risk.set_base_balance(balance)
|
self.risk.set_base_balance(balance)
|
||||||
logger.info(f"기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)")
|
logger.info(f"기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)")
|
||||||
await self.stream.start(
|
|
||||||
|
user_stream = UserDataStream(
|
||||||
|
symbol=self.config.symbol,
|
||||||
|
on_order_filled=self._on_position_closed,
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
user_stream.start(
|
||||||
|
api_key=self.config.api_key,
|
||||||
|
api_secret=self.config.api_secret,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -172,3 +172,31 @@ class BinanceFuturesClient:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"펀딩비 조회 실패 (무시): {e}")
|
logger.warning(f"펀딩비 조회 실패 (무시): {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def create_listen_key(self) -> str:
|
||||||
|
"""POST /fapi/v1/listenKey — listenKey 신규 발급"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self.client.futures_stream_get_listen_key(),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def keepalive_listen_key(self, listen_key: str) -> None:
|
||||||
|
"""PUT /fapi/v1/listenKey — listenKey 만료 연장 (60분 → 리셋)"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self.client.futures_stream_keepalive(listenKey=listen_key),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_listen_key(self, listen_key: str) -> None:
|
||||||
|
"""DELETE /fapi/v1/listenKey — listenKey 삭제 (정상 종료 시)"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self.client.futures_stream_close(listenKey=listen_key),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"listenKey 삭제 실패 (무시): {e}")
|
||||||
|
|||||||
@@ -49,13 +49,20 @@ class DiscordNotifier:
|
|||||||
self,
|
self,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
side: str,
|
side: str,
|
||||||
|
close_reason: str,
|
||||||
exit_price: float,
|
exit_price: float,
|
||||||
pnl: float,
|
estimated_pnl: float,
|
||||||
|
net_pnl: float,
|
||||||
|
diff: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
emoji = "✅" if pnl >= 0 else "❌"
|
emoji_map = {"TP": "✅", "SL": "❌", "MANUAL": "🔶"}
|
||||||
|
emoji = emoji_map.get(close_reason, "🔶")
|
||||||
msg = (
|
msg = (
|
||||||
f"{emoji} **[{symbol}] {side} 청산**\n"
|
f"{emoji} **[{symbol}] {side} {close_reason} 청산**\n"
|
||||||
f"청산가: `{exit_price:.4f}` | PnL: `{pnl:+.4f} USDT`"
|
f"청산가: `{exit_price:.4f}`\n"
|
||||||
|
f"예상 수익: `{estimated_pnl:+.4f} USDT`\n"
|
||||||
|
f"실제 순수익: `{net_pnl:+.4f} USDT`\n"
|
||||||
|
f"차이(슬리피지+수수료): `{diff:+.4f} USDT`"
|
||||||
)
|
)
|
||||||
self._send(msg)
|
self._send(msg)
|
||||||
|
|
||||||
|
|||||||
105
src/user_data_stream.py
Normal file
105
src/user_data_stream.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import Callable
|
||||||
|
from binance import AsyncClient, BinanceSocketManager
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
_RECONNECT_DELAY = 5 # 재연결 대기 초
|
||||||
|
|
||||||
|
_CLOSE_ORDER_TYPES = {"TAKE_PROFIT_MARKET", "STOP_MARKET"}
|
||||||
|
|
||||||
|
|
||||||
|
class UserDataStream:
|
||||||
|
"""
|
||||||
|
Binance Futures User Data Stream을 구독하여 주문 체결 이벤트를 처리한다.
|
||||||
|
|
||||||
|
- python-binance BinanceSocketManager의 내장 keepalive 활용
|
||||||
|
- 네트워크 단절 시 무한 재연결 루프
|
||||||
|
- ORDER_TRADE_UPDATE 이벤트에서 지정 심볼의 청산 주문만 필터링하여 콜백 호출
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
symbol: str, # 감시할 심볼 (예: "XRPUSDT")
|
||||||
|
on_order_filled: Callable, # bot._on_position_closed 콜백
|
||||||
|
):
|
||||||
|
self._symbol = symbol.upper()
|
||||||
|
self._on_order_filled = on_order_filled
|
||||||
|
|
||||||
|
async def start(self, api_key: str, api_secret: str) -> None:
|
||||||
|
"""User Data Stream 메인 루프 — 봇 종료 시까지 실행."""
|
||||||
|
client = await AsyncClient.create(
|
||||||
|
api_key=api_key,
|
||||||
|
api_secret=api_secret,
|
||||||
|
)
|
||||||
|
bm = BinanceSocketManager(client)
|
||||||
|
try:
|
||||||
|
await self._run_loop(bm)
|
||||||
|
finally:
|
||||||
|
await client.close_connection()
|
||||||
|
|
||||||
|
async def _run_loop(self, bm: BinanceSocketManager) -> None:
|
||||||
|
"""연결 → 재연결 무한 루프. BinanceSocketManager가 listenKey keepalive를 내부 처리한다."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async with bm.futures_user_socket() as stream:
|
||||||
|
logger.info(f"User Data Stream 연결 완료 (심볼 필터: {self._symbol})")
|
||||||
|
async for msg in stream:
|
||||||
|
await self._handle_message(msg)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("User Data Stream 정상 종료")
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"User Data Stream 끊김: {e} — "
|
||||||
|
f"{_RECONNECT_DELAY}초 후 재연결"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(_RECONNECT_DELAY)
|
||||||
|
|
||||||
|
async def _handle_message(self, msg: dict) -> None:
|
||||||
|
"""ORDER_TRADE_UPDATE 이벤트에서 청산 주문을 필터링하여 콜백을 호출한다."""
|
||||||
|
if msg.get("e") != "ORDER_TRADE_UPDATE":
|
||||||
|
return
|
||||||
|
|
||||||
|
order = msg.get("o", {})
|
||||||
|
|
||||||
|
# 심볼 필터링: 봇이 관리하는 심볼만 처리
|
||||||
|
if order.get("s", "") != self._symbol:
|
||||||
|
return
|
||||||
|
|
||||||
|
# x: Execution Type, X: Order Status
|
||||||
|
if order.get("x") != "TRADE" or order.get("X") != "FILLED":
|
||||||
|
return
|
||||||
|
|
||||||
|
order_type = order.get("o", "")
|
||||||
|
is_reduce = order.get("R", False)
|
||||||
|
realized_pnl = float(order.get("rp", "0"))
|
||||||
|
|
||||||
|
# 청산 주문 판별: reduceOnly이거나, TP/SL 타입이거나, rp != 0
|
||||||
|
is_close = is_reduce or order_type in _CLOSE_ORDER_TYPES or realized_pnl != 0
|
||||||
|
if not is_close:
|
||||||
|
return
|
||||||
|
|
||||||
|
commission = abs(float(order.get("n", "0")))
|
||||||
|
net_pnl = realized_pnl - commission
|
||||||
|
exit_price = float(order.get("ap", "0"))
|
||||||
|
|
||||||
|
if order_type == "TAKE_PROFIT_MARKET":
|
||||||
|
close_reason = "TP"
|
||||||
|
elif order_type == "STOP_MARKET":
|
||||||
|
close_reason = "SL"
|
||||||
|
else:
|
||||||
|
close_reason = "MANUAL"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"청산 감지({close_reason}): exit={exit_price:.4f}, "
|
||||||
|
f"rp={realized_pnl:+.4f}, commission={commission:.4f}, "
|
||||||
|
f"net_pnl={net_pnl:+.4f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._on_order_filled(
|
||||||
|
net_pnl=net_pnl,
|
||||||
|
close_reason=close_reason,
|
||||||
|
exit_price=exit_price,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user