Compare commits
26 Commits
dcdaf9f90a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fe2158511 | ||
|
|
3613e3bf18 | ||
|
|
fce4d536ea | ||
|
|
74966590b5 | ||
|
|
6cd54b46d9 | ||
|
|
0af138d8ee | ||
|
|
b7ad358a0a | ||
|
|
8e56301d52 | ||
|
|
99fa508db7 | ||
|
|
eeb5e9d877 | ||
|
|
c8a2c36bfb | ||
|
|
b8b99da207 | ||
|
|
77590accf2 | ||
|
|
a8cba2cb4c | ||
|
|
52affb5532 | ||
|
|
05ae88dc61 | ||
|
|
6237efe4d3 | ||
|
|
4e8e61b5cf | ||
|
|
4ffee0ae8b | ||
|
|
7e7f0f4f22 | ||
|
|
c4f806fc35 | ||
|
|
22f1debb3d | ||
|
|
4f3183df47 | ||
|
|
223608bec0 | ||
|
|
e72126516b | ||
|
|
63c2eb8927 |
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"superpowers@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
646
ARCHITECTURE.md
Normal file
646
ARCHITECTURE.md
Normal file
@@ -0,0 +1,646 @@
|
||||
# CoinTrader — 아키텍처 문서
|
||||
|
||||
> 이 문서는 CoinTrader 코드베이스를 처음 접하는 개발자와 트레이딩 배경 독자 모두를 위해 작성되었습니다.
|
||||
> 기술 스택, 레이어별 역할, MLOps 파이프라인, 핵심 동작 시나리오를 순서대로 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [시스템 오버뷰](#1-시스템-오버뷰)
|
||||
2. [코어 레이어 아키텍처](#2-코어-레이어-아키텍처)
|
||||
3. [MLOps 파이프라인 — 자가 진화 시스템](#3-mlops-파이프라인--자가-진화-시스템)
|
||||
4. [핵심 동작 시나리오](#4-핵심-동작-시나리오)
|
||||
5. [테스트 커버리지](#5-테스트-커버리지)
|
||||
|
||||
---
|
||||
|
||||
## 1. 시스템 오버뷰
|
||||
|
||||
CoinTrader는 **Binance Futures 자동매매 봇**입니다. 기술 지표 신호를 1차 필터로, LightGBM(또는 MLX 신경망) 모델을 2차 필터로 사용하여 XRPUSDT 선물 포지션을 자동 진입·청산합니다.
|
||||
|
||||
### 전체 데이터 파이프라인 흐름도
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph 외부["외부 데이터 소스 (Binance)"]
|
||||
WS1["Combined WebSocket<br/>XRP/BTC/ETH 15분봉 캔들"]
|
||||
WS2["User Data Stream WebSocket<br/>ORDER_TRADE_UPDATE 이벤트"]
|
||||
REST["REST API<br/>OI·펀딩비·잔고·포지션 조회"]
|
||||
end
|
||||
|
||||
subgraph 실시간봇["실시간 봇 (bot.py — asyncio)"]
|
||||
DS["data_stream.py<br/>MultiSymbolStream<br/>캔들 버퍼 (deque 200개)"]
|
||||
IND["indicators.py<br/>기술 지표 계산<br/>RSI·MACD·BB·EMA·StochRSI·ATR·ADX"]
|
||||
MF["ml_features.py<br/>23개 피처 추출<br/>(XRP 13 + BTC/ETH 8 + OI/FR 2)"]
|
||||
ML["ml_filter.py<br/>MLFilter<br/>ONNX 우선 / LightGBM 폴백<br/>확률 ≥ 0.60 시 진입 허용"]
|
||||
RM["risk_manager.py<br/>RiskManager<br/>일일 손실 5% 한도<br/>동적 증거금 비율"]
|
||||
EX["exchange.py<br/>BinanceFuturesClient<br/>주문·레버리지·잔고 API"]
|
||||
UDS["user_data_stream.py<br/>UserDataStream<br/>TP/SL 즉시 감지"]
|
||||
NT["notifier.py<br/>DiscordNotifier<br/>진입·청산·오류 알림"]
|
||||
end
|
||||
|
||||
subgraph mlops["MLOps 파이프라인 (맥미니 — 수동/크론)"]
|
||||
FH["fetch_history.py<br/>과거 캔들 + OI/펀딩비<br/>Parquet Upsert"]
|
||||
DB["dataset_builder.py<br/>벡터화 데이터셋 생성<br/>레이블: ATR SL/TP 6시간 룩어헤드"]
|
||||
TM["train_model.py<br/>LightGBM 학습<br/>Walk-Forward 5폴드 검증"]
|
||||
TN["tune_hyperparams.py<br/>Optuna 50 trials<br/>TPE + MedianPruner"]
|
||||
AP["active_lgbm_params.json<br/>Active Config 패턴<br/>승인된 파라미터 저장"]
|
||||
DM["deploy_model.sh<br/>rsync → LXC 서버<br/>봇 핫리로드 트리거"]
|
||||
end
|
||||
|
||||
WS1 -->|캔들 마감 이벤트| DS
|
||||
WS2 -->|체결 이벤트| UDS
|
||||
REST -->|OI·펀딩비| MF
|
||||
DS -->|DataFrame| IND
|
||||
IND -->|신호 + 지표값| MF
|
||||
MF -->|피처 Series| ML
|
||||
ML -->|진입 허용/차단| RM
|
||||
RM -->|주문 승인| EX
|
||||
EX -->|체결 결과| NT
|
||||
UDS -->|net_pnl·청산 사유| NT
|
||||
UDS -->|상태 초기화| DS
|
||||
|
||||
FH -->|combined_15m.parquet| DB
|
||||
DB -->|X, y, w| TM
|
||||
TM -->|lgbm_filter.pkl| DM
|
||||
TN -->|Best Params| AP
|
||||
AP -->|파라미터 반영| TM
|
||||
DM -->|모델 파일 전송| ML
|
||||
```
|
||||
|
||||
### 기술 스택 요약
|
||||
|
||||
| 분류 | 기술 |
|
||||
|------|------|
|
||||
| 언어 | Python 3.11+ |
|
||||
| 비동기 런타임 | `asyncio` + `python-binance` WebSocket |
|
||||
| 기술 지표 | `pandas-ta` (RSI, MACD, BB, EMA, StochRSI, ATR) |
|
||||
| ML 프레임워크 | `LightGBM` (CPU) / `MLX` (Apple Silicon GPU) |
|
||||
| 모델 서빙 | `onnxruntime` (ONNX 우선) / `joblib` (LightGBM 폴백) |
|
||||
| 하이퍼파라미터 탐색 | `Optuna` (TPE Sampler + MedianPruner) |
|
||||
| 데이터 저장 | `Parquet` (pyarrow) |
|
||||
| 로깅 | `Loguru` |
|
||||
| 알림 | Discord Webhook (`httpx`) |
|
||||
| 컨테이너화 | Docker + Docker Compose |
|
||||
| CI/CD | Jenkins + Gitea Container Registry |
|
||||
| 운영 서버 | LXC 컨테이너 (`10.1.10.24`) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 코어 레이어 아키텍처
|
||||
|
||||
봇은 5개의 레이어로 구성됩니다. 각 레이어는 단일 책임을 가지며, 위에서 아래로 데이터가 흐릅니다.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Layer 1: Data Layer data_stream.py │
|
||||
│ 캔들 수신 · 버퍼 관리 · 과거 데이터 프리로드 │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Layer 2: Signal Layer indicators.py │
|
||||
│ 기술 지표 계산 · 복합 신호 생성 │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Layer 3: ML Filter Layer ml_filter.py │
|
||||
│ LightGBM/ONNX 확률 예측 · 진입 차단 │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Layer 4: Execution & Risk exchange.py │
|
||||
│ Layer risk_manager.py │
|
||||
│ 주문 실행 · 포지션 관리 · 리스크 제어 │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Layer 5: Event / Alert user_data_stream.py │
|
||||
│ Layer notifier.py │
|
||||
│ TP/SL 즉시 감지 · Discord 알림 │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Layer 1: Data Layer
|
||||
|
||||
**파일:** `src/data_stream.py`
|
||||
|
||||
봇이 시작되면 가장 먼저 실행되는 레이어입니다. Binance Combined WebSocket 단일 연결로 XRP·BTC·ETH 3개 심볼의 15분봉 캔들을 동시에 수신합니다.
|
||||
|
||||
**핵심 동작:**
|
||||
|
||||
1. **프리로드**: 봇 시작 시 REST API로 과거 캔들 200개를 `deque`에 즉시 채웁니다. EMA50 안정화에 필요한 최소 캔들(100개)을 확보하여 첫 캔들부터 신호를 계산할 수 있게 합니다.
|
||||
2. **버퍼 관리**: 심볼별 `deque(maxlen=200)`에 마감된 캔들만 추가합니다. 미마감 캔들(`is_closed=False`)은 무시합니다.
|
||||
3. **콜백 트리거**: XRP(주 심볼) 캔들이 마감되면 `bot._on_candle_closed()`를 호출합니다. BTC·ETH는 버퍼에만 쌓이고 콜백을 트리거하지 않습니다.
|
||||
|
||||
```
|
||||
Combined WebSocket
|
||||
├── xrpusdt@kline_15m → buffers["xrpusdt"] → on_candle() 호출
|
||||
├── btcusdt@kline_15m → buffers["btcusdt"] (콜백 없음)
|
||||
└── ethusdt@kline_15m → buffers["ethusdt"] (콜백 없음)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Layer 2: Signal Layer
|
||||
|
||||
**파일:** `src/indicators.py`
|
||||
|
||||
`pandas-ta` 라이브러리로 기술 지표를 계산하고, 복합 가중치 시스템으로 매매 신호를 생성합니다.
|
||||
|
||||
**계산되는 지표:**
|
||||
|
||||
| 지표 | 파라미터 | 역할 |
|
||||
|------|---------|------|
|
||||
| RSI | length=14 | 과매수/과매도 판단 |
|
||||
| MACD | (12, 26, 9) | 추세 전환 감지 (골든/데드크로스) |
|
||||
| 볼린저 밴드 | (20, 2σ) | 가격 이탈 감지 |
|
||||
| EMA | (9, 21, 50) | 추세 방향 (정배열/역배열) |
|
||||
| Stochastic RSI | (14, 14, 3, 3) | 단기 과매수/과매도 |
|
||||
| ATR | length=14 | 변동성 측정 → SL/TP 계산에 사용 |
|
||||
| ADX | length=14 | 추세 강도 측정 → 횡보장 필터 (ADX < 25 시 진입 차단) |
|
||||
| Volume MA | length=20 | 거래량 급증 감지 |
|
||||
|
||||
**신호 생성 로직 (ADX 필터 + 가중치 합산):**
|
||||
|
||||
```
|
||||
[1단계] ADX 횡보장 필터:
|
||||
ADX < 25 → 즉시 HOLD 반환 (추세 부재로 진입 차단)
|
||||
|
||||
[2단계] 롱 신호 점수:
|
||||
RSI < 35 → +1
|
||||
MACD 골든크로스 (전봉→현봉) → +2 ← 강한 신호
|
||||
종가 < 볼린저 하단 → +1
|
||||
EMA 정배열 (9 > 21 > 50) → +1
|
||||
StochRSI K < 20 and K > D → +1
|
||||
|
||||
진입 조건: 점수 ≥ 3 AND (거래량 급증 OR 점수 ≥ 4)
|
||||
SL = 진입가 - ATR × 1.5
|
||||
TP = 진입가 + ATR × 3.0 (리스크:리워드 = 1:2)
|
||||
```
|
||||
|
||||
숏 신호는 롱의 대칭 조건으로 계산됩니다.
|
||||
|
||||
---
|
||||
|
||||
### Layer 3: ML Filter Layer
|
||||
|
||||
**파일:** `src/ml_filter.py`, `src/ml_features.py`
|
||||
|
||||
기술 지표 신호가 발생해도 ML 모델이 "이 타점은 실패 확률이 높다"고 판단하면 진입을 차단합니다. 오진입(억까 타점)을 줄이는 2차 게이트키퍼입니다.
|
||||
|
||||
**모델 우선순위:**
|
||||
|
||||
```
|
||||
ONNX (MLX 신경망) → LightGBM → 폴백(항상 허용)
|
||||
```
|
||||
|
||||
모델 파일이 없으면 모든 신호를 허용합니다. 봇 재시작 없이 모델 파일을 교체하면 다음 캔들 마감 시 자동으로 핫리로드됩니다(`mtime` 감지).
|
||||
|
||||
**23개 ML 피처:**
|
||||
|
||||
```
|
||||
XRP 기술 지표 (13개):
|
||||
rsi, macd_hist, bb_pct, ema_align, stoch_k, stoch_d,
|
||||
atr_pct, vol_ratio, ret_1, ret_3, ret_5,
|
||||
signal_strength, side
|
||||
|
||||
BTC/ETH 상관관계 (8개):
|
||||
btc_ret_1, btc_ret_3, btc_ret_5,
|
||||
eth_ret_1, eth_ret_3, eth_ret_5,
|
||||
xrp_btc_rs, xrp_eth_rs
|
||||
|
||||
시장 미시구조 (2개):
|
||||
oi_change ← 이전 캔들 대비 미결제약정 변화율
|
||||
funding_rate ← 현재 펀딩비
|
||||
```
|
||||
|
||||
`oi_change`와 `funding_rate`는 캔들 마감마다 Binance REST API로 실시간 조회합니다. API 실패 시 `0.0`으로 폴백하여 봇이 멈추지 않습니다.
|
||||
|
||||
**진입 판단:**
|
||||
|
||||
```python
|
||||
proba = model.predict_proba(features)[0][1] # 성공 확률
|
||||
return proba >= 0.60 # 임계값 60%
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Layer 4: Execution & Risk Layer
|
||||
|
||||
**파일:** `src/exchange.py`, `src/risk_manager.py`
|
||||
|
||||
ML 필터를 통과한 신호를 실제 주문으로 변환하고, 리스크 한도를 관리합니다.
|
||||
|
||||
**포지션 크기 계산 (동적 증거금 비율):**
|
||||
|
||||
잔고가 늘어날수록 증거금 비율을 선형으로 줄여 복리 과노출을 방지합니다.
|
||||
|
||||
```
|
||||
증거금 비율 = max(20%, min(50%, 50% - (잔고 - 기준잔고) × 0.0006))
|
||||
명목금액 = 잔고 × 증거금 비율 × 레버리지
|
||||
수량 = 명목금액 / 현재가
|
||||
```
|
||||
|
||||
**주문 흐름:**
|
||||
|
||||
```
|
||||
1. set_leverage(10x)
|
||||
2. place_order(MARKET) ← 진입
|
||||
3. place_order(STOP_MARKET) ← SL 설정
|
||||
4. place_order(TAKE_PROFIT_MARKET) ← TP 설정
|
||||
```
|
||||
|
||||
SL/TP 주문은 `/fapi/v1/algoOrder` 엔드포인트로 전송됩니다 (일반 계정의 `-4120` 오류 대응).
|
||||
|
||||
**리스크 제어:**
|
||||
|
||||
| 제어 항목 | 기준 |
|
||||
|----------|------|
|
||||
| 일일 최대 손실 | 기준 잔고의 5% |
|
||||
| 최대 동시 포지션 | 3개 |
|
||||
| 최소 명목금액 | $5 USDT |
|
||||
|
||||
**반대 시그널 재진입:** 보유 포지션과 반대 방향 신호 발생 시 기존 포지션을 즉시 청산하고, ML 필터 통과 시 반대 방향으로 재진입합니다. 재진입 중 User Data Stream 콜백이 신규 포지션 상태를 덮어쓰지 않도록 `_is_reentering` 플래그로 보호합니다.
|
||||
|
||||
---
|
||||
|
||||
### Layer 5: Event / Alert Layer
|
||||
|
||||
**파일:** `src/user_data_stream.py`, `src/notifier.py`
|
||||
|
||||
기존 폴링 방식(캔들 마감마다 포지션 조회)의 한계를 극복하기 위해 도입된 레이어입니다.
|
||||
|
||||
**User Data Stream의 역할:**
|
||||
|
||||
Binance `ORDER_TRADE_UPDATE` 웹소켓 이벤트를 구독하여 TP/SL 체결을 **즉시** 감지합니다. 기존 방식은 최대 15분 지연이 발생했지만, 이제 체결 즉시 콜백이 호출됩니다.
|
||||
|
||||
```
|
||||
이벤트 필터링 조건:
|
||||
e == "ORDER_TRADE_UPDATE"
|
||||
AND s == "XRPUSDT" ← 심볼 필터
|
||||
AND x == "TRADE" ← 실제 체결
|
||||
AND X == "FILLED" ← 완전 체결
|
||||
AND (reduceOnly OR order_type in {STOP_MARKET, TAKE_PROFIT_MARKET} OR rp != 0)
|
||||
```
|
||||
|
||||
청산 사유 분류:
|
||||
- `TAKE_PROFIT_MARKET` → `"TP"`
|
||||
- `STOP_MARKET` → `"SL"`
|
||||
- 그 외 → `"MANUAL"`
|
||||
|
||||
순수익 계산:
|
||||
```
|
||||
net_pnl = realized_pnl - commission
|
||||
```
|
||||
|
||||
**Discord 알림 포맷:**
|
||||
|
||||
진입 시:
|
||||
```
|
||||
[XRPUSDT] LONG 진입
|
||||
진입가: 2.3450 | 수량: 100.0 | 레버리지: 10x
|
||||
SL: 2.3100 | TP: 2.4150
|
||||
RSI: 32.50 | MACD Hist: -0.000123 | ATR: 0.023400
|
||||
```
|
||||
|
||||
청산 시:
|
||||
```
|
||||
✅ [XRPUSDT] LONG TP 청산
|
||||
청산가: 2.4150
|
||||
예상 수익: +7.0000 USDT
|
||||
실제 순수익: +6.7800 USDT
|
||||
차이(슬리피지+수수료): -0.2200 USDT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. MLOps 파이프라인 — 자가 진화 시스템
|
||||
|
||||
봇의 ML 모델은 고정된 것이 아니라 주기적으로 재학습·개선됩니다. 전체 라이프사이클은 다음과 같습니다.
|
||||
|
||||
### 3.1 전체 라이프사이클
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["주말 수동 트리거<br/>tune_hyperparams.py<br/>(Optuna 50 trials, ~30분)"]
|
||||
B["결과 검토<br/>tune_results_YYYYMMDD.json<br/>Best AUC vs Baseline 비교"]
|
||||
C{"개선폭 충분?<br/>(AUC +0.01 이상<br/>폴드 분산 낮음)"}
|
||||
D["active_lgbm_params.json<br/>업데이트<br/>(Active Config 패턴)"]
|
||||
E["새벽 2시 크론탭<br/>train_and_deploy.sh<br/>(데이터 수집 → 학습 → 배포)"]
|
||||
F["LXC 서버<br/>lgbm_filter.pkl 교체"]
|
||||
G["봇 핫리로드<br/>다음 캔들 mtime 감지<br/>→ 자동 리로드"]
|
||||
|
||||
A --> B
|
||||
B --> C
|
||||
C -->|Yes| D
|
||||
C -->|No| A
|
||||
D --> E
|
||||
E --> F
|
||||
F --> G
|
||||
G --> A
|
||||
```
|
||||
|
||||
### 3.2 단계별 상세 설명
|
||||
|
||||
#### Step 1: Optuna 하이퍼파라미터 탐색
|
||||
|
||||
`scripts/tune_hyperparams.py`는 LightGBM의 9개 하이퍼파라미터를 자동으로 탐색합니다.
|
||||
|
||||
- **알고리즘**: TPE Sampler (Tree-structured Parzen Estimator) — 베이지안 최적화 계열
|
||||
- **조기 종료**: MedianPruner — 중간 폴드 AUC가 중앙값 미만이면 trial 조기 종료
|
||||
- **평가 지표**: Walk-Forward 5폴드 평균 AUC (시계열 순서 유지, 미래 데이터 누수 방지)
|
||||
- **클래스 불균형 처리**: 언더샘플링 (양성:음성 = 1:1, 시간 순서 유지)
|
||||
|
||||
탐색 공간:
|
||||
|
||||
```
|
||||
n_estimators: 100 ~ 600
|
||||
learning_rate: 0.01 ~ 0.20 (log scale)
|
||||
max_depth: 2 ~ 7
|
||||
num_leaves: 7 ~ min(31, 2^max_depth - 1) ← 과적합 방지 제약
|
||||
min_child_samples: 10 ~ 50
|
||||
subsample: 0.5 ~ 1.0
|
||||
colsample_bytree: 0.5 ~ 1.0
|
||||
reg_alpha: 1e-4 ~ 1.0 (log scale)
|
||||
reg_lambda: 1e-4 ~ 1.0 (log scale)
|
||||
```
|
||||
|
||||
결과는 `models/tune_results_YYYYMMDD_HHMMSS.json`에 저장됩니다.
|
||||
|
||||
#### Step 2: Active Config 패턴으로 파라미터 승인
|
||||
|
||||
Optuna가 찾은 파라미터는 **자동으로 적용되지 않습니다.** 사람이 결과를 검토하고 직접 `models/active_lgbm_params.json`을 업데이트해야 합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"promoted_at": "2026-03-02T14:47:49",
|
||||
"best_trial": {
|
||||
"number": 23,
|
||||
"value": 0.6821,
|
||||
"params": {
|
||||
"n_estimators": 434,
|
||||
"learning_rate": 0.123659,
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`train_model.py`는 학습 시작 시 이 파일을 읽어 파라미터를 적용합니다. 파일이 없으면 코드 내 기본값을 사용합니다.
|
||||
|
||||
> **주의**: Optuna 결과는 과적합 위험이 있습니다. 폴드별 AUC 분산이 크거나 (std > 0.05), 개선폭이 미미하면 (< 0.01) 적용하지 않는 것을 권장합니다.
|
||||
|
||||
#### Step 3: 자동 학습 및 배포 (크론탭)
|
||||
|
||||
`scripts/train_and_deploy.sh`는 3단계를 자동으로 실행합니다:
|
||||
|
||||
```
|
||||
[1/3] 데이터 수집 (fetch_history.py)
|
||||
- 기존 parquet 없음 → 1년치(365일) 전체 수집
|
||||
- 기존 parquet 있음 → 35일치 Upsert (OI/펀딩비 0.0 구간 보충)
|
||||
|
||||
[2/3] 모델 학습 (train_model.py)
|
||||
- active_lgbm_params.json 파라미터 로드
|
||||
- 벡터화 데이터셋 생성 (dataset_builder.py)
|
||||
- Walk-Forward 5폴드 검증 후 최종 모델 저장
|
||||
- 학습 로그: models/training_log.json
|
||||
|
||||
[3/3] LXC 배포 (deploy_model.sh)
|
||||
- rsync로 lgbm_filter.pkl → LXC 서버 전송
|
||||
- 기존 모델 자동 백업 (lgbm_filter_prev.pkl)
|
||||
- ONNX 파일 충돌 방지 (우선순위 보장)
|
||||
```
|
||||
|
||||
#### Step 4: 봇 핫리로드
|
||||
|
||||
모델 파일이 교체되면 봇 재시작 없이 자동으로 새 모델이 적용됩니다.
|
||||
|
||||
```python
|
||||
# bot.py → process_candle() 첫 줄
|
||||
self.ml_filter.check_and_reload()
|
||||
|
||||
# ml_filter.py → check_and_reload()
|
||||
onnx_changed = _mtime(self._onnx_path) != self._loaded_onnx_mtime
|
||||
lgbm_changed = _mtime(self._lgbm_path) != self._loaded_lgbm_mtime
|
||||
if onnx_changed or lgbm_changed:
|
||||
self._try_load() # 새 모델 로드
|
||||
```
|
||||
|
||||
매 캔들 마감(15분)마다 모델 파일의 `mtime`을 확인합니다. 변경이 감지되면 즉시 리로드합니다.
|
||||
|
||||
### 3.3 레이블 생성 방식
|
||||
|
||||
학습 데이터의 레이블은 **미래 6시간(24캔들) 룩어헤드**로 생성됩니다.
|
||||
|
||||
```
|
||||
신호 발생 시점 기준:
|
||||
SL = 진입가 - ATR × 1.5
|
||||
TP = 진입가 + ATR × 3.0
|
||||
|
||||
향후 24캔들 동안:
|
||||
- 저가가 SL에 먼저 닿으면 → label = 0 (실패)
|
||||
- 고가가 TP에 먼저 닿으면 → label = 1 (성공)
|
||||
- 둘 다 안 닿으면 → 샘플 제외
|
||||
```
|
||||
|
||||
보수적 접근: SL 체크를 TP보다 먼저 수행하여 동시 돌파 시 실패로 처리합니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 핵심 동작 시나리오
|
||||
|
||||
### 시나리오 1: 15분 캔들 마감 시 봇의 동작 흐름
|
||||
|
||||
> "XRP 15분봉이 마감되면 봇은 무엇을 하는가?"
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant WS as Binance WebSocket
|
||||
participant DS as data_stream.py
|
||||
participant BOT as bot.py
|
||||
participant IND as indicators.py
|
||||
participant MF as ml_features.py
|
||||
participant ML as ml_filter.py
|
||||
participant RM as risk_manager.py
|
||||
participant EX as exchange.py
|
||||
participant NT as notifier.py
|
||||
|
||||
WS->>DS: kline 이벤트 (is_closed=True)
|
||||
DS->>DS: buffers["xrpusdt"].append(candle)
|
||||
DS->>BOT: on_candle_closed(candle) 콜백
|
||||
|
||||
BOT->>BOT: ml_filter.check_and_reload() [mtime 확인]
|
||||
BOT->>EX: get_open_interest() + get_funding_rate() [병렬]
|
||||
BOT->>RM: is_trading_allowed() [일일 손실 한도 확인]
|
||||
|
||||
BOT->>IND: calculate_all(xrp_df) [지표 계산]
|
||||
IND-->>BOT: df_with_indicators (RSI, MACD, BB, EMA, StochRSI, ATR, ADX)
|
||||
BOT->>IND: get_signal(df) [신호 생성]
|
||||
IND-->>BOT: "LONG" | "SHORT" | "HOLD"
|
||||
|
||||
alt 신호 = LONG 또는 SHORT, 포지션 없음
|
||||
BOT->>MF: build_features(df, signal, btc_df, eth_df, oi_change, funding_rate)
|
||||
MF-->>BOT: features (23개 피처 Series)
|
||||
BOT->>ML: should_enter(features)
|
||||
ML-->>BOT: proba=0.73 ≥ 0.60 → True
|
||||
|
||||
BOT->>EX: get_balance()
|
||||
BOT->>RM: get_dynamic_margin_ratio(balance)
|
||||
BOT->>EX: set_leverage(10)
|
||||
BOT->>EX: place_order(MARKET, BUY, qty=100.0)
|
||||
BOT->>EX: place_order(STOP_MARKET, SELL, stop=2.3100)
|
||||
BOT->>EX: place_order(TAKE_PROFIT_MARKET, SELL, stop=2.4150)
|
||||
BOT->>NT: notify_open(진입가, SL, TP, RSI, MACD, ATR)
|
||||
|
||||
else 신호 = HOLD 또는 ML 차단
|
||||
BOT->>BOT: 대기 (다음 캔들까지)
|
||||
end
|
||||
```
|
||||
|
||||
**핵심 포인트:**
|
||||
- OI·펀딩비 조회는 `asyncio.gather()`로 병렬 실행 → 지연 최소화
|
||||
- ML 필터가 없으면(모델 파일 없음) 모든 신호를 허용
|
||||
- 명목금액 < $5 USDT이면 주문을 건너뜀 (바이낸스 최소 주문 제약)
|
||||
|
||||
---
|
||||
|
||||
### 시나리오 2: TP/SL 체결 시 봇의 동작 흐름
|
||||
|
||||
> "거래소에서 TP가 작동하면 봇은 어떻게 반응하는가?"
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant BN as Binance
|
||||
participant UDS as user_data_stream.py
|
||||
participant BOT as bot.py
|
||||
participant RM as risk_manager.py
|
||||
participant NT as notifier.py
|
||||
|
||||
BN->>UDS: ORDER_TRADE_UPDATE 이벤트
|
||||
Note over UDS: e="ORDER_TRADE_UPDATE"<br/>s="XRPUSDT"<br/>x="TRADE", X="FILLED"<br/>o="TAKE_PROFIT_MARKET"<br/>rp="+7.0000", n="0.2200"
|
||||
|
||||
UDS->>UDS: 심볼 필터 (XRPUSDT만 처리)
|
||||
UDS->>UDS: 청산 주문 판별 (reduceOnly or TP/SL type)
|
||||
UDS->>UDS: net_pnl = 7.0000 - 0.2200 = 6.7800
|
||||
UDS->>UDS: close_reason = "TP"
|
||||
|
||||
UDS->>BOT: _on_position_closed(net_pnl=6.78, reason="TP", exit_price=2.4150)
|
||||
|
||||
BOT->>BOT: estimated_pnl = (2.4150 - 2.3450) × 100 = 7.0000
|
||||
BOT->>BOT: diff = 6.7800 - 7.0000 = -0.2200
|
||||
|
||||
BOT->>RM: record_pnl(6.7800) [일일 누적 PnL 갱신]
|
||||
|
||||
BOT->>NT: notify_close(TP, exit=2.4150, est=+7.00, net=+6.78, diff=-0.22)
|
||||
NT->>NT: Discord 웹훅 전송
|
||||
|
||||
BOT->>BOT: current_trade_side = None
|
||||
BOT->>BOT: _entry_price = None
|
||||
BOT->>BOT: _entry_quantity = None
|
||||
Note over BOT: Flat 상태로 초기화 완료
|
||||
```
|
||||
|
||||
**핵심 포인트:**
|
||||
- User Data Stream은 `asyncio.gather()`로 캔들 스트림과 **병렬** 실행
|
||||
- 체결 즉시 감지 (최대 15분 지연이었던 폴링 방식 대비 실시간)
|
||||
- `realized_pnl - commission` = 정확한 순수익 (슬리피지·수수료 포함)
|
||||
- `_is_reentering` 플래그: 반대 시그널 재진입 중에는 콜백이 신규 포지션 상태를 초기화하지 않음
|
||||
|
||||
---
|
||||
|
||||
## 5. 테스트 커버리지
|
||||
|
||||
### 5.1 테스트 파일 구성
|
||||
|
||||
`tests/` 폴더에 12개 테스트 파일, 총 **81개의 테스트 케이스**가 작성되어 있습니다.
|
||||
|
||||
```bash
|
||||
pytest tests/ -v # 전체 실행
|
||||
bash scripts/run_tests.sh # 래퍼 스크립트 실행
|
||||
```
|
||||
|
||||
### 5.2 모듈별 테스트 현황
|
||||
|
||||
| 테스트 파일 | 대상 모듈 | 테스트 케이스 | 주요 검증 항목 |
|
||||
|------------|----------|:------------:|--------------|
|
||||
| `test_bot.py` | `src/bot.py` | 11 | 반대 시그널 재진입 흐름, ML 차단 시 재진입 스킵, OI/펀딩비 피처 전달, OI 변화율 계산 |
|
||||
| `test_indicators.py` | `src/indicators.py` | 7 | RSI 범위(0~100), MACD 컬럼 존재, 볼린저 밴드 상하단 대소관계, 신호 반환값 유효성, ADX 컬럼 존재, ADX<25 횡보장 차단, ADX NaN 폴스루 |
|
||||
| `test_ml_features.py` | `src/ml_features.py` | 11 | 23개 피처 수, BTC/ETH 포함 시 피처 수, RS 분모 0 처리, NaN 없음, side 인코딩, OI/펀딩비 파라미터 반영 |
|
||||
| `test_ml_filter.py` | `src/ml_filter.py` | 5 | 모델 없을 때 폴백 허용, 임계값 이상/미만 판단, 핫리로드 후 상태 변화 |
|
||||
| `test_risk_manager.py` | `src/risk_manager.py` | 8 | 일일 손실 한도 초과 차단, 최대 포지션 수 제한, 동적 증거금 비율 상한/하한 클램핑 |
|
||||
| `test_exchange.py` | `src/exchange.py` | 8 | 수량 계산(기본/최소명목금액/잔고0), OI·펀딩비 조회 정상/오류 시 반환값 |
|
||||
| `test_data_stream.py` | `src/data_stream.py` | 6 | 3심볼 버퍼 존재, 빈 버퍼 None 반환, 캔들 파싱, 마감 캔들 콜백 호출, 프리로드 200개 |
|
||||
| `test_label_builder.py` | `src/label_builder.py` | 4 | LONG TP 도달 → 1, LONG SL 도달 → 0, 미결 → None, SHORT TP 도달 → 1 |
|
||||
| `test_dataset_builder.py` | `src/dataset_builder.py` | 9 | DataFrame 반환, 필수 컬럼 존재, 레이블 이진값, BTC/ETH 포함 시 23개 피처, inf/NaN 없음, OI nan 마스킹, RS 분모 0 처리 |
|
||||
| `test_mlx_filter.py` | `src/mlx_filter.py` | 5 | GPU 디바이스 확인, 학습 전 예측 형태, 학습 후 유효 확률, NaN 피처 처리, 저장/로드 후 동일 예측 |
|
||||
| `test_fetch_history.py` | `scripts/fetch_history.py` | 5 | OI=0 구간 Upsert, 신규 행 추가, 기존 비0값 보존, 파일 없을 때 신규 반환, 타임스탬프 오름차순 정렬 |
|
||||
| `test_config.py` | `src/config.py` | 2 | 환경변수 로드, 동적 증거금 파라미터 로드 |
|
||||
|
||||
> `test_mlx_filter.py`는 Apple Silicon(`mlx` 패키지)이 없는 환경에서 자동 스킵됩니다.
|
||||
|
||||
### 5.3 커버리지 매트릭스
|
||||
|
||||
아래는 핵심 비즈니스 로직의 테스트 커버 여부입니다.
|
||||
|
||||
| 기능 | 단위 테스트 | 통합 수준 테스트 | 비고 |
|
||||
|------|:----------:|:--------------:|------|
|
||||
| 기술 지표 계산 (RSI/MACD/BB/EMA/StochRSI/ADX) | ✅ | ✅ | `test_indicators` + `test_ml_features` + `test_dataset_builder` |
|
||||
| 신호 생성 (가중치 합산) | ✅ | ✅ | `test_indicators` + `test_dataset_builder` |
|
||||
| ADX 횡보장 필터 (ADX < 25 차단) | ✅ | ✅ | `test_indicators` + `test_dataset_builder` (`_calc_signals` 실제 호출) |
|
||||
| ML 피처 추출 (23개) | ✅ | ✅ | `test_ml_features` + `test_dataset_builder` (`_calc_features_vectorized` 실제 호출) |
|
||||
| ML 필터 추론 (임계값 판단) | ✅ | — | `test_ml_filter` |
|
||||
| MLX 신경망 학습/저장/로드 | ✅ | — | `test_mlx_filter` (Apple Silicon 전용) |
|
||||
| 레이블 생성 (SL/TP 룩어헤드) | ✅ | ✅ | `test_label_builder` + `test_dataset_builder` (전체 파이프라인 실제 호출) |
|
||||
| 벡터화 데이터셋 빌더 | ✅ | ✅ | `test_dataset_builder` |
|
||||
| 동적 증거금 비율 계산 | ✅ | — | `test_risk_manager` |
|
||||
| 일일 손실 한도 제어 | ✅ | — | `test_risk_manager` |
|
||||
| 포지션 수량 계산 | ✅ | — | `test_exchange` |
|
||||
| OI/펀딩비 API 조회 (정상/오류) | ✅ | ✅ | `test_exchange` + `test_bot` (`process_candle` → OI/펀딩비 → `build_features` 전달) |
|
||||
| 반대 시그널 재진입 흐름 | ✅ | ✅ | `test_bot` |
|
||||
| ML 차단 시 재진입 스킵 | ✅ | ✅ | `test_bot` (`_close_and_reenter` → ML 판단 → 스킵 전체 흐름) |
|
||||
| OI 변화율 계산 (API 실패 폴백) | ✅ | ✅ | `test_bot` (`process_candle` → OI 조회 → `_calc_oi_change` 흐름) |
|
||||
| 캔들 버퍼 관리 및 프리로드 | ✅ | — | `test_data_stream` |
|
||||
| Parquet Upsert (OI=0 보충) | ✅ | — | `test_fetch_history` |
|
||||
| User Data Stream TP/SL 감지 | ❌ | — | 미작성 (실제 WebSocket 의존) |
|
||||
| Discord 알림 전송 | ❌ | — | 미작성 (외부 웹훅 의존) |
|
||||
| CI/CD 파이프라인 | ❌ | — | Jenkins 환경 의존 |
|
||||
|
||||
### 5.4 테스트 전략
|
||||
|
||||
**Mock 활용 원칙:**
|
||||
- Binance API 호출(`BinanceFuturesClient`, `AsyncClient`)은 모두 `unittest.mock.AsyncMock`으로 대체합니다.
|
||||
- 외부 의존성(Discord Webhook, Binance WebSocket)은 테스트 대상에서 제외합니다.
|
||||
- `tmp_path` pytest fixture로 Parquet 파일 I/O를 격리합니다.
|
||||
|
||||
**비동기 테스트:**
|
||||
- `pytest-asyncio`를 사용하며, `@pytest.mark.asyncio` 데코레이터로 `async def` 테스트를 실행합니다.
|
||||
|
||||
**경계값 및 엣지 케이스 중심:**
|
||||
- 분모 0 (RS 계산, bb_range, vol_ma20)
|
||||
- API 실패 시 `None` 반환 및 `0.0` 폴백
|
||||
- 최소 명목금액 미달 시 주문 스킵
|
||||
- OI=0 구간 Parquet Upsert 보존/덮어쓰기 조건
|
||||
|
||||
---
|
||||
|
||||
## 부록: 파일별 역할 요약
|
||||
|
||||
| 파일 | 레이어 | 역할 |
|
||||
|------|--------|------|
|
||||
| `main.py` | — | 진입점. `Config` 로드 후 `TradingBot.run()` 실행 |
|
||||
| `src/bot.py` | 오케스트레이터 | 모든 레이어를 조율하는 메인 트레이딩 루프 |
|
||||
| `src/config.py` | — | 환경변수 기반 설정 (`@dataclass`) |
|
||||
| `src/data_stream.py` | Data | Combined WebSocket 캔들 수신·버퍼 관리 |
|
||||
| `src/indicators.py` | Signal | 기술 지표 계산 및 복합 신호 생성 |
|
||||
| `src/ml_features.py` | ML Filter | 23개 ML 피처 추출 |
|
||||
| `src/ml_filter.py` | ML Filter | ONNX/LightGBM 모델 로드·추론·핫리로드 |
|
||||
| `src/mlx_filter.py` | ML Filter | Apple Silicon GPU 학습 + ONNX export |
|
||||
| `src/exchange.py` | Execution | Binance Futures REST API 클라이언트 |
|
||||
| `src/risk_manager.py` | Risk | 일일 손실 한도·동적 증거금 비율·포지션 수 제어 |
|
||||
| `src/user_data_stream.py` | Event | User Data Stream TP/SL 즉시 감지 |
|
||||
| `src/notifier.py` | Alert | Discord 웹훅 알림 |
|
||||
| `src/label_builder.py` | MLOps | 학습 레이블 생성 (ATR SL/TP 룩어헤드) |
|
||||
| `src/dataset_builder.py` | MLOps | 벡터화 데이터셋 빌더 (학습용) |
|
||||
| `src/logger_setup.py` | — | Loguru 로거 설정 |
|
||||
| `scripts/fetch_history.py` | MLOps | 과거 캔들 + OI/펀딩비 수집 (Parquet Upsert) |
|
||||
| `scripts/train_model.py` | MLOps | LightGBM 모델 학습 (CPU) |
|
||||
| `scripts/train_mlx_model.py` | MLOps | MLX 신경망 학습 (Apple Silicon GPU) |
|
||||
| `scripts/tune_hyperparams.py` | MLOps | Optuna 하이퍼파라미터 탐색 (수동 트리거) |
|
||||
| `scripts/train_and_deploy.sh` | MLOps | 전체 파이프라인 (수집→학습→배포) |
|
||||
| `scripts/deploy_model.sh` | MLOps | 모델 파일 LXC 서버 전송 |
|
||||
| `models/active_lgbm_params.json` | MLOps | 승인된 LightGBM 파라미터 (Active Config) |
|
||||
116
CLAUDE.md
Normal file
116
CLAUDE.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
CoinTrader is a Python asyncio-based automated cryptocurrency trading bot for Binance Futures. It trades XRPUSDT on 15-minute candles, using BTC/ETH as correlation features. The system has 5 layers: Data (WebSocket streams) → Signal (technical indicators) → ML Filter (ONNX/LightGBM) → Execution & Risk → Event/Alert (Discord).
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Run the bot
|
||||
python main.py
|
||||
|
||||
# Run full test suite
|
||||
bash scripts/run_tests.sh
|
||||
|
||||
# Run filtered tests
|
||||
bash scripts/run_tests.sh -k "bot"
|
||||
|
||||
# Run pytest directly
|
||||
pytest tests/ -v --tb=short
|
||||
|
||||
# ML training pipeline (LightGBM default)
|
||||
bash scripts/train_and_deploy.sh
|
||||
|
||||
# MLX GPU training (macOS Apple Silicon)
|
||||
bash scripts/train_and_deploy.sh mlx
|
||||
|
||||
# Hyperparameter tuning (50 trials, 5-fold walk-forward)
|
||||
python scripts/tune_hyperparams.py
|
||||
|
||||
# Fetch historical data
|
||||
python scripts/fetch_history.py --symbols XRPUSDT BTCUSDT ETHUSDT --interval 15m --days 365
|
||||
|
||||
# Deploy models to production
|
||||
bash scripts/deploy_model.sh
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Entry point**: `main.py` → creates `Config` (dataclass from env vars) → runs `TradingBot`
|
||||
|
||||
**5-layer data flow on each 15m candle close:**
|
||||
1. `src/data_stream.py` — Combined WebSocket for XRP/BTC/ETH, deque buffers (200 candles each)
|
||||
2. `src/indicators.py` — RSI, MACD, BB, EMA, StochRSI, ATR; weighted signal aggregation → LONG/SHORT/HOLD
|
||||
3. `src/ml_filter.py` + `src/ml_features.py` — 23-feature extraction, ONNX priority > LightGBM fallback, threshold ≥ 0.60
|
||||
4. `src/exchange.py` + `src/risk_manager.py` — Dynamic margin, MARKET orders with SL/TP, daily loss limit (5%)
|
||||
5. `src/user_data_stream.py` + `src/notifier.py` — Real-time TP/SL detection via WebSocket, Discord webhooks
|
||||
|
||||
**Parallel execution**: `user_data_stream` runs independently via `asyncio.gather()` alongside candle processing.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **Async-first**: All I/O operations use `async/await`; parallel tasks via `asyncio.gather()`
|
||||
- **Reverse signal re-entry**: While holding LONG, if SHORT signal appears → close position, cancel SL/TP, open SHORT. `_is_reentering` flag prevents race conditions with User Data Stream
|
||||
- **ML hot reload**: `ml_filter.check_and_reload()` compares file mtime on every candle, reloads model without restart
|
||||
- **Active Config pattern**: Best hyperparams stored in `models/active_lgbm_params.json`, must be manually approved before retraining
|
||||
- **Graceful degradation**: Missing model → all signals pass; API failure → use fallback values (0.0 for OI/funding)
|
||||
- **Walk-forward validation**: Time-series CV with undersampling (1:1 class balance, preserving time order)
|
||||
- **Label generation**: Binary labels based on 24-candle (6h) lookahead — check SL hit first (conservative), then TP
|
||||
|
||||
## Testing
|
||||
|
||||
- All external APIs (Binance, Discord) are mocked with `unittest.mock.AsyncMock`
|
||||
- Async tests use `@pytest.mark.asyncio`
|
||||
- 14 test files, 80+ test cases covering all layers
|
||||
- Testing is done in actual terminal, not IDE sandbox
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables via `.env` file (see `.env.example`). Key vars: `BINANCE_API_KEY`, `BINANCE_API_SECRET`, `SYMBOL` (default XRPUSDT), `LEVERAGE`, `DISCORD_WEBHOOK_URL`, `MARGIN_MAX_RATIO`, `MARGIN_MIN_RATIO`, `NO_ML_FILTER`.
|
||||
|
||||
`src/config.py` uses `@dataclass` with `__post_init__` to load and validate all env vars.
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Docker**: `Dockerfile` (Python 3.12-slim) + `docker-compose.yml`
|
||||
- **CI/CD**: Jenkins pipeline (Gitea → Docker registry → LXC production server)
|
||||
- Models stored in `models/`, data cache in `data/`, logs in `logs/`
|
||||
|
||||
## Design & Implementation Plans
|
||||
|
||||
All design documents and implementation plans are stored in `docs/plans/` with the naming convention `YYYY-MM-DD-feature-name.md`. Design docs (`-design.md`) describe architecture decisions; implementation plans (`-plan.md`) contain step-by-step tasks for Claude to execute.
|
||||
|
||||
**Chronological plan history:**
|
||||
|
||||
| Date | Plan | Status |
|
||||
|------|------|--------|
|
||||
| 2026-03-01 | `xrp-futures-autotrader` | Completed |
|
||||
| 2026-03-01 | `discord-notifier-and-position-recovery` | Completed |
|
||||
| 2026-03-01 | `upload-to-gitea` | Completed |
|
||||
| 2026-03-01 | `dockerfile-and-docker-compose` | Completed |
|
||||
| 2026-03-01 | `fix-pandas-ta-python312` | Completed |
|
||||
| 2026-03-01 | `jenkins-gitea-registry-cicd` | Completed |
|
||||
| 2026-03-01 | `ml-filter-design` / `ml-filter-implementation` | Completed |
|
||||
| 2026-03-01 | `train-on-mac-deploy-to-lxc` | Completed |
|
||||
| 2026-03-01 | `m4-accelerated-training` | Completed |
|
||||
| 2026-03-01 | `vectorized-dataset-builder` | Completed |
|
||||
| 2026-03-01 | `btc-eth-correlation-features` (design + plan) | Completed |
|
||||
| 2026-03-01 | `dynamic-margin-ratio` (design + plan) | Completed |
|
||||
| 2026-03-01 | `lgbm-improvement` | Completed |
|
||||
| 2026-03-01 | `15m-timeframe-upgrade` | Completed |
|
||||
| 2026-03-01 | `oi-nan-epsilon-precision-threshold` | Completed |
|
||||
| 2026-03-02 | `rs-divide-mlx-nan-fix` | Completed |
|
||||
| 2026-03-02 | `reverse-signal-reenter` (design + plan) | Completed |
|
||||
| 2026-03-02 | `realtime-oi-funding-features` | Completed |
|
||||
| 2026-03-02 | `oi-funding-accumulation` | Completed |
|
||||
| 2026-03-02 | `optuna-hyperparam-tuning` (design + plan) | Completed |
|
||||
| 2026-03-02 | `user-data-stream-tp-sl-detection` (design + plan) | Completed |
|
||||
| 2026-03-02 | `adx-filter-design` | Completed |
|
||||
| 2026-03-02 | `hold-negative-sampling` (design + plan) | Completed |
|
||||
| 2026-03-03 | `optuna-precision-objective-plan` | Pending |
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Binance Futures 자동매매 봇. 복합 기술 지표와 ML 필터(LightGBM / MLX 신경망)를 결합하여 XRPUSDT(기본) 선물 포지션을 자동으로 진입·청산하며, Discord로 실시간 알림을 전송합니다.
|
||||
|
||||
> **아키텍처 문서**: 코드 구조, 레이어별 역할, MLOps 파이프라인, 동작 시나리오를 상세히 설명한 [ARCHITECTURE.md](./ARCHITECTURE.md)를 참고하세요.
|
||||
|
||||
---
|
||||
|
||||
## 주요 기능
|
||||
@@ -19,6 +21,10 @@ Binance Futures 자동매매 봇. 복합 기술 지표와 ML 필터(LightGBM / M
|
||||
- **반대 시그널 재진입**: 보유 포지션과 반대 신호 발생 시 즉시 청산 후 ML 필터 통과 시 반대 방향 재진입
|
||||
- **리스크 관리**: 트레이드당 리스크 비율, 최대 포지션 수, 일일 손실 한도(5%) 제어
|
||||
- **포지션 복구**: 봇 재시작 시 기존 포지션 자동 감지 및 상태 복원
|
||||
- **실시간 TP/SL 감지**: Binance User Data Stream으로 TP/SL 작동을 즉시 감지 (캔들 마감 대기 없음)
|
||||
- **순수익(Net PnL) 기록**: 바이낸스 `realizedProfit - commission`으로 정확한 순수익 계산
|
||||
- **Discord 상세 청산 알림**: 예상 수익 vs 실제 순수익 + 슬리피지/수수료 차이 표시
|
||||
- **listenKey 자동 갱신**: 30분 keepalive + 네트워크 단절 시 자동 재연결. `stream.recv()` 기반으로 수신하며, 라이브러리 내부 에러 페이로드(`{"e":"error"}`) 감지 시 즉시 재연결하여 좀비 커넥션 방지
|
||||
- **Discord 알림**: 진입·청산·오류 이벤트 실시간 웹훅 알림
|
||||
- **CI/CD**: Jenkins + Gitea Container Registry 기반 Docker 이미지 자동 빌드·배포 (LXC 운영 서버 자동 적용)
|
||||
|
||||
@@ -262,6 +268,7 @@ pytest tests/ -v
|
||||
| `MARGIN_MAX_RATIO` | `0.50` | 최대 증거금 비율 (잔고 대비 50%) |
|
||||
| `MARGIN_MIN_RATIO` | `0.20` | 최소 증거금 비율 (잔고 대비 20%) |
|
||||
| `MARGIN_DECAY_RATE` | `0.0006` | 잔고 증가 시 증거금 비율 감소 속도 |
|
||||
| `NO_ML_FILTER` | — | `true`/`1`/`yes` 설정 시 ML 필터 완전 비활성화 — 모델 로드 없이 모든 신호 허용 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
150
docs/plans/2026-03-02-adx-filter-design.md
Normal file
150
docs/plans/2026-03-02-adx-filter-design.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# ADX 횡보장 필터 구현 계획
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** ADX < 25일 때 get_signal()에서 즉시 HOLD를 반환하여 횡보장 진입을 차단한다.
|
||||
|
||||
**Architecture:** `calculate_all()`에서 `pandas_ta.adx()`로 ADX 컬럼을 추가하고, `get_signal()`에서 가중치 계산 전 ADX < 25이면 early-return HOLD. NaN(초기 캔들)은 기존 로직으로 폴백.
|
||||
|
||||
**Tech Stack:** pandas-ta (이미 사용 중), pytest
|
||||
|
||||
---
|
||||
|
||||
### Task 1: ADX 계산 테스트 추가
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/test_indicators.py`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
def test_adx_column_exists(sample_df):
|
||||
"""calculate_all()이 adx 컬럼을 생성하는지 확인."""
|
||||
ind = Indicators(sample_df)
|
||||
df = ind.calculate_all()
|
||||
assert "adx" in df.columns
|
||||
valid = df["adx"].dropna()
|
||||
assert (valid >= 0).all()
|
||||
```
|
||||
|
||||
`tests/test_indicators.py`에 위 테스트 함수를 추가한다.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_indicators.py::test_adx_column_exists -v`
|
||||
Expected: FAIL — `"adx" not in df.columns`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: calculate_all()에 ADX 계산 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/indicators.py:46-48` (vol_ma20 계산 바로 앞에 추가)
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
`calculate_all()`의 Stochastic RSI 계산 뒤, `vol_ma20` 계산 앞에 추가:
|
||||
|
||||
```python
|
||||
# ADX (14) — 횡보장 필터
|
||||
adx_df = ta.adx(df["high"], df["low"], df["close"], length=14)
|
||||
df["adx"] = adx_df["ADX_14"]
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_indicators.py::test_adx_column_exists -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/indicators.py tests/test_indicators.py
|
||||
git commit -m "feat: add ADX calculation to indicators"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: ADX 필터 테스트 추가 (차단 케이스)
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/test_indicators.py`
|
||||
|
||||
**Step 6: Write the failing test**
|
||||
|
||||
```python
|
||||
def test_adx_filter_blocks_low_adx(sample_df):
|
||||
"""ADX < 25일 때 가중치와 무관하게 HOLD를 반환해야 한다."""
|
||||
ind = Indicators(sample_df)
|
||||
df = ind.calculate_all()
|
||||
# ADX를 강제로 낮은 값으로 설정
|
||||
df["adx"] = 15.0
|
||||
signal = ind.get_signal(df)
|
||||
assert signal == "HOLD"
|
||||
```
|
||||
|
||||
**Step 7: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_indicators.py::test_adx_filter_blocks_low_adx -v`
|
||||
Expected: FAIL — signal이 LONG 또는 SHORT 반환 (ADX 필터 미구현)
|
||||
|
||||
---
|
||||
|
||||
### Task 4: ADX 필터 테스트 추가 (NaN 폴백 케이스)
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/test_indicators.py`
|
||||
|
||||
**Step 8: Write the failing test**
|
||||
|
||||
```python
|
||||
def test_adx_nan_falls_through(sample_df):
|
||||
"""ADX가 NaN(초기 캔들)이면 기존 가중치 로직으로 폴백해야 한다."""
|
||||
ind = Indicators(sample_df)
|
||||
df = ind.calculate_all()
|
||||
df["adx"] = float("nan")
|
||||
signal = ind.get_signal(df)
|
||||
# NaN이면 차단하지 않고 기존 로직 실행 → LONG/SHORT/HOLD 중 하나
|
||||
assert signal in ("LONG", "SHORT", "HOLD")
|
||||
```
|
||||
|
||||
**Step 9: Run test to verify it passes (이 테스트는 현재도 통과)**
|
||||
|
||||
Run: `pytest tests/test_indicators.py::test_adx_nan_falls_through -v`
|
||||
Expected: PASS (ADX 컬럼이 무시되므로 기존 로직 그대로)
|
||||
|
||||
---
|
||||
|
||||
### Task 5: get_signal()에 ADX early-return 구현
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/indicators.py:51-56` (get_signal 메서드 시작부)
|
||||
|
||||
**Step 10: Write minimal implementation**
|
||||
|
||||
`get_signal()` 메서드의 `last = df.iloc[-1]` 바로 다음에 추가:
|
||||
|
||||
```python
|
||||
# ADX 횡보장 필터: ADX < 25이면 추세 부재로 판단하여 진입 차단
|
||||
adx = last.get("adx", None)
|
||||
if adx is not None and not pd.isna(adx) and adx < 25:
|
||||
logger.debug(f"ADX 필터: {adx:.1f} < 25 — HOLD")
|
||||
return "HOLD"
|
||||
```
|
||||
|
||||
**Step 11: Run all ADX-related tests**
|
||||
|
||||
Run: `pytest tests/test_indicators.py -k "adx" -v`
|
||||
Expected: 3 tests PASS
|
||||
|
||||
**Step 12: Run full test suite to check for regressions**
|
||||
|
||||
Run: `pytest tests/ -v --tb=short`
|
||||
Expected: All tests PASS
|
||||
|
||||
**Step 13: Commit**
|
||||
|
||||
```bash
|
||||
git add src/indicators.py tests/test_indicators.py
|
||||
git commit -m "feat: add ADX filter to block sideways market entries"
|
||||
```
|
||||
91
docs/plans/2026-03-02-hold-negative-sampling-design.md
Normal file
91
docs/plans/2026-03-02-hold-negative-sampling-design.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# HOLD Negative Sampling + Stratified Undersampling Design
|
||||
|
||||
## Problem
|
||||
|
||||
현재 ML 파이프라인의 학습 데이터가 535개로 매우 적음.
|
||||
`dataset_builder.py`에서 시그널(LONG/SHORT) 발생 캔들만 라벨링하기 때문.
|
||||
전체 ~35,000개 캔들 중 98.5%가 HOLD로 버려짐.
|
||||
|
||||
## Goal
|
||||
|
||||
- HOLD 캔들을 negative sample로 활용하여 학습 데이터 증가
|
||||
- Train-Serve Skew 방지 (학습/추론 데이터 분포 일치)
|
||||
- 기존 signal 샘플은 하나도 버리지 않는 계층적 샘플링
|
||||
|
||||
## Design
|
||||
|
||||
### 1. dataset_builder.py — HOLD Negative Sampling
|
||||
|
||||
**변경 위치**: `generate_dataset_vectorized()` (line 360-421)
|
||||
|
||||
**현재 로직**:
|
||||
```python
|
||||
valid_rows = (
|
||||
(signal_arr != "HOLD") & # ← 시그널 캔들만 선택
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**변경 로직**:
|
||||
1. 기존 시그널 캔들(LONG/SHORT) 라벨링은 그대로 유지
|
||||
2. HOLD 캔들 중 랜덤 샘플링 (시그널 수의 NEGATIVE_RATIO배)
|
||||
3. HOLD 캔들: label=0, side=랜덤(50% LONG / 50% SHORT), signal_strength=0
|
||||
4. `source` 컬럼 추가: "signal" | "hold_negative" (계층적 샘플링에 사용)
|
||||
|
||||
**파라미터**:
|
||||
```python
|
||||
NEGATIVE_RATIO = 5 # 시그널 대비 HOLD 샘플 비율
|
||||
RANDOM_SEED = 42 # 재현성
|
||||
```
|
||||
|
||||
**예상 데이터량**:
|
||||
- 시그널: ~535개 (Win ~200, Loss ~335)
|
||||
- HOLD negative: ~2,675개
|
||||
- 총 학습 데이터: ~3,210개
|
||||
|
||||
### 2. train_model.py — Stratified Undersampling
|
||||
|
||||
**변경 위치**: `train()` 함수 내 언더샘플링 블록 (line 241-257)
|
||||
|
||||
**현재 로직**: 양성:음성 = 1:1 블라인드 언더샘플링
|
||||
```python
|
||||
if len(neg_idx) > len(pos_idx):
|
||||
neg_idx = np.random.choice(neg_idx, size=len(pos_idx), replace=False)
|
||||
```
|
||||
|
||||
**변경 로직**: 계층적 3-class 샘플링
|
||||
```python
|
||||
# 1. Signal 샘플(source="signal") 전수 유지 (Win + Loss 모두)
|
||||
# 2. HOLD negative(source="hold_negative")에서만 샘플링
|
||||
# → 양성(Win) 수와 동일한 수만큼 샘플링
|
||||
# 최종: Win ~200 + Signal Loss ~335 + HOLD ~200 = ~735개
|
||||
```
|
||||
|
||||
**효과**:
|
||||
- Signal 샘플 보존율: 100% (Win/Loss 모두)
|
||||
- HOLD negative: 적절한 양만 추가
|
||||
- Train-Serve Skew 없음 (추론 시 signal_strength ≥ 3에서만 호출)
|
||||
|
||||
### 3. 런타임 (변경 없음)
|
||||
|
||||
- `bot.py`: 시그널 발생 시에만 ML 필터 호출 (기존 동일)
|
||||
- `ml_filter.py`: `should_enter()` 그대로
|
||||
- `ml_features.py`: `FEATURE_COLS` 그대로
|
||||
- `label_builder.py`: 기존 SL/TP 룩어헤드 로직 그대로
|
||||
|
||||
## Test Cases
|
||||
|
||||
### 필수 테스트
|
||||
1. **HOLD negative label 검증**: HOLD negative 샘플의 label이 전부 0인지 확인
|
||||
2. **Signal 보존 검증**: 계층적 샘플링 후 source="signal" 샘플이 하나도 버려지지 않았는지 확인
|
||||
|
||||
### 기존 테스트 호환성
|
||||
- 기존 dataset_builder 관련 테스트가 깨지지 않도록 보장
|
||||
|
||||
## File Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/dataset_builder.py` | HOLD negative sampling, source 컬럼 추가 |
|
||||
| `scripts/train_model.py` | 계층적 샘플링으로 교체 |
|
||||
| `tests/test_dataset_builder.py` (or equivalent) | 2개 테스트 케이스 추가 |
|
||||
432
docs/plans/2026-03-02-hold-negative-sampling-plan.md
Normal file
432
docs/plans/2026-03-02-hold-negative-sampling-plan.md
Normal file
@@ -0,0 +1,432 @@
|
||||
# HOLD Negative Sampling Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** HOLD 캔들을 negative sample로 추가하고 계층적 언더샘플링을 도입하여 ML 학습 데이터를 535 → ~3,200개로 증가시킨다.
|
||||
|
||||
**Architecture:** `dataset_builder.py`에서 시그널 캔들 외에 HOLD 캔들을 label=0으로 추가 샘플링하고, `source` 컬럼("signal"/"hold_negative")으로 구분한다. 학습 시 signal 샘플은 전수 유지, HOLD negative에서만 양성 수 만큼 샘플링하는 계층적 언더샘플링을 적용한다.
|
||||
|
||||
**Tech Stack:** Python, NumPy, pandas, LightGBM, pytest
|
||||
|
||||
---
|
||||
|
||||
### Task 1: dataset_builder.py — HOLD Negative Sampling 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/dataset_builder.py:360-421` (generate_dataset_vectorized 함수)
|
||||
- Test: `tests/test_dataset_builder.py`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
`tests/test_dataset_builder.py` 끝에 2개 테스트 추가:
|
||||
|
||||
```python
|
||||
def test_hold_negative_labels_are_all_zero(sample_df):
|
||||
"""HOLD negative 샘플의 label은 전부 0이어야 한다."""
|
||||
result = generate_dataset_vectorized(sample_df, negative_ratio=3)
|
||||
if len(result) > 0 and "source" in result.columns:
|
||||
hold_neg = result[result["source"] == "hold_negative"]
|
||||
if len(hold_neg) > 0:
|
||||
assert (hold_neg["label"] == 0).all(), \
|
||||
f"HOLD negative 중 label != 0인 샘플 존재: {hold_neg['label'].value_counts().to_dict()}"
|
||||
|
||||
|
||||
def test_signal_samples_preserved_after_sampling(sample_df):
|
||||
"""계층적 샘플링 후 source='signal' 샘플이 하나도 버려지지 않아야 한다."""
|
||||
# negative_ratio=0이면 기존 동작 (signal만), >0이면 HOLD 추가
|
||||
result_signal_only = generate_dataset_vectorized(sample_df, negative_ratio=0)
|
||||
result_with_hold = generate_dataset_vectorized(sample_df, negative_ratio=3)
|
||||
|
||||
if len(result_with_hold) > 0 and "source" in result_with_hold.columns:
|
||||
signal_count = (result_with_hold["source"] == "signal").sum()
|
||||
assert signal_count == len(result_signal_only), \
|
||||
f"Signal 샘플 손실: 원본={len(result_signal_only)}, 유지={signal_count}"
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pytest tests/test_dataset_builder.py::test_hold_negative_labels_are_all_zero tests/test_dataset_builder.py::test_signal_samples_preserved_after_sampling -v`
|
||||
Expected: FAIL — `generate_dataset_vectorized()` does not accept `negative_ratio` parameter
|
||||
|
||||
**Step 3: Implement HOLD negative sampling in generate_dataset_vectorized**
|
||||
|
||||
`src/dataset_builder.py`의 `generate_dataset_vectorized()` 함수를 수정한다.
|
||||
시그니처에 `negative_ratio: int = 0` 파라미터를 추가하고, HOLD 캔들 샘플링 로직을 삽입한다.
|
||||
|
||||
수정 대상: `generate_dataset_vectorized` 함수 전체.
|
||||
|
||||
```python
|
||||
def generate_dataset_vectorized(
|
||||
df: pd.DataFrame,
|
||||
btc_df: pd.DataFrame | None = None,
|
||||
eth_df: pd.DataFrame | None = None,
|
||||
time_weight_decay: float = 0.0,
|
||||
negative_ratio: int = 0,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
전체 시계열을 1회 계산해 학습 데이터셋을 생성한다.
|
||||
|
||||
negative_ratio: 시그널 샘플 대비 HOLD negative 샘플 비율.
|
||||
0이면 기존 동작 (시그널만). 5면 시그널의 5배만큼 HOLD 샘플 추가.
|
||||
"""
|
||||
print(" [1/3] 전체 시계열 지표 계산 (1회)...")
|
||||
d = _calc_indicators(df)
|
||||
|
||||
print(" [2/3] 신호 마스킹 및 피처 추출...")
|
||||
signal_arr = _calc_signals(d)
|
||||
feat_all = _calc_features_vectorized(d, signal_arr, btc_df=btc_df, eth_df=eth_df)
|
||||
|
||||
# 신호 발생 + NaN 없음 + 미래 데이터 충분한 인덱스만
|
||||
OPTIONAL_COLS = {"oi_change", "funding_rate"}
|
||||
available_cols_for_nan_check = [
|
||||
c for c in FEATURE_COLS
|
||||
if c in feat_all.columns and c not in OPTIONAL_COLS
|
||||
]
|
||||
base_valid = (
|
||||
(~feat_all[available_cols_for_nan_check].isna().any(axis=1).values) &
|
||||
(np.arange(len(d)) >= WARMUP) &
|
||||
(np.arange(len(d)) < len(d) - LOOKAHEAD)
|
||||
)
|
||||
|
||||
# --- 시그널 캔들 (기존 로직) ---
|
||||
sig_valid = base_valid & (signal_arr != "HOLD")
|
||||
sig_idx = np.where(sig_valid)[0]
|
||||
print(f" 신호 발생 인덱스: {len(sig_idx):,}개")
|
||||
|
||||
print(" [3/3] 레이블 계산...")
|
||||
labels, valid_mask = _calc_labels_vectorized(d, feat_all, sig_idx)
|
||||
|
||||
final_sig_idx = sig_idx[valid_mask]
|
||||
available_feature_cols = [c for c in FEATURE_COLS if c in feat_all.columns]
|
||||
feat_signal = feat_all.iloc[final_sig_idx][available_feature_cols].copy()
|
||||
feat_signal["label"] = labels
|
||||
feat_signal["source"] = "signal"
|
||||
|
||||
# --- HOLD negative 캔들 ---
|
||||
if negative_ratio > 0 and len(final_sig_idx) > 0:
|
||||
hold_valid = base_valid & (signal_arr == "HOLD")
|
||||
hold_candidates = np.where(hold_valid)[0]
|
||||
n_neg = min(len(hold_candidates), len(final_sig_idx) * negative_ratio)
|
||||
|
||||
if n_neg > 0:
|
||||
rng = np.random.default_rng(42)
|
||||
hold_idx = rng.choice(hold_candidates, size=n_neg, replace=False)
|
||||
hold_idx = np.sort(hold_idx)
|
||||
|
||||
feat_hold = feat_all.iloc[hold_idx][available_feature_cols].copy()
|
||||
feat_hold["label"] = 0
|
||||
feat_hold["source"] = "hold_negative"
|
||||
|
||||
# HOLD 캔들은 시그널이 없으므로 side를 랜덤 할당 (50:50)
|
||||
sides = rng.integers(0, 2, size=len(feat_hold)).astype(np.float32)
|
||||
feat_hold["side"] = sides
|
||||
# signal_strength는 이미 0 (시그널 미발생이므로)
|
||||
|
||||
print(f" HOLD negative 추가: {len(feat_hold):,}개 "
|
||||
f"(비율 1:{negative_ratio})")
|
||||
|
||||
feat_final = pd.concat([feat_signal, feat_hold], ignore_index=True)
|
||||
# 시간 순서 복원 (원본 인덱스 기반 정렬)
|
||||
original_order = np.concatenate([final_sig_idx, hold_idx])
|
||||
sort_order = np.argsort(original_order)
|
||||
feat_final = feat_final.iloc[sort_order].reset_index(drop=True)
|
||||
else:
|
||||
feat_final = feat_signal.reset_index(drop=True)
|
||||
else:
|
||||
feat_final = feat_signal.reset_index(drop=True)
|
||||
|
||||
# 시간 가중치
|
||||
n = len(feat_final)
|
||||
if time_weight_decay > 0 and n > 1:
|
||||
weights = np.exp(time_weight_decay * np.linspace(0.0, 1.0, n)).astype(np.float32)
|
||||
weights /= weights.mean()
|
||||
print(f" 시간 가중치 적용 (decay={time_weight_decay}): "
|
||||
f"min={weights.min():.3f}, max={weights.max():.3f}")
|
||||
else:
|
||||
weights = np.ones(n, dtype=np.float32)
|
||||
|
||||
feat_final["sample_weight"] = weights
|
||||
|
||||
total_sig = (feat_final["source"] == "signal").sum() if "source" in feat_final.columns else len(feat_final)
|
||||
total_hold = (feat_final["source"] == "hold_negative").sum() if "source" in feat_final.columns else 0
|
||||
print(f" 최종 데이터셋: {n:,}개 (시그널={total_sig:,}, HOLD={total_hold:,})")
|
||||
|
||||
return feat_final
|
||||
```
|
||||
|
||||
**Step 4: Run the new tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_dataset_builder.py::test_hold_negative_labels_are_all_zero tests/test_dataset_builder.py::test_signal_samples_preserved_after_sampling -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Run all existing dataset_builder tests to verify no regressions**
|
||||
|
||||
Run: `pytest tests/test_dataset_builder.py -v`
|
||||
Expected: All existing tests PASS (기존 동작은 negative_ratio=0 기본값으로 유지)
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/dataset_builder.py tests/test_dataset_builder.py
|
||||
git commit -m "feat: add HOLD negative sampling to dataset builder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 계층적 언더샘플링 헬퍼 함수
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/dataset_builder.py` (파일 끝에 헬퍼 추가)
|
||||
- Test: `tests/test_dataset_builder.py`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
def test_stratified_undersample_preserves_signal():
|
||||
"""stratified_undersample은 signal 샘플을 전수 유지해야 한다."""
|
||||
from src.dataset_builder import stratified_undersample
|
||||
|
||||
y = np.array([1, 0, 0, 0, 0, 0, 0, 0, 1, 0])
|
||||
source = np.array(["signal", "signal", "signal", "hold_negative",
|
||||
"hold_negative", "hold_negative", "hold_negative",
|
||||
"hold_negative", "signal", "signal"])
|
||||
|
||||
idx = stratified_undersample(y, source, seed=42)
|
||||
|
||||
# signal 인덱스: 0, 1, 2, 8, 9 → 전부 포함
|
||||
signal_indices = np.where(source == "signal")[0]
|
||||
for si in signal_indices:
|
||||
assert si in idx, f"signal 인덱스 {si}가 누락됨"
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_dataset_builder.py::test_stratified_undersample_preserves_signal -v`
|
||||
Expected: FAIL — `stratified_undersample` 함수 미존재
|
||||
|
||||
**Step 3: Implement stratified_undersample**
|
||||
|
||||
`src/dataset_builder.py` 끝에 추가:
|
||||
|
||||
```python
|
||||
def stratified_undersample(
|
||||
y: np.ndarray,
|
||||
source: np.ndarray,
|
||||
seed: int = 42,
|
||||
) -> np.ndarray:
|
||||
"""Signal 샘플 전수 유지 + HOLD negative만 양성 수 만큼 샘플링.
|
||||
|
||||
Args:
|
||||
y: 라벨 배열 (0 or 1)
|
||||
source: 소스 배열 ("signal" or "hold_negative")
|
||||
seed: 랜덤 시드
|
||||
|
||||
Returns:
|
||||
정렬된 인덱스 배열 (학습에 사용할 행 인덱스)
|
||||
"""
|
||||
pos_idx = np.where(y == 1)[0] # Signal Win
|
||||
sig_neg_idx = np.where((y == 0) & (source == "signal"))[0] # Signal Loss
|
||||
hold_neg_idx = np.where(source == "hold_negative")[0] # HOLD negative
|
||||
|
||||
# HOLD negative에서 양성 수 만큼만 샘플링
|
||||
n_hold = min(len(hold_neg_idx), len(pos_idx))
|
||||
rng = np.random.default_rng(seed)
|
||||
if n_hold > 0:
|
||||
hold_sampled = rng.choice(hold_neg_idx, size=n_hold, replace=False)
|
||||
else:
|
||||
hold_sampled = np.array([], dtype=np.intp)
|
||||
|
||||
return np.sort(np.concatenate([pos_idx, sig_neg_idx, hold_sampled]))
|
||||
```
|
||||
|
||||
**Step 4: Run tests**
|
||||
|
||||
Run: `pytest tests/test_dataset_builder.py::test_stratified_undersample_preserves_signal -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/dataset_builder.py tests/test_dataset_builder.py
|
||||
git commit -m "feat: add stratified_undersample helper function"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: train_model.py — 계층적 언더샘플링 적용
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/train_model.py:229-257` (train 함수)
|
||||
- Modify: `scripts/train_model.py:356-391` (walk_forward_auc 함수)
|
||||
|
||||
**Step 1: Update train() function**
|
||||
|
||||
`scripts/train_model.py`에서 `dataset_builder`에서 `stratified_undersample`을 import하고,
|
||||
`train()` 함수의 언더샘플링 블록을 교체한다.
|
||||
|
||||
import 수정 (line 25):
|
||||
```python
|
||||
from src.dataset_builder import generate_dataset_vectorized, stratified_undersample
|
||||
```
|
||||
|
||||
`train()` 함수에서 데이터셋 생성 호출에 `negative_ratio=5` 추가 (line 217):
|
||||
```python
|
||||
dataset = generate_dataset_vectorized(
|
||||
df, btc_df=btc_df, eth_df=eth_df,
|
||||
time_weight_decay=time_weight_decay,
|
||||
negative_ratio=5,
|
||||
)
|
||||
```
|
||||
|
||||
source 배열 추출 추가 (line 231 부근, w 다음):
|
||||
```python
|
||||
source = dataset["source"].values if "source" in dataset.columns else np.full(len(X), "signal")
|
||||
```
|
||||
|
||||
언더샘플링 블록 교체 (line 241-257):
|
||||
```python
|
||||
# --- 계층적 샘플링: signal 전수 유지, HOLD negative만 양성 수 만큼 ---
|
||||
source_train = source[:split]
|
||||
balanced_idx = stratified_undersample(y_train.values, source_train, seed=42)
|
||||
|
||||
X_train = X_train.iloc[balanced_idx]
|
||||
y_train = y_train.iloc[balanced_idx]
|
||||
w_train = w_train[balanced_idx]
|
||||
|
||||
sig_count = (source_train[balanced_idx] == "signal").sum()
|
||||
hold_count = (source_train[balanced_idx] == "hold_negative").sum()
|
||||
print(f"\n계층적 샘플링 후 학습 데이터: {len(X_train)}개 "
|
||||
f"(Signal={sig_count}, HOLD={hold_count}, "
|
||||
f"양성={int(y_train.sum())}, 음성={int((y_train==0).sum())})")
|
||||
print(f"검증 데이터: {len(X_val)}개 (양성={int(y_val.sum())}, 음성={int((y_val==0).sum())})")
|
||||
```
|
||||
|
||||
**Step 2: Update walk_forward_auc() function**
|
||||
|
||||
`walk_forward_auc()` 함수에서도 동일하게 적용.
|
||||
|
||||
dataset 생성 (line 356-358)에 `negative_ratio=5` 추가:
|
||||
```python
|
||||
dataset = generate_dataset_vectorized(
|
||||
df, btc_df=btc_df, eth_df=eth_df,
|
||||
time_weight_decay=time_weight_decay,
|
||||
negative_ratio=5,
|
||||
)
|
||||
```
|
||||
|
||||
source 배열 추출 (line 362 부근):
|
||||
```python
|
||||
source = dataset["source"].values if "source" in dataset.columns else np.full(n, "signal")
|
||||
```
|
||||
|
||||
폴드 내 언더샘플링 교체 (line 381-386):
|
||||
```python
|
||||
source_tr = source[:tr_end]
|
||||
bal_idx = stratified_undersample(y_tr, source_tr, seed=42)
|
||||
```
|
||||
|
||||
**Step 3: Run training to verify**
|
||||
|
||||
Run: `python scripts/train_model.py --data data/combined_15m.parquet --decay 2.0`
|
||||
Expected: 학습 샘플 수 대폭 증가 확인 (기존 ~535 → ~3,200)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/train_model.py
|
||||
git commit -m "feat: apply stratified undersampling to training pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: tune_hyperparams.py — 계층적 언더샘플링 적용
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/tune_hyperparams.py:41-81` (load_dataset)
|
||||
- Modify: `scripts/tune_hyperparams.py:88-144` (_walk_forward_cv)
|
||||
- Modify: `scripts/tune_hyperparams.py:151-206` (make_objective)
|
||||
- Modify: `scripts/tune_hyperparams.py:213-244` (measure_baseline)
|
||||
- Modify: `scripts/tune_hyperparams.py:370-449` (main)
|
||||
|
||||
**Step 1: Update load_dataset to return source**
|
||||
|
||||
import 수정 (line 34):
|
||||
```python
|
||||
from src.dataset_builder import generate_dataset_vectorized, stratified_undersample
|
||||
```
|
||||
|
||||
`load_dataset()` 시그니처와 반환값 수정:
|
||||
```python
|
||||
def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
```
|
||||
|
||||
dataset 생성에 `negative_ratio=5` 추가 (line 66):
|
||||
```python
|
||||
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=0.0, negative_ratio=5)
|
||||
```
|
||||
|
||||
source 추출 추가 (line 74 부근, w 다음):
|
||||
```python
|
||||
source = dataset["source"].values if "source" in dataset.columns else np.full(len(dataset), "signal")
|
||||
```
|
||||
|
||||
return 수정:
|
||||
```python
|
||||
return X, y, w, source
|
||||
```
|
||||
|
||||
**Step 2: Update _walk_forward_cv to accept and use source**
|
||||
|
||||
시그니처에 source 추가:
|
||||
```python
|
||||
def _walk_forward_cv(
|
||||
X: np.ndarray,
|
||||
y: np.ndarray,
|
||||
w: np.ndarray,
|
||||
source: np.ndarray,
|
||||
params: dict,
|
||||
...
|
||||
```
|
||||
|
||||
폴드 내 언더샘플링 교체 (line 117-122):
|
||||
```python
|
||||
source_tr = source[:tr_end]
|
||||
bal_idx = stratified_undersample(y_tr, source_tr, seed=42)
|
||||
```
|
||||
|
||||
**Step 3: Update make_objective, measure_baseline, main**
|
||||
|
||||
`make_objective()`: 클로저에 source 캡처, `_walk_forward_cv` 호출에 source 전달
|
||||
`measure_baseline()`: source 파라미터 추가, `_walk_forward_cv` 호출에 전달
|
||||
`main()`: `load_dataset` 반환값 4개로 변경, 하위 함수에 source 전달
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/tune_hyperparams.py
|
||||
git commit -m "feat: apply stratified undersampling to hyperparameter tuning"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 전체 테스트 실행 및 검증
|
||||
|
||||
**Step 1: Run full test suite**
|
||||
|
||||
Run: `bash scripts/run_tests.sh`
|
||||
Expected: All tests PASS
|
||||
|
||||
**Step 2: Run training pipeline end-to-end**
|
||||
|
||||
Run: `python scripts/train_model.py --data data/combined_15m.parquet --decay 2.0`
|
||||
Expected:
|
||||
- 학습 샘플 ~3,200개 (기존 535)
|
||||
- "계층적 샘플링 후" 로그에 Signal/HOLD 카운트 표시
|
||||
- AUC 출력 (값 자체보다 실행 완료가 중요)
|
||||
|
||||
**Step 3: Commit final state**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: verify HOLD negative sampling pipeline end-to-end"
|
||||
```
|
||||
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
|
||||
```
|
||||
80
docs/plans/2026-03-03-optuna-precision-objective-plan.md
Normal file
80
docs/plans/2026-03-03-optuna-precision-objective-plan.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Optuna 목적함수를 Precision 중심으로 변경
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 현재 ROC-AUC만 최적화하는 Optuna objective를 **recall >= 0.35 제약 하에서 precision을 최대화**하는 방향으로 변경한다. AUC는 threshold-independent 지표라 실제 운용 시점의 성능(precision)을 반영하지 못하며, 오탐(false positive = 잘못된 진입)이 실제 손실을 발생시키므로 precision 우선 최적화가 필요하다.
|
||||
|
||||
**Tech Stack:** Python, LightGBM, Optuna, scikit-learn
|
||||
|
||||
---
|
||||
|
||||
## 변경 파일
|
||||
- `scripts/tune_hyperparams.py` (유일한 변경 대상)
|
||||
|
||||
---
|
||||
|
||||
## 구현 단계
|
||||
|
||||
### 1. `_find_best_precision_at_recall` 헬퍼 함수 추가
|
||||
- `sklearn.metrics.precision_recall_curve`로 recall >= min_recall 조건의 최대 precision과 threshold 반환
|
||||
- 조건 불만족 시 `(0.0, 0.0, 0.50)` fallback
|
||||
- train_model.py:277-292와 동일한 로직
|
||||
|
||||
### 2. `_walk_forward_cv` 수정
|
||||
- 기존 반환: `(mean_auc, fold_aucs)` → 신규: `(mean_score, details_dict)`
|
||||
- `details_dict` 키: `fold_aucs`, `fold_precisions`, `fold_recalls`, `fold_thresholds`, `fold_n_pos`, `mean_auc`, `mean_precision`, `mean_recall`
|
||||
- **Score 공식**: `precision + auc * 0.001` (AUC는 precision 동률 시 tiebreaker)
|
||||
- fold 내 양성 < 3개면 해당 fold precision=0.0으로 처리, 평균 계산에서 제외
|
||||
- 인자 추가: `min_recall: float = 0.35`
|
||||
- import 추가: `from sklearn.metrics import precision_recall_curve`
|
||||
- Pruning: 양성 충분한 fold만 report하여 false pruning 방지
|
||||
|
||||
### 3. `make_objective` 수정
|
||||
- `min_recall` 인자 추가 → `_walk_forward_cv`에 전달
|
||||
- `trial.set_user_attr`로 precision/recall/threshold/n_pos 등 저장
|
||||
- 반환값: `mean_score` (precision + auc * 0.001)
|
||||
|
||||
### 4. `measure_baseline` 수정
|
||||
- `min_recall` 인자 추가
|
||||
- 반환값을 `(mean_score, details_dict)` 형태로 변경
|
||||
|
||||
### 5. `--min-recall` CLI 인자 추가
|
||||
- `parser.add_argument("--min-recall", type=float, default=0.35)`
|
||||
- `make_objective`와 `measure_baseline`에 전달
|
||||
|
||||
### 6. `print_report` 수정
|
||||
- Best Score, Precision, AUC 모두 표시
|
||||
- 폴드별 AUC + Precision + Recall + Threshold + 양성수 표시
|
||||
- Baseline과 비교 시 precision 기준 개선폭 표시
|
||||
|
||||
### 7. `save_results` 수정
|
||||
- JSON에 `min_recall_constraint`, precision/recall/threshold 필드 추가
|
||||
- `best_trial` 내 `score`, `precision`, `recall`, `threshold`, `fold_precisions`, `fold_recalls`, `fold_thresholds`, `fold_n_pos` 추가
|
||||
- `best_trial.params` 구조는 그대로 유지 (하위호환)
|
||||
|
||||
### 8. 비교 로직 및 기타 수정
|
||||
- line 440: `study.best_value > baseline_auc` → `study.best_value > baseline_score`
|
||||
- `study_name`: `"lgbm_wf_auc"` → `"lgbm_wf_precision"`
|
||||
- progress callback: Precision과 AUC 동시 표시
|
||||
- `n_warmup_steps` 2 → 3 (precision이 AUC보다 노이즈가 크므로)
|
||||
|
||||
---
|
||||
|
||||
## 검증 방법
|
||||
|
||||
```bash
|
||||
# 기본 실행 (min_recall=0.35)
|
||||
python scripts/tune_hyperparams.py --trials 10 --folds 3
|
||||
|
||||
# min_recall 조절
|
||||
python scripts/tune_hyperparams.py --trials 10 --min-recall 0.4
|
||||
|
||||
# 기존 테스트 통과 확인
|
||||
bash scripts/run_tests.sh
|
||||
```
|
||||
|
||||
확인 포인트:
|
||||
- 폴드별 precision/recall/threshold가 리포트에 표시되는지
|
||||
- recall >= min_recall 제약이 올바르게 동작하는지
|
||||
- active_lgbm_params.json이 precision 기준으로 갱신되는지
|
||||
- train_model.py가 새 JSON 포맷을 기존과 동일하게 읽는지
|
||||
File diff suppressed because it is too large
Load Diff
@@ -301,5 +301,130 @@
|
||||
"max_depth": 6
|
||||
},
|
||||
"weight_scale": 1.783105
|
||||
},
|
||||
{
|
||||
"date": "2026-03-02T18:10:27.584046",
|
||||
"backend": "lgbm",
|
||||
"auc": 0.5466,
|
||||
"best_threshold": 0.6424,
|
||||
"best_precision": 0.426,
|
||||
"best_recall": 0.556,
|
||||
"samples": 535,
|
||||
"features": 23,
|
||||
"time_weight_decay": 0.5,
|
||||
"model_path": "models/lgbm_filter.pkl",
|
||||
"tuned_params_path": null,
|
||||
"lgbm_params": {
|
||||
"n_estimators": 434,
|
||||
"learning_rate": 0.123659,
|
||||
"max_depth": 6,
|
||||
"num_leaves": 14,
|
||||
"min_child_samples": 10,
|
||||
"subsample": 0.929062,
|
||||
"colsample_bytree": 0.94633,
|
||||
"reg_alpha": 0.573971,
|
||||
"reg_lambda": 0.000157
|
||||
},
|
||||
"weight_scale": 1.783105
|
||||
},
|
||||
{
|
||||
"date": "2026-03-03T00:12:17.351458",
|
||||
"backend": "lgbm",
|
||||
"auc": 0.949,
|
||||
"best_threshold": 0.42,
|
||||
"best_precision": 0.56,
|
||||
"best_recall": 0.538,
|
||||
"samples": 1524,
|
||||
"features": 23,
|
||||
"time_weight_decay": 0.5,
|
||||
"model_path": "models/lgbm_filter.pkl",
|
||||
"tuned_params_path": null,
|
||||
"lgbm_params": {
|
||||
"n_estimators": 434,
|
||||
"learning_rate": 0.123659,
|
||||
"max_depth": 6,
|
||||
"num_leaves": 14,
|
||||
"min_child_samples": 10,
|
||||
"subsample": 0.929062,
|
||||
"colsample_bytree": 0.94633,
|
||||
"reg_alpha": 0.573971,
|
||||
"reg_lambda": 0.000157
|
||||
},
|
||||
"weight_scale": 1.783105
|
||||
},
|
||||
{
|
||||
"date": "2026-03-03T00:13:56.456518",
|
||||
"backend": "lgbm",
|
||||
"auc": 0.9439,
|
||||
"best_threshold": 0.6558,
|
||||
"best_precision": 0.667,
|
||||
"best_recall": 0.154,
|
||||
"samples": 1524,
|
||||
"features": 23,
|
||||
"time_weight_decay": 2.0,
|
||||
"model_path": "models/lgbm_filter.pkl",
|
||||
"tuned_params_path": null,
|
||||
"lgbm_params": {
|
||||
"n_estimators": 434,
|
||||
"learning_rate": 0.123659,
|
||||
"max_depth": 6,
|
||||
"num_leaves": 14,
|
||||
"min_child_samples": 10,
|
||||
"subsample": 0.929062,
|
||||
"colsample_bytree": 0.94633,
|
||||
"reg_alpha": 0.573971,
|
||||
"reg_lambda": 0.000157
|
||||
},
|
||||
"weight_scale": 1.783105
|
||||
},
|
||||
{
|
||||
"date": "2026-03-03T00:20:43.712971",
|
||||
"backend": "lgbm",
|
||||
"auc": 0.9473,
|
||||
"best_threshold": 0.3015,
|
||||
"best_precision": 0.465,
|
||||
"best_recall": 0.769,
|
||||
"samples": 1524,
|
||||
"features": 23,
|
||||
"time_weight_decay": 0.5,
|
||||
"model_path": "models/lgbm_filter.pkl",
|
||||
"tuned_params_path": "models/active_lgbm_params.json",
|
||||
"lgbm_params": {
|
||||
"n_estimators": 195,
|
||||
"learning_rate": 0.033934,
|
||||
"max_depth": 3,
|
||||
"num_leaves": 7,
|
||||
"min_child_samples": 11,
|
||||
"subsample": 0.998659,
|
||||
"colsample_bytree": 0.837233,
|
||||
"reg_alpha": 0.007008,
|
||||
"reg_lambda": 0.80039
|
||||
},
|
||||
"weight_scale": 0.718348
|
||||
},
|
||||
{
|
||||
"date": "2026-03-03T00:39:05.427160",
|
||||
"backend": "lgbm",
|
||||
"auc": 0.9436,
|
||||
"best_threshold": 0.3041,
|
||||
"best_precision": 0.467,
|
||||
"best_recall": 0.269,
|
||||
"samples": 1524,
|
||||
"features": 23,
|
||||
"time_weight_decay": 0.5,
|
||||
"model_path": "models/lgbm_filter.pkl",
|
||||
"tuned_params_path": "models/active_lgbm_params.json",
|
||||
"lgbm_params": {
|
||||
"n_estimators": 221,
|
||||
"learning_rate": 0.031072,
|
||||
"max_depth": 5,
|
||||
"num_leaves": 20,
|
||||
"min_child_samples": 39,
|
||||
"subsample": 0.83244,
|
||||
"colsample_bytree": 0.526349,
|
||||
"reg_alpha": 0.062177,
|
||||
"reg_lambda": 0.082872
|
||||
},
|
||||
"weight_scale": 1.431662
|
||||
}
|
||||
]
|
||||
@@ -21,6 +21,5 @@ fi
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
python -m pytest tests/ \
|
||||
--ignore=tests/test_database.py \
|
||||
-v \
|
||||
"$@"
|
||||
|
||||
@@ -17,12 +17,12 @@ import joblib
|
||||
import lightgbm as lgb
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.metrics import roc_auc_score, classification_report
|
||||
from sklearn.metrics import roc_auc_score, classification_report, precision_recall_curve
|
||||
|
||||
from src.indicators import Indicators
|
||||
from src.ml_features import build_features, FEATURE_COLS
|
||||
from src.label_builder import build_labels
|
||||
from src.dataset_builder import generate_dataset_vectorized
|
||||
from src.dataset_builder import generate_dataset_vectorized, stratified_undersample
|
||||
|
||||
def _cgroup_cpu_count() -> int:
|
||||
"""cgroup v1/v2 쿼터를 읽어 실제 할당된 CPU 수를 반환한다.
|
||||
@@ -214,7 +214,11 @@ def train(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str
|
||||
df = df_raw[base_cols].copy()
|
||||
|
||||
print("데이터셋 생성 중...")
|
||||
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=time_weight_decay)
|
||||
dataset = generate_dataset_vectorized(
|
||||
df, btc_df=btc_df, eth_df=eth_df,
|
||||
time_weight_decay=time_weight_decay,
|
||||
negative_ratio=5,
|
||||
)
|
||||
|
||||
if dataset.empty or "label" not in dataset.columns:
|
||||
raise ValueError(f"데이터셋 생성 실패: 샘플 0개. 위 오류 메시지를 확인하세요.")
|
||||
@@ -229,6 +233,7 @@ def train(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str
|
||||
X = dataset[actual_feature_cols]
|
||||
y = dataset["label"]
|
||||
w = dataset["sample_weight"].values
|
||||
source = dataset["source"].values if "source" in dataset.columns else np.full(len(X), "signal")
|
||||
|
||||
split = int(len(X) * 0.8)
|
||||
X_train, X_val = X.iloc[:split], X.iloc[split:]
|
||||
@@ -238,21 +243,19 @@ def train(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str
|
||||
lgbm_params, weight_scale = _load_lgbm_params(tuned_params_path)
|
||||
w_train = (w[:split] * weight_scale).astype(np.float32)
|
||||
|
||||
# --- 클래스 불균형 처리: 언더샘플링 (시간 가중치 인덱스 보존) ---
|
||||
pos_idx = np.where(y_train == 1)[0]
|
||||
neg_idx = np.where(y_train == 0)[0]
|
||||
|
||||
if len(neg_idx) > len(pos_idx):
|
||||
np.random.seed(42)
|
||||
neg_idx = np.random.choice(neg_idx, size=len(pos_idx), replace=False)
|
||||
|
||||
balanced_idx = np.sort(np.concatenate([pos_idx, neg_idx])) # 시간 순서 유지
|
||||
# --- 계층적 샘플링: signal 전수 유지, HOLD negative만 양성 수 만큼 ---
|
||||
source_train = source[:split]
|
||||
balanced_idx = stratified_undersample(y_train.values, source_train, seed=42)
|
||||
|
||||
X_train = X_train.iloc[balanced_idx]
|
||||
y_train = y_train.iloc[balanced_idx]
|
||||
w_train = w_train[balanced_idx]
|
||||
|
||||
print(f"\n언더샘플링 후 학습 데이터: {len(X_train)}개 (양성={y_train.sum()}, 음성={(y_train==0).sum()})")
|
||||
sig_count = (source_train[balanced_idx] == "signal").sum()
|
||||
hold_count = (source_train[balanced_idx] == "hold_negative").sum()
|
||||
print(f"\n계층적 샘플링 후 학습 데이터: {len(X_train)}개 "
|
||||
f"(Signal={sig_count}, HOLD={hold_count}, "
|
||||
f"양성={int(y_train.sum())}, 음성={int((y_train==0).sum())})")
|
||||
print(f"검증 데이터: {len(X_val)}개 (양성={int(y_val.sum())}, 음성={int((y_val==0).sum())})")
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -272,7 +275,6 @@ def train(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str
|
||||
auc = roc_auc_score(y_val, val_proba)
|
||||
|
||||
# 최적 임계값 탐색: 최소 재현율(0.15) 조건부 정밀도 최대화
|
||||
from sklearn.metrics import precision_recall_curve
|
||||
precisions, recalls, thresholds = precision_recall_curve(y_val, val_proba)
|
||||
# precision_recall_curve의 마지막 원소는 (1.0, 0.0)이므로 제외
|
||||
precisions, recalls = precisions[:-1], recalls[:-1]
|
||||
@@ -354,13 +356,16 @@ def walk_forward_auc(
|
||||
df = df_raw[base_cols].copy()
|
||||
|
||||
dataset = generate_dataset_vectorized(
|
||||
df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=time_weight_decay
|
||||
df, btc_df=btc_df, eth_df=eth_df,
|
||||
time_weight_decay=time_weight_decay,
|
||||
negative_ratio=5,
|
||||
)
|
||||
actual_feature_cols = [c for c in FEATURE_COLS if c in dataset.columns]
|
||||
X = dataset[actual_feature_cols].values
|
||||
y = dataset["label"].values
|
||||
w = dataset["sample_weight"].values
|
||||
n = len(dataset)
|
||||
source = dataset["source"].values if "source" in dataset.columns else np.full(n, "signal")
|
||||
|
||||
lgbm_params, weight_scale = _load_lgbm_params(tuned_params_path)
|
||||
w = (w * weight_scale).astype(np.float32)
|
||||
@@ -369,6 +374,7 @@ def walk_forward_auc(
|
||||
train_end_start = int(n * train_ratio)
|
||||
|
||||
aucs = []
|
||||
fold_metrics = []
|
||||
for i in range(n_splits):
|
||||
tr_end = train_end_start + i * step
|
||||
val_end = tr_end + step
|
||||
@@ -378,12 +384,8 @@ def walk_forward_auc(
|
||||
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
|
||||
X_val, y_val = X[tr_end:val_end], y[tr_end:val_end]
|
||||
|
||||
pos_idx = np.where(y_tr == 1)[0]
|
||||
neg_idx = np.where(y_tr == 0)[0]
|
||||
if len(neg_idx) > len(pos_idx):
|
||||
np.random.seed(42)
|
||||
neg_idx = np.random.choice(neg_idx, size=len(pos_idx), replace=False)
|
||||
idx = np.sort(np.concatenate([pos_idx, neg_idx]))
|
||||
source_tr = source[:tr_end]
|
||||
idx = stratified_undersample(y_tr, source_tr, seed=42)
|
||||
|
||||
model = lgb.LGBMClassifier(**lgbm_params, random_state=42, verbose=-1)
|
||||
with warnings.catch_warnings():
|
||||
@@ -393,12 +395,30 @@ def walk_forward_auc(
|
||||
proba = model.predict_proba(X_val)[:, 1]
|
||||
auc = roc_auc_score(y_val, proba) if len(np.unique(y_val)) > 1 else 0.5
|
||||
aucs.append(auc)
|
||||
|
||||
# 폴드별 최적 임계값 (recall >= 0.15 조건부 precision 최대화)
|
||||
MIN_RECALL = 0.15
|
||||
precs, recs, thrs = precision_recall_curve(y_val, proba)
|
||||
precs, recs = precs[:-1], recs[:-1]
|
||||
valid_idx = np.where(recs >= MIN_RECALL)[0]
|
||||
if len(valid_idx) > 0:
|
||||
best_i = valid_idx[np.argmax(precs[valid_idx])]
|
||||
f_thr, f_prec, f_rec = float(thrs[best_i]), float(precs[best_i]), float(recs[best_i])
|
||||
else:
|
||||
f_thr, f_prec, f_rec = 0.50, 0.0, 0.0
|
||||
|
||||
fold_metrics.append({"auc": auc, "precision": f_prec, "recall": f_rec, "threshold": f_thr})
|
||||
print(
|
||||
f" 폴드 {i+1}/{n_splits}: 학습={tr_end}개, "
|
||||
f"검증={tr_end}~{val_end} ({step}개), AUC={auc:.4f}"
|
||||
f"검증={tr_end}~{val_end} ({step}개), AUC={auc:.4f} | "
|
||||
f"Thr={f_thr:.4f} Prec={f_prec:.3f} Rec={f_rec:.3f}"
|
||||
)
|
||||
|
||||
mean_prec = np.mean([m["precision"] for m in fold_metrics])
|
||||
mean_rec = np.mean([m["recall"] for m in fold_metrics])
|
||||
mean_thr = np.mean([m["threshold"] for m in fold_metrics])
|
||||
print(f"\n Walk-Forward 평균 AUC: {np.mean(aucs):.4f} ± {np.std(aucs):.4f}")
|
||||
print(f" 평균 Precision: {mean_prec:.3f} | 평균 Recall: {mean_rec:.3f} | 평균 Threshold: {mean_thr:.4f}")
|
||||
print(f" 폴드별: {[round(a, 4) for a in aucs]}")
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ Optuna를 사용한 LightGBM 하이퍼파라미터 자동 탐색.
|
||||
python scripts/tune_hyperparams.py --trials 10 --folds 3 # 빠른 테스트
|
||||
python scripts/tune_hyperparams.py --data data/combined_15m.parquet --trials 100
|
||||
python scripts/tune_hyperparams.py --no-baseline # 베이스라인 측정 건너뜀
|
||||
python scripts/tune_hyperparams.py --min-recall 0.4 # 최소 재현율 제약 조정
|
||||
|
||||
결과:
|
||||
- 콘솔: Best Params + Walk-Forward 리포트
|
||||
@@ -28,17 +29,17 @@ import lightgbm as lgb
|
||||
import optuna
|
||||
from optuna.samplers import TPESampler
|
||||
from optuna.pruners import MedianPruner
|
||||
from sklearn.metrics import roc_auc_score
|
||||
from sklearn.metrics import roc_auc_score, precision_recall_curve
|
||||
|
||||
from src.ml_features import FEATURE_COLS
|
||||
from src.dataset_builder import generate_dataset_vectorized
|
||||
from src.dataset_builder import generate_dataset_vectorized, stratified_undersample
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 데이터 로드 및 데이터셋 생성 (1회 캐싱)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""
|
||||
parquet 로드 → 벡터화 데이터셋 생성 → (X, y, w) numpy 배열 반환.
|
||||
study 시작 전 1회만 호출하여 모든 trial이 공유한다.
|
||||
@@ -63,7 +64,7 @@ def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
df = df_raw[base_cols].copy()
|
||||
|
||||
print("\n데이터셋 생성 중 (1회만 실행)...")
|
||||
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=0.0)
|
||||
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=0.0, negative_ratio=5)
|
||||
|
||||
if dataset.empty or "label" not in dataset.columns:
|
||||
raise ValueError("데이터셋 생성 실패: 샘플 0개")
|
||||
@@ -72,13 +73,45 @@ def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
X = dataset[actual_feature_cols].values.astype(np.float32)
|
||||
y = dataset["label"].values.astype(np.int8)
|
||||
w = dataset["sample_weight"].values.astype(np.float32)
|
||||
source = dataset["source"].values if "source" in dataset.columns else np.full(len(dataset), "signal")
|
||||
|
||||
pos = int(y.sum())
|
||||
neg = int((y == 0).sum())
|
||||
print(f"데이터셋 완성: {len(dataset):,}개 샘플 (양성={pos}, 음성={neg})")
|
||||
print(f"사용 피처: {len(actual_feature_cols)}개\n")
|
||||
|
||||
return X, y, w
|
||||
return X, y, w, source
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Precision 헬퍼
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _find_best_precision_at_recall(
|
||||
y_true: np.ndarray,
|
||||
proba: np.ndarray,
|
||||
min_recall: float = 0.35,
|
||||
) -> tuple[float, float, float]:
|
||||
"""
|
||||
precision_recall_curve에서 recall >= min_recall 조건을 만족하는
|
||||
최대 precision과 해당 threshold를 반환한다.
|
||||
|
||||
Returns:
|
||||
(best_precision, best_recall, best_threshold)
|
||||
조건 불만족 시 (0.0, 0.0, 0.50)
|
||||
"""
|
||||
precisions, recalls, thresholds = precision_recall_curve(y_true, proba)
|
||||
precisions, recalls = precisions[:-1], recalls[:-1]
|
||||
|
||||
valid_idx = np.where(recalls >= min_recall)[0]
|
||||
if len(valid_idx) > 0:
|
||||
best_idx = valid_idx[np.argmax(precisions[valid_idx])]
|
||||
return (
|
||||
float(precisions[best_idx]),
|
||||
float(recalls[best_idx]),
|
||||
float(thresholds[best_idx]),
|
||||
)
|
||||
return (0.0, 0.0, 0.50)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -89,20 +122,32 @@ def _walk_forward_cv(
|
||||
X: np.ndarray,
|
||||
y: np.ndarray,
|
||||
w: np.ndarray,
|
||||
source: np.ndarray,
|
||||
params: dict,
|
||||
n_splits: int,
|
||||
train_ratio: float,
|
||||
min_recall: float = 0.35,
|
||||
trial: "optuna.Trial | None" = None,
|
||||
) -> tuple[float, list[float]]:
|
||||
) -> tuple[float, dict]:
|
||||
"""
|
||||
Walk-Forward 교차검증으로 평균 AUC를 반환한다.
|
||||
Walk-Forward 교차검증으로 precision 기반 복합 점수를 반환한다.
|
||||
Score = mean_precision + mean_auc * 0.001 (AUC는 tiebreaker)
|
||||
|
||||
trial이 제공되면 각 폴드 후 Optuna에 중간 값을 보고하여 Pruning을 활성화한다.
|
||||
|
||||
Returns:
|
||||
(mean_score, details) where details contains per-fold metrics.
|
||||
"""
|
||||
n = len(X)
|
||||
step = max(1, int(n * (1 - train_ratio) / n_splits))
|
||||
train_end_start = int(n * train_ratio)
|
||||
|
||||
fold_aucs: list[float] = []
|
||||
fold_precisions: list[float] = []
|
||||
fold_recalls: list[float] = []
|
||||
fold_thresholds: list[float] = []
|
||||
fold_n_pos: list[int] = []
|
||||
scores_so_far: list[float] = []
|
||||
|
||||
for fold_idx in range(n_splits):
|
||||
tr_end = train_end_start + fold_idx * step
|
||||
@@ -113,16 +158,18 @@ def _walk_forward_cv(
|
||||
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
|
||||
X_val, y_val = X[tr_end:val_end], y[tr_end:val_end]
|
||||
|
||||
# 클래스 불균형 처리: 언더샘플링 (시간 순서 유지)
|
||||
pos_idx = np.where(y_tr == 1)[0]
|
||||
neg_idx = np.where(y_tr == 0)[0]
|
||||
if len(neg_idx) > len(pos_idx) and len(pos_idx) > 0:
|
||||
rng = np.random.default_rng(42)
|
||||
neg_idx = rng.choice(neg_idx, size=len(pos_idx), replace=False)
|
||||
bal_idx = np.sort(np.concatenate([pos_idx, neg_idx]))
|
||||
# 계층적 샘플링: signal 전수 유지, HOLD negative만 양성 수 만큼
|
||||
source_tr = source[:tr_end]
|
||||
bal_idx = stratified_undersample(y_tr, source_tr, seed=42)
|
||||
|
||||
n_pos = int(y_val.sum())
|
||||
|
||||
if len(bal_idx) < 20 or len(np.unique(y_val)) < 2:
|
||||
fold_aucs.append(0.5)
|
||||
fold_precisions.append(0.0)
|
||||
fold_recalls.append(0.0)
|
||||
fold_thresholds.append(0.50)
|
||||
fold_n_pos.append(n_pos)
|
||||
continue
|
||||
|
||||
model = lgb.LGBMClassifier(**params, random_state=42, verbose=-1)
|
||||
@@ -134,14 +181,47 @@ def _walk_forward_cv(
|
||||
auc = roc_auc_score(y_val, proba) if len(np.unique(y_val)) > 1 else 0.5
|
||||
fold_aucs.append(float(auc))
|
||||
|
||||
# Optuna Pruning: 중간 값 보고
|
||||
if trial is not None:
|
||||
trial.report(float(np.mean(fold_aucs)), step=fold_idx)
|
||||
if trial.should_prune():
|
||||
raise optuna.TrialPruned()
|
||||
# Precision at recall-constrained threshold
|
||||
if n_pos >= 3:
|
||||
prec, rec, thr = _find_best_precision_at_recall(y_val, proba, min_recall)
|
||||
else:
|
||||
prec, rec, thr = 0.0, 0.0, 0.50
|
||||
|
||||
fold_precisions.append(prec)
|
||||
fold_recalls.append(rec)
|
||||
fold_thresholds.append(thr)
|
||||
fold_n_pos.append(n_pos)
|
||||
|
||||
# Pruning: 양성 충분한 fold의 score만 보고
|
||||
score = prec + auc * 0.001
|
||||
scores_so_far.append(score)
|
||||
if trial is not None and n_pos >= 3:
|
||||
valid_scores = [s for s, np_ in zip(scores_so_far, fold_n_pos) if np_ >= 3]
|
||||
if valid_scores:
|
||||
trial.report(float(np.mean(valid_scores)), step=fold_idx)
|
||||
if trial.should_prune():
|
||||
raise optuna.TrialPruned()
|
||||
|
||||
# 양성 충분한 fold만으로 precision 평균 계산
|
||||
valid_precs = [p for p, np_ in zip(fold_precisions, fold_n_pos) if np_ >= 3]
|
||||
mean_auc = float(np.mean(fold_aucs)) if fold_aucs else 0.5
|
||||
return mean_auc, fold_aucs
|
||||
mean_prec = float(np.mean(valid_precs)) if valid_precs else 0.0
|
||||
valid_recs = [r for r, np_ in zip(fold_recalls, fold_n_pos) if np_ >= 3]
|
||||
mean_rec = float(np.mean(valid_recs)) if valid_recs else 0.0
|
||||
mean_score = mean_prec + mean_auc * 0.001
|
||||
|
||||
details = {
|
||||
"fold_aucs": fold_aucs,
|
||||
"fold_precisions": fold_precisions,
|
||||
"fold_recalls": fold_recalls,
|
||||
"fold_thresholds": fold_thresholds,
|
||||
"fold_n_pos": fold_n_pos,
|
||||
"mean_auc": mean_auc,
|
||||
"mean_precision": mean_prec,
|
||||
"mean_recall": mean_rec,
|
||||
}
|
||||
|
||||
return mean_score, details
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -152,8 +232,10 @@ def make_objective(
|
||||
X: np.ndarray,
|
||||
y: np.ndarray,
|
||||
w: np.ndarray,
|
||||
source: np.ndarray,
|
||||
n_splits: int,
|
||||
train_ratio: float,
|
||||
min_recall: float = 0.35,
|
||||
):
|
||||
"""클로저로 데이터셋을 캡처한 목적 함수를 반환한다."""
|
||||
|
||||
@@ -191,33 +273,43 @@ def make_objective(
|
||||
"reg_lambda": reg_lambda,
|
||||
}
|
||||
|
||||
mean_auc, fold_aucs = _walk_forward_cv(
|
||||
X, y, w_scaled, params,
|
||||
mean_score, details = _walk_forward_cv(
|
||||
X, y, w_scaled, source, params,
|
||||
n_splits=n_splits,
|
||||
train_ratio=train_ratio,
|
||||
min_recall=min_recall,
|
||||
trial=trial,
|
||||
)
|
||||
|
||||
# 폴드별 AUC를 user_attrs에 저장 (결과 리포트용)
|
||||
trial.set_user_attr("fold_aucs", fold_aucs)
|
||||
# 폴드별 상세 메트릭을 user_attrs에 저장 (결과 리포트용)
|
||||
trial.set_user_attr("fold_aucs", details["fold_aucs"])
|
||||
trial.set_user_attr("fold_precisions", details["fold_precisions"])
|
||||
trial.set_user_attr("fold_recalls", details["fold_recalls"])
|
||||
trial.set_user_attr("fold_thresholds", details["fold_thresholds"])
|
||||
trial.set_user_attr("fold_n_pos", details["fold_n_pos"])
|
||||
trial.set_user_attr("mean_auc", details["mean_auc"])
|
||||
trial.set_user_attr("mean_precision", details["mean_precision"])
|
||||
trial.set_user_attr("mean_recall", details["mean_recall"])
|
||||
|
||||
return mean_auc
|
||||
return mean_score
|
||||
|
||||
return objective
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 베이스라인 AUC 측정 (현재 고정 파라미터)
|
||||
# 베이스라인 측정 (현재 고정 파라미터)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def measure_baseline(
|
||||
X: np.ndarray,
|
||||
y: np.ndarray,
|
||||
w: np.ndarray,
|
||||
source: np.ndarray,
|
||||
n_splits: int,
|
||||
train_ratio: float,
|
||||
) -> tuple[float, list[float]]:
|
||||
"""현재 실전 파라미터(active 파일 또는 하드코딩 기본값)로 베이스라인 AUC를 측정한다."""
|
||||
min_recall: float = 0.35,
|
||||
) -> tuple[float, dict]:
|
||||
"""현재 실전 파라미터(active 파일 또는 하드코딩 기본값)로 베이스라인을 측정한다."""
|
||||
active_path = Path("models/active_lgbm_params.json")
|
||||
|
||||
if active_path.exists():
|
||||
@@ -241,7 +333,11 @@ def measure_baseline(
|
||||
}
|
||||
print("베이스라인 측정 중 (active 파일 없음 → 코드 내 기본 파라미터)...")
|
||||
|
||||
return _walk_forward_cv(X, y, w, baseline_params, n_splits=n_splits, train_ratio=train_ratio)
|
||||
return _walk_forward_cv(
|
||||
X, y, w, source, baseline_params,
|
||||
n_splits=n_splits, train_ratio=train_ratio,
|
||||
min_recall=min_recall,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -250,17 +346,24 @@ def measure_baseline(
|
||||
|
||||
def print_report(
|
||||
study: optuna.Study,
|
||||
baseline_auc: float,
|
||||
baseline_folds: list[float],
|
||||
baseline_score: float,
|
||||
baseline_details: dict,
|
||||
elapsed_sec: float,
|
||||
output_path: Path,
|
||||
min_recall: float,
|
||||
) -> None:
|
||||
"""콘솔에 최종 리포트를 출력한다."""
|
||||
best = study.best_trial
|
||||
best_auc = best.value
|
||||
best_folds = best.user_attrs.get("fold_aucs", [])
|
||||
improvement = best_auc - baseline_auc
|
||||
improvement_pct = (improvement / baseline_auc * 100) if baseline_auc > 0 else 0.0
|
||||
best_score = best.value
|
||||
best_prec = best.user_attrs.get("mean_precision", 0.0)
|
||||
best_auc = best.user_attrs.get("mean_auc", 0.0)
|
||||
best_rec = best.user_attrs.get("mean_recall", 0.0)
|
||||
|
||||
baseline_prec = baseline_details.get("mean_precision", 0.0)
|
||||
baseline_auc = baseline_details.get("mean_auc", 0.0)
|
||||
|
||||
prec_improvement = best_prec - baseline_prec
|
||||
prec_improvement_pct = (prec_improvement / baseline_prec * 100) if baseline_prec > 0 else 0.0
|
||||
|
||||
elapsed_min = int(elapsed_sec // 60)
|
||||
elapsed_s = int(elapsed_sec % 60)
|
||||
@@ -276,11 +379,15 @@ def print_report(
|
||||
f"(완료={len(completed)}, 조기종료={len(pruned)}) | "
|
||||
f"소요: {elapsed_min}분 {elapsed_s}초")
|
||||
print(sep)
|
||||
print(f" Best AUC : {best_auc:.4f} (Trial #{best.number})")
|
||||
if baseline_auc > 0:
|
||||
sign = "+" if improvement >= 0 else ""
|
||||
print(f" Baseline : {baseline_auc:.4f} (현재 train_model.py 고정값)")
|
||||
print(f" 개선폭 : {sign}{improvement:.4f} ({sign}{improvement_pct:.1f}%)")
|
||||
print(f" 최적화 지표: Precision (recall >= {min_recall} 제약)")
|
||||
print(f" Best Prec : {best_prec:.4f} (Trial #{best.number})")
|
||||
print(f" Best AUC : {best_auc:.4f}")
|
||||
print(f" Best Recall: {best_rec:.4f}")
|
||||
if baseline_score > 0:
|
||||
sign = "+" if prec_improvement >= 0 else ""
|
||||
print(dash)
|
||||
print(f" Baseline : Prec={baseline_prec:.4f}, AUC={baseline_auc:.4f}")
|
||||
print(f" 개선폭 : Precision {sign}{prec_improvement:.4f} ({sign}{prec_improvement_pct:.1f}%)")
|
||||
print(dash)
|
||||
print(" Best Parameters:")
|
||||
for k, v in best.params.items():
|
||||
@@ -289,19 +396,42 @@ def print_report(
|
||||
else:
|
||||
print(f" {k:<22}: {v}")
|
||||
print(dash)
|
||||
print(" Walk-Forward 폴드별 AUC (Best Trial):")
|
||||
for i, auc in enumerate(best_folds, 1):
|
||||
print(f" 폴드 {i}: {auc:.4f}")
|
||||
if best_folds:
|
||||
arr = np.array(best_folds)
|
||||
print(f" 평균: {arr.mean():.4f} ± {arr.std():.4f}")
|
||||
if baseline_folds:
|
||||
|
||||
# 폴드별 상세
|
||||
fold_aucs = best.user_attrs.get("fold_aucs", [])
|
||||
fold_precs = best.user_attrs.get("fold_precisions", [])
|
||||
fold_recs = best.user_attrs.get("fold_recalls", [])
|
||||
fold_thrs = best.user_attrs.get("fold_thresholds", [])
|
||||
fold_npos = best.user_attrs.get("fold_n_pos", [])
|
||||
|
||||
print(" Walk-Forward 폴드별 상세 (Best Trial):")
|
||||
for i, (auc, prec, rec, thr, npos) in enumerate(
|
||||
zip(fold_aucs, fold_precs, fold_recs, fold_thrs, fold_npos), 1
|
||||
):
|
||||
print(f" 폴드 {i}: AUC={auc:.4f} Prec={prec:.3f} Rec={rec:.3f} Thr={thr:.3f} (양성={npos})")
|
||||
if fold_precs:
|
||||
valid_precs = [p for p, np_ in zip(fold_precs, fold_npos) if np_ >= 3]
|
||||
if valid_precs:
|
||||
arr_p = np.array(valid_precs)
|
||||
print(f" 평균 Precision: {arr_p.mean():.4f} ± {arr_p.std():.4f}")
|
||||
if fold_aucs:
|
||||
arr_a = np.array(fold_aucs)
|
||||
print(f" 평균 AUC: {arr_a.mean():.4f} ± {arr_a.std():.4f}")
|
||||
|
||||
# 베이스라인 폴드별
|
||||
bl_folds = baseline_details.get("fold_aucs", [])
|
||||
bl_precs = baseline_details.get("fold_precisions", [])
|
||||
bl_recs = baseline_details.get("fold_recalls", [])
|
||||
bl_thrs = baseline_details.get("fold_thresholds", [])
|
||||
bl_npos = baseline_details.get("fold_n_pos", [])
|
||||
if bl_folds:
|
||||
print(dash)
|
||||
print(" Baseline 폴드별 AUC:")
|
||||
for i, auc in enumerate(baseline_folds, 1):
|
||||
print(f" 폴드 {i}: {auc:.4f}")
|
||||
arr = np.array(baseline_folds)
|
||||
print(f" 평균: {arr.mean():.4f} ± {arr.std():.4f}")
|
||||
print(" Baseline 폴드별 상세:")
|
||||
for i, (auc, prec, rec, thr, npos) in enumerate(
|
||||
zip(bl_folds, bl_precs, bl_recs, bl_thrs, bl_npos), 1
|
||||
):
|
||||
print(f" 폴드 {i}: AUC={auc:.4f} Prec={prec:.3f} Rec={rec:.3f} Thr={thr:.3f} (양성={npos})")
|
||||
|
||||
print(dash)
|
||||
print(f" 결과 저장: {output_path}")
|
||||
print(f" 다음 단계: python scripts/train_model.py (파라미터 수동 반영 후)")
|
||||
@@ -310,10 +440,11 @@ def print_report(
|
||||
|
||||
def save_results(
|
||||
study: optuna.Study,
|
||||
baseline_auc: float,
|
||||
baseline_folds: list[float],
|
||||
baseline_score: float,
|
||||
baseline_details: dict,
|
||||
elapsed_sec: float,
|
||||
data_path: str,
|
||||
min_recall: float,
|
||||
) -> Path:
|
||||
"""결과를 JSON 파일로 저장하고 경로를 반환한다."""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
@@ -327,8 +458,12 @@ def save_results(
|
||||
if t.state == optuna.trial.TrialState.COMPLETE:
|
||||
all_trials.append({
|
||||
"number": t.number,
|
||||
"auc": round(t.value, 6),
|
||||
"score": round(t.value, 6),
|
||||
"auc": round(t.user_attrs.get("mean_auc", 0.0), 6),
|
||||
"precision": round(t.user_attrs.get("mean_precision", 0.0), 6),
|
||||
"recall": round(t.user_attrs.get("mean_recall", 0.0), 6),
|
||||
"fold_aucs": [round(a, 6) for a in t.user_attrs.get("fold_aucs", [])],
|
||||
"fold_precisions": [round(p, 6) for p in t.user_attrs.get("fold_precisions", [])],
|
||||
"params": {
|
||||
k: (round(v, 6) if isinstance(v, float) else v)
|
||||
for k, v in t.params.items()
|
||||
@@ -336,19 +471,33 @@ def save_results(
|
||||
})
|
||||
|
||||
result = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"data_path": data_path,
|
||||
"n_trials_total": len(study.trials),
|
||||
"n_trials_complete": len(all_trials),
|
||||
"elapsed_sec": round(elapsed_sec, 1),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"data_path": data_path,
|
||||
"min_recall_constraint": min_recall,
|
||||
"n_trials_total": len(study.trials),
|
||||
"n_trials_complete": len(all_trials),
|
||||
"elapsed_sec": round(elapsed_sec, 1),
|
||||
"baseline": {
|
||||
"auc": round(baseline_auc, 6),
|
||||
"fold_aucs": [round(a, 6) for a in baseline_folds],
|
||||
"score": round(baseline_score, 6),
|
||||
"auc": round(baseline_details.get("mean_auc", 0.0), 6),
|
||||
"precision": round(baseline_details.get("mean_precision", 0.0), 6),
|
||||
"recall": round(baseline_details.get("mean_recall", 0.0), 6),
|
||||
"fold_aucs": [round(a, 6) for a in baseline_details.get("fold_aucs", [])],
|
||||
"fold_precisions": [round(p, 6) for p in baseline_details.get("fold_precisions", [])],
|
||||
"fold_recalls": [round(r, 6) for r in baseline_details.get("fold_recalls", [])],
|
||||
"fold_thresholds": [round(t, 6) for t in baseline_details.get("fold_thresholds", [])],
|
||||
},
|
||||
"best_trial": {
|
||||
"number": best.number,
|
||||
"auc": round(best.value, 6),
|
||||
"fold_aucs": [round(a, 6) for a in best.user_attrs.get("fold_aucs", [])],
|
||||
"number": best.number,
|
||||
"score": round(best.value, 6),
|
||||
"auc": round(best.user_attrs.get("mean_auc", 0.0), 6),
|
||||
"precision": round(best.user_attrs.get("mean_precision", 0.0), 6),
|
||||
"recall": round(best.user_attrs.get("mean_recall", 0.0), 6),
|
||||
"fold_aucs": [round(a, 6) for a in best.user_attrs.get("fold_aucs", [])],
|
||||
"fold_precisions": [round(p, 6) for p in best.user_attrs.get("fold_precisions", [])],
|
||||
"fold_recalls": [round(r, 6) for r in best.user_attrs.get("fold_recalls", [])],
|
||||
"fold_thresholds": [round(t, 6) for t in best.user_attrs.get("fold_thresholds", [])],
|
||||
"fold_n_pos": best.user_attrs.get("fold_n_pos", []),
|
||||
"params": {
|
||||
k: (round(v, 6) if isinstance(v, float) else v)
|
||||
for k, v in best.params.items()
|
||||
@@ -373,37 +522,49 @@ def main():
|
||||
parser.add_argument("--trials", type=int, default=50, help="Optuna trial 수 (기본: 50)")
|
||||
parser.add_argument("--folds", type=int, default=5, help="Walk-Forward 폴드 수 (기본: 5)")
|
||||
parser.add_argument("--train-ratio", type=float, default=0.6, help="학습 구간 비율 (기본: 0.6)")
|
||||
parser.add_argument("--min-recall", type=float, default=0.35, help="최소 재현율 제약 (기본: 0.35)")
|
||||
parser.add_argument("--no-baseline", action="store_true", help="베이스라인 측정 건너뜀")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 1. 데이터셋 로드 (1회)
|
||||
X, y, w = load_dataset(args.data)
|
||||
X, y, w, source = load_dataset(args.data)
|
||||
|
||||
# 2. 베이스라인 측정
|
||||
if args.no_baseline:
|
||||
baseline_auc, baseline_folds = 0.0, []
|
||||
baseline_score, baseline_details = 0.0, {}
|
||||
print("베이스라인 측정 건너뜀 (--no-baseline)\n")
|
||||
else:
|
||||
baseline_auc, baseline_folds = measure_baseline(X, y, w, args.folds, args.train_ratio)
|
||||
baseline_score, baseline_details = measure_baseline(
|
||||
X, y, w, source, args.folds, args.train_ratio, args.min_recall,
|
||||
)
|
||||
bl_prec = baseline_details.get("mean_precision", 0.0)
|
||||
bl_auc = baseline_details.get("mean_auc", 0.0)
|
||||
bl_rec = baseline_details.get("mean_recall", 0.0)
|
||||
print(
|
||||
f"베이스라인 AUC: {baseline_auc:.4f} "
|
||||
f"(폴드별: {[round(a, 4) for a in baseline_folds]})\n"
|
||||
f"베이스라인: Prec={bl_prec:.4f}, AUC={bl_auc:.4f}, Recall={bl_rec:.4f} "
|
||||
f"(recall >= {args.min_recall} 제약)\n"
|
||||
)
|
||||
|
||||
# 3. Optuna study 실행
|
||||
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
||||
sampler = TPESampler(seed=42)
|
||||
pruner = MedianPruner(n_startup_trials=5, n_warmup_steps=2)
|
||||
pruner = MedianPruner(n_startup_trials=5, n_warmup_steps=3)
|
||||
study = optuna.create_study(
|
||||
direction="maximize",
|
||||
sampler=sampler,
|
||||
pruner=pruner,
|
||||
study_name="lgbm_wf_auc",
|
||||
study_name="lgbm_wf_precision",
|
||||
)
|
||||
|
||||
objective = make_objective(X, y, w, n_splits=args.folds, train_ratio=args.train_ratio)
|
||||
objective = make_objective(
|
||||
X, y, w, source,
|
||||
n_splits=args.folds,
|
||||
train_ratio=args.train_ratio,
|
||||
min_recall=args.min_recall,
|
||||
)
|
||||
|
||||
print(f"Optuna 탐색 시작: {args.trials} trials, {args.folds}폴드 Walk-Forward")
|
||||
print(f"최적화 지표: Precision (recall >= {args.min_recall} 제약)")
|
||||
print("(trial 완료마다 진행 상황 출력)\n")
|
||||
|
||||
start_time = time.time()
|
||||
@@ -411,12 +572,13 @@ def main():
|
||||
def _progress_callback(study: optuna.Study, trial: optuna.trial.FrozenTrial) -> None:
|
||||
if trial.state == optuna.trial.TrialState.COMPLETE:
|
||||
best_so_far = study.best_value
|
||||
leaves = trial.params.get("num_leaves", "?")
|
||||
depth = trial.params.get("max_depth", "?")
|
||||
prec = trial.user_attrs.get("mean_precision", 0.0)
|
||||
auc = trial.user_attrs.get("mean_auc", 0.0)
|
||||
print(
|
||||
f" Trial #{trial.number:3d} | AUC={trial.value:.4f} "
|
||||
f" Trial #{trial.number:3d} | Prec={prec:.4f} AUC={auc:.4f} "
|
||||
f"| Best={best_so_far:.4f} "
|
||||
f"| leaves={leaves} depth={depth}"
|
||||
f"| leaves={trial.params.get('num_leaves', '?')} "
|
||||
f"depth={trial.params.get('max_depth', '?')}"
|
||||
)
|
||||
elif trial.state == optuna.trial.TrialState.PRUNED:
|
||||
print(f" Trial #{trial.number:3d} | PRUNED (조기 종료)")
|
||||
@@ -431,21 +593,32 @@ def main():
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# 4. 결과 저장 및 출력
|
||||
output_path = save_results(study, baseline_auc, baseline_folds, elapsed, args.data)
|
||||
print_report(study, baseline_auc, baseline_folds, elapsed, output_path)
|
||||
output_path = save_results(
|
||||
study, baseline_score, baseline_details, elapsed, args.data, args.min_recall,
|
||||
)
|
||||
print_report(
|
||||
study, baseline_score, baseline_details, elapsed, output_path, args.min_recall,
|
||||
)
|
||||
|
||||
# 5. 성능 개선 시 active 파일 자동 갱신
|
||||
import shutil
|
||||
active_path = Path("models/active_lgbm_params.json")
|
||||
if not args.no_baseline and study.best_value > baseline_auc:
|
||||
if not args.no_baseline and study.best_value > baseline_score:
|
||||
shutil.copy(output_path, active_path)
|
||||
improvement = study.best_value - baseline_auc
|
||||
print(f"[MLOps] AUC +{improvement:.4f} 개선 → {active_path} 자동 갱신 완료")
|
||||
best_prec = study.best_trial.user_attrs.get("mean_precision", 0.0)
|
||||
bl_prec = baseline_details.get("mean_precision", 0.0)
|
||||
improvement = best_prec - bl_prec
|
||||
print(f"[MLOps] Precision +{improvement:.4f} 개선 → {active_path} 자동 갱신 완료")
|
||||
print(f"[MLOps] 다음 train_model.py 실행 시 새 파라미터가 자동 적용됩니다.\n")
|
||||
elif args.no_baseline:
|
||||
print("[MLOps] --no-baseline 모드: 성능 비교 없이 active 파일 유지\n")
|
||||
else:
|
||||
print(f"[MLOps] 성능 개선 없음 (Best={study.best_value:.4f} ≤ Baseline={baseline_auc:.4f}) → active 파일 유지\n")
|
||||
best_prec = study.best_trial.user_attrs.get("mean_precision", 0.0)
|
||||
bl_prec = baseline_details.get("mean_precision", 0.0)
|
||||
print(
|
||||
f"[MLOps] 성능 개선 없음 (Prec={best_prec:.4f} ≤ Baseline={bl_prec:.4f}) "
|
||||
f"→ active 파일 유지\n"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
118
src/bot.py
118
src/bot.py
@@ -9,6 +9,7 @@ from src.notifier import DiscordNotifier
|
||||
from src.risk_manager import RiskManager
|
||||
from src.ml_filter import MLFilter
|
||||
from src.ml_features import build_features
|
||||
from src.user_data_stream import UserDataStream
|
||||
|
||||
|
||||
class TradingBot:
|
||||
@@ -19,6 +20,9 @@ class TradingBot:
|
||||
self.risk = RiskManager(config)
|
||||
self.ml_filter = MLFilter()
|
||||
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.stream = MultiSymbolStream(
|
||||
symbols=[config.symbol, "BTCUSDT", "ETHUSDT"],
|
||||
@@ -39,6 +43,8 @@ class TradingBot:
|
||||
if position is not None:
|
||||
amt = float(position["positionAmt"])
|
||||
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"])
|
||||
logger.info(
|
||||
f"기존 포지션 복구: {self.current_trade_side} | "
|
||||
@@ -152,6 +158,8 @@ class TradingBot:
|
||||
}
|
||||
|
||||
self.current_trade_side = signal
|
||||
self._entry_price = price
|
||||
self._entry_quantity = quantity
|
||||
self.notifier.notify_open(
|
||||
symbol=self.config.symbol,
|
||||
side=signal,
|
||||
@@ -183,26 +191,57 @@ class TradingBot:
|
||||
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)
|
||||
def _calc_estimated_pnl(self, exit_price: float) -> float:
|
||||
"""진입가·수량 기반 예상 PnL 계산 (수수료 미반영)."""
|
||||
if self._entry_price is None or self._entry_quantity is None or self.current_trade_side 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
|
||||
|
||||
entry = float(position["entryPrice"])
|
||||
mark = float(position["markPrice"])
|
||||
pnl = (mark - entry) * amt if side == "SELL" else (entry - mark) * amt
|
||||
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=pos_side,
|
||||
exit_price=mark,
|
||||
pnl=pnl,
|
||||
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,
|
||||
)
|
||||
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
|
||||
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(
|
||||
self,
|
||||
@@ -215,23 +254,28 @@ class TradingBot:
|
||||
funding_rate: float = 0.0,
|
||||
) -> None:
|
||||
"""기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다."""
|
||||
await self._close_position(position)
|
||||
# 재진입 플래그: User Data Stream 콜백이 신규 포지션 상태를 초기화하지 않도록 보호
|
||||
self._is_reentering = True
|
||||
try:
|
||||
await self._close_position(position)
|
||||
|
||||
if not self.risk.can_open_new_position():
|
||||
logger.info("최대 포지션 수 도달 — 재진입 건너뜀")
|
||||
return
|
||||
|
||||
if self.ml_filter.is_model_loaded():
|
||||
features = build_features(
|
||||
df, signal,
|
||||
btc_df=btc_df, eth_df=eth_df,
|
||||
oi_change=oi_change, funding_rate=funding_rate,
|
||||
)
|
||||
if not self.ml_filter.should_enter(features):
|
||||
logger.info(f"ML 필터 차단: {signal} 재진입 무시")
|
||||
if not self.risk.can_open_new_position():
|
||||
logger.info("최대 포지션 수 도달 — 재진입 건너뜀")
|
||||
return
|
||||
|
||||
await self._open_position(signal, df)
|
||||
if self.ml_filter.is_model_loaded():
|
||||
features = build_features(
|
||||
df, signal,
|
||||
btc_df=btc_df, eth_df=eth_df,
|
||||
oi_change=oi_change, funding_rate=funding_rate,
|
||||
)
|
||||
if not self.ml_filter.should_enter(features):
|
||||
logger.info(f"ML 필터 차단: {signal} 재진입 무시")
|
||||
return
|
||||
|
||||
await self._open_position(signal, df)
|
||||
finally:
|
||||
self._is_reentering = False
|
||||
|
||||
async def run(self):
|
||||
logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x")
|
||||
@@ -239,7 +283,19 @@ class TradingBot:
|
||||
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,
|
||||
|
||||
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_secret=self.config.api_secret,
|
||||
),
|
||||
user_stream.start(
|
||||
api_key=self.config.api_key,
|
||||
api_secret=self.config.api_secret,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -47,6 +47,10 @@ def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
||||
d["stoch_k"] = stoch["STOCHRSIk_14_14_3_3"]
|
||||
d["stoch_d"] = stoch["STOCHRSId_14_14_3_3"]
|
||||
|
||||
# ADX (14) — 횡보장 필터
|
||||
adx_df = ta.adx(high, low, close, length=14)
|
||||
d["adx"] = adx_df["ADX_14"]
|
||||
|
||||
return d
|
||||
|
||||
|
||||
@@ -112,6 +116,12 @@ def _calc_signals(d: pd.DataFrame) -> np.ndarray:
|
||||
# 둘 다 해당하면 HOLD (충돌 방지)
|
||||
signal_arr[long_enter & short_enter] = "HOLD"
|
||||
|
||||
# ADX 횡보장 필터: ADX < 25이면 추세 부재로 판단하여 진입 차단
|
||||
if "adx" in d.columns:
|
||||
adx = d["adx"].values
|
||||
low_adx = (~np.isnan(adx)) & (adx < 25)
|
||||
signal_arr[low_adx] = "HOLD"
|
||||
|
||||
return signal_arr
|
||||
|
||||
|
||||
@@ -352,6 +362,7 @@ def generate_dataset_vectorized(
|
||||
btc_df: pd.DataFrame | None = None,
|
||||
eth_df: pd.DataFrame | None = None,
|
||||
time_weight_decay: float = 0.0,
|
||||
negative_ratio: int = 0,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
전체 시계열을 1회 계산해 학습 데이터셋을 생성한다.
|
||||
@@ -362,6 +373,9 @@ def generate_dataset_vectorized(
|
||||
양수일수록 최신 샘플에 더 높은 가중치를 부여한다.
|
||||
예) 2.0 → 최신 샘플이 가장 오래된 샘플보다 e^2 ≈ 7.4배 높은 가중치.
|
||||
결과 DataFrame에 'sample_weight' 컬럼으로 포함된다.
|
||||
|
||||
negative_ratio: 시그널 샘플 대비 HOLD negative 샘플 비율.
|
||||
0이면 기존 동작 (시그널만). 5면 시그널의 5배만큼 HOLD 샘플 추가.
|
||||
"""
|
||||
print(" [1/3] 전체 시계열 지표 계산 (1회)...")
|
||||
d = _calc_indicators(df)
|
||||
@@ -371,41 +385,107 @@ def generate_dataset_vectorized(
|
||||
feat_all = _calc_features_vectorized(d, signal_arr, btc_df=btc_df, eth_df=eth_df)
|
||||
|
||||
# 신호 발생 + NaN 없음 + 미래 데이터 충분한 인덱스만
|
||||
# oi_change/funding_rate는 선택적 피처(컬럼 없으면 전체 nan)이므로 NaN 체크에서 제외
|
||||
OPTIONAL_COLS = {"oi_change", "funding_rate"}
|
||||
available_cols_for_nan_check = [
|
||||
c for c in FEATURE_COLS
|
||||
if c in feat_all.columns and c not in OPTIONAL_COLS
|
||||
]
|
||||
valid_rows = (
|
||||
(signal_arr != "HOLD") &
|
||||
base_valid = (
|
||||
(~feat_all[available_cols_for_nan_check].isna().any(axis=1).values) &
|
||||
(np.arange(len(d)) >= WARMUP) &
|
||||
(np.arange(len(d)) < len(d) - LOOKAHEAD)
|
||||
)
|
||||
sig_idx = np.where(valid_rows)[0]
|
||||
|
||||
# --- 시그널 캔들 (기존 로직) ---
|
||||
sig_valid = base_valid & (signal_arr != "HOLD")
|
||||
sig_idx = np.where(sig_valid)[0]
|
||||
print(f" 신호 발생 인덱스: {len(sig_idx):,}개")
|
||||
|
||||
print(" [3/3] 레이블 계산...")
|
||||
labels, valid_mask = _calc_labels_vectorized(d, feat_all, sig_idx)
|
||||
|
||||
final_idx = sig_idx[valid_mask]
|
||||
# btc_df/eth_df 제공 여부에 따라 실제 존재하는 피처 컬럼만 선택
|
||||
final_sig_idx = sig_idx[valid_mask]
|
||||
available_feature_cols = [c for c in FEATURE_COLS if c in feat_all.columns]
|
||||
feat_final = feat_all.iloc[final_idx][available_feature_cols].copy()
|
||||
feat_final["label"] = labels
|
||||
feat_signal = feat_all.iloc[final_sig_idx][available_feature_cols].copy()
|
||||
feat_signal["label"] = labels
|
||||
feat_signal["source"] = "signal"
|
||||
|
||||
# 시간 가중치: 오래된 샘플 → 낮은 가중치, 최신 샘플 → 높은 가중치
|
||||
# --- HOLD negative 캔들 ---
|
||||
if negative_ratio > 0 and len(final_sig_idx) > 0:
|
||||
hold_valid = base_valid & (signal_arr == "HOLD")
|
||||
hold_candidates = np.where(hold_valid)[0]
|
||||
n_neg = min(len(hold_candidates), len(final_sig_idx) * negative_ratio)
|
||||
|
||||
if n_neg > 0:
|
||||
rng = np.random.default_rng(42)
|
||||
hold_idx = rng.choice(hold_candidates, size=n_neg, replace=False)
|
||||
hold_idx = np.sort(hold_idx)
|
||||
|
||||
feat_hold = feat_all.iloc[hold_idx][available_feature_cols].copy()
|
||||
feat_hold["label"] = 0
|
||||
feat_hold["source"] = "hold_negative"
|
||||
|
||||
# HOLD 캔들은 시그널이 없으므로 side를 랜덤 할당 (50:50)
|
||||
sides = rng.integers(0, 2, size=len(feat_hold)).astype(np.float32)
|
||||
feat_hold["side"] = sides
|
||||
|
||||
print(f" HOLD negative 추가: {len(feat_hold):,}개 "
|
||||
f"(비율 1:{negative_ratio})")
|
||||
|
||||
feat_final = pd.concat([feat_signal, feat_hold], ignore_index=True)
|
||||
# 시간 순서 복원 (원본 인덱스 기반 정렬)
|
||||
original_order = np.concatenate([final_sig_idx, hold_idx])
|
||||
sort_order = np.argsort(original_order)
|
||||
feat_final = feat_final.iloc[sort_order].reset_index(drop=True)
|
||||
else:
|
||||
feat_final = feat_signal.reset_index(drop=True)
|
||||
else:
|
||||
feat_final = feat_signal.reset_index(drop=True)
|
||||
|
||||
# 시간 가중치
|
||||
n = len(feat_final)
|
||||
if time_weight_decay > 0 and n > 1:
|
||||
weights = np.exp(time_weight_decay * np.linspace(0.0, 1.0, n)).astype(np.float32)
|
||||
weights /= weights.mean() # 평균 1로 정규화해 학습률 스케일 유지
|
||||
weights /= weights.mean()
|
||||
print(f" 시간 가중치 적용 (decay={time_weight_decay}): "
|
||||
f"min={weights.min():.3f}, max={weights.max():.3f}")
|
||||
else:
|
||||
weights = np.ones(n, dtype=np.float32)
|
||||
|
||||
feat_final = feat_final.reset_index(drop=True)
|
||||
feat_final["sample_weight"] = weights
|
||||
|
||||
total_sig = (feat_final["source"] == "signal").sum() if "source" in feat_final.columns else len(feat_final)
|
||||
total_hold = (feat_final["source"] == "hold_negative").sum() if "source" in feat_final.columns else 0
|
||||
print(f" 최종 데이터셋: {n:,}개 (시그널={total_sig:,}, HOLD={total_hold:,})")
|
||||
|
||||
return feat_final
|
||||
|
||||
|
||||
def stratified_undersample(
|
||||
y: np.ndarray,
|
||||
source: np.ndarray,
|
||||
seed: int = 42,
|
||||
) -> np.ndarray:
|
||||
"""Signal 샘플 전수 유지 + HOLD negative만 양성 수 만큼 샘플링.
|
||||
|
||||
Args:
|
||||
y: 라벨 배열 (0 or 1)
|
||||
source: 소스 배열 ("signal" or "hold_negative")
|
||||
seed: 랜덤 시드
|
||||
|
||||
Returns:
|
||||
정렬된 인덱스 배열 (학습에 사용할 행 인덱스)
|
||||
"""
|
||||
pos_idx = np.where(y == 1)[0] # Signal Win
|
||||
sig_neg_idx = np.where((y == 0) & (source == "signal"))[0] # Signal Loss
|
||||
hold_neg_idx = np.where(source == "hold_negative")[0] # HOLD negative
|
||||
|
||||
# HOLD negative에서 양성 수 만큼만 샘플링
|
||||
n_hold = min(len(hold_neg_idx), len(pos_idx))
|
||||
rng = np.random.default_rng(seed)
|
||||
if n_hold > 0:
|
||||
hold_sampled = rng.choice(hold_neg_idx, size=n_hold, replace=False)
|
||||
else:
|
||||
hold_sampled = np.array([], dtype=np.intp)
|
||||
|
||||
return np.sort(np.concatenate([pos_idx, sig_neg_idx, hold_sampled]))
|
||||
|
||||
@@ -172,3 +172,31 @@ class BinanceFuturesClient:
|
||||
except Exception as e:
|
||||
logger.warning(f"펀딩비 조회 실패 (무시): {e}")
|
||||
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}")
|
||||
|
||||
@@ -43,6 +43,10 @@ class Indicators:
|
||||
df["stoch_k"] = stoch["STOCHRSIk_14_14_3_3"]
|
||||
df["stoch_d"] = stoch["STOCHRSId_14_14_3_3"]
|
||||
|
||||
# ADX (14) — 횡보장 필터
|
||||
adx_df = ta.adx(df["high"], df["low"], df["close"], length=14)
|
||||
df["adx"] = adx_df["ADX_14"]
|
||||
|
||||
# 거래량 이동평균
|
||||
df["vol_ma20"] = ta.sma(df["volume"], length=20)
|
||||
|
||||
@@ -56,6 +60,12 @@ class Indicators:
|
||||
last = df.iloc[-1]
|
||||
prev = df.iloc[-2]
|
||||
|
||||
# ADX 횡보장 필터: ADX < 25이면 추세 부재로 판단하여 진입 차단
|
||||
adx = last.get("adx", None)
|
||||
if adx is not None and not pd.isna(adx) and adx < 25:
|
||||
logger.debug(f"ADX 필터: {adx:.1f} < 25 — HOLD")
|
||||
return "HOLD"
|
||||
|
||||
long_signals = 0
|
||||
short_signals = 0
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import joblib
|
||||
import numpy as np
|
||||
@@ -34,6 +35,7 @@ class MLFilter:
|
||||
lgbm_path: str = str(LGBM_MODEL_PATH),
|
||||
threshold: float = 0.60,
|
||||
):
|
||||
self._disabled = os.environ.get("NO_ML_FILTER", "").lower() in ("1", "true", "yes")
|
||||
self._onnx_path = Path(onnx_path)
|
||||
self._lgbm_path = Path(lgbm_path)
|
||||
self._threshold = threshold
|
||||
@@ -41,7 +43,11 @@ class MLFilter:
|
||||
self._lgbm_model = None
|
||||
self._loaded_onnx_mtime: float = 0.0
|
||||
self._loaded_lgbm_mtime: float = 0.0
|
||||
self._try_load()
|
||||
|
||||
if self._disabled:
|
||||
logger.info("ML 필터 비활성화 모드 (NO_ML_FILTER=true) → 모든 신호 허용")
|
||||
else:
|
||||
self._try_load()
|
||||
|
||||
def _try_load(self):
|
||||
# 로드 여부와 무관하게 두 파일의 현재 mtime을 항상 기록한다.
|
||||
@@ -101,6 +107,7 @@ class MLFilter:
|
||||
모델 파일의 mtime을 확인해 변경됐으면 리로드한다.
|
||||
실제로 리로드가 일어났으면 True 반환.
|
||||
"""
|
||||
if self._disabled: return False
|
||||
onnx_changed = _mtime(self._onnx_path) != self._loaded_onnx_mtime
|
||||
lgbm_changed = _mtime(self._lgbm_path) != self._loaded_lgbm_mtime
|
||||
|
||||
@@ -121,8 +128,11 @@ class MLFilter:
|
||||
def should_enter(self, features: pd.Series) -> bool:
|
||||
"""
|
||||
확률 >= threshold 이면 True (진입 허용).
|
||||
모델 없으면 True 반환 (폴백).
|
||||
NO_ML_FILTER=true 이거나 모델 없으면 True 반환 (폴백).
|
||||
"""
|
||||
if self._disabled:
|
||||
logger.debug("ML 필터 비활성화 모드 → 진입 허용")
|
||||
return True
|
||||
if not self.is_model_loaded():
|
||||
return True
|
||||
try:
|
||||
|
||||
@@ -49,13 +49,20 @@ class DiscordNotifier:
|
||||
self,
|
||||
symbol: str,
|
||||
side: str,
|
||||
close_reason: str,
|
||||
exit_price: float,
|
||||
pnl: float,
|
||||
estimated_pnl: float,
|
||||
net_pnl: float,
|
||||
diff: float,
|
||||
) -> None:
|
||||
emoji = "✅" if pnl >= 0 else "❌"
|
||||
emoji_map = {"TP": "✅", "SL": "❌", "MANUAL": "🔶"}
|
||||
emoji = emoji_map.get(close_reason, "🔶")
|
||||
msg = (
|
||||
f"{emoji} **[{symbol}] {side} 청산**\n"
|
||||
f"청산가: `{exit_price:.4f}` | PnL: `{pnl:+.4f} USDT`"
|
||||
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)
|
||||
|
||||
|
||||
114
src/user_data_stream.py
Normal file
114
src/user_data_stream.py
Normal file
@@ -0,0 +1,114 @@
|
||||
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})")
|
||||
while True:
|
||||
msg = await stream.recv()
|
||||
|
||||
if isinstance(msg, dict) and msg.get("e") == "error":
|
||||
logger.warning(
|
||||
f"웹소켓 내부 에러 수신: {msg.get('m', msg)} — "
|
||||
f"재연결을 위해 연결 종료"
|
||||
)
|
||||
break
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -17,6 +17,7 @@ def config():
|
||||
"RISK_PER_TRADE": "0.02",
|
||||
"NOTION_TOKEN": "secret_test",
|
||||
"NOTION_DATABASE_ID": "db_test",
|
||||
"DISCORD_WEBHOOK_URL": "",
|
||||
})
|
||||
return Config()
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from src.database import TradeRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_repo():
|
||||
with patch("src.database.Client") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
repo = TradeRepository(token="secret_test", database_id="db_test")
|
||||
repo.client = mock_client
|
||||
yield repo
|
||||
|
||||
|
||||
def test_save_trade(mock_repo):
|
||||
mock_repo.client.pages.create.return_value = {
|
||||
"id": "abc123",
|
||||
"properties": {},
|
||||
}
|
||||
result = mock_repo.save_trade(
|
||||
symbol="XRPUSDT",
|
||||
side="LONG",
|
||||
entry_price=0.5,
|
||||
quantity=400.0,
|
||||
leverage=10,
|
||||
signal_data={"rsi": 32, "macd_hist": 0.001},
|
||||
)
|
||||
assert result["id"] == "abc123"
|
||||
|
||||
|
||||
def test_close_trade(mock_repo):
|
||||
mock_repo.client.pages.update.return_value = {
|
||||
"id": "abc123",
|
||||
"properties": {
|
||||
"Status": {"select": {"name": "CLOSED"}},
|
||||
},
|
||||
}
|
||||
result = mock_repo.close_trade(
|
||||
trade_id="abc123", exit_price=0.55, pnl=20.0
|
||||
)
|
||||
assert result["id"] == "abc123"
|
||||
@@ -70,7 +70,7 @@ def test_generate_dataset_vectorized_with_btc_eth_has_21_feature_cols():
|
||||
result = generate_dataset_vectorized(xrp_df, btc_df=btc_df, eth_df=eth_df)
|
||||
if not result.empty:
|
||||
assert set(FEATURE_COLS).issubset(set(result.columns))
|
||||
assert len(result.columns) == len(FEATURE_COLS) + 1 # +1 for label
|
||||
assert "label" in result.columns
|
||||
|
||||
|
||||
def test_matches_original_generate_dataset(sample_df):
|
||||
@@ -208,3 +208,61 @@ def test_rs_zero_denominator():
|
||||
"xrp_btc_rs에 inf가 있으면 안 됨"
|
||||
assert not feat["xrp_btc_rs"].isna().all(), \
|
||||
"xrp_btc_rs가 전부 nan이면 안 됨"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def signal_producing_df():
|
||||
"""시그널이 반드시 발생하는 더미 데이터. 높은 변동성 + 거래량 급증."""
|
||||
rng = np.random.default_rng(7)
|
||||
n = 800
|
||||
trend = np.linspace(1.5, 3.0, n)
|
||||
noise = np.cumsum(rng.normal(0, 0.04, n))
|
||||
close = np.clip(trend + noise, 0.01, None)
|
||||
high = close * (1 + rng.uniform(0, 0.015, n))
|
||||
low = close * (1 - rng.uniform(0, 0.015, n))
|
||||
volume = rng.uniform(1e6, 3e6, n)
|
||||
volume[::30] *= 3.0 # 30봉마다 거래량 급증
|
||||
return pd.DataFrame({
|
||||
"open": close, "high": high, "low": low,
|
||||
"close": close, "volume": volume,
|
||||
})
|
||||
|
||||
|
||||
def test_hold_negative_labels_are_all_zero(signal_producing_df):
|
||||
"""HOLD negative 샘플의 label은 전부 0이어야 한다."""
|
||||
result = generate_dataset_vectorized(signal_producing_df, negative_ratio=3)
|
||||
assert len(result) > 0, "시그널이 발생하지 않아 테스트 불가"
|
||||
assert "source" in result.columns
|
||||
hold_neg = result[result["source"] == "hold_negative"]
|
||||
assert len(hold_neg) > 0, "HOLD negative 샘플이 0개"
|
||||
assert (hold_neg["label"] == 0).all(), \
|
||||
f"HOLD negative 중 label != 0인 샘플 존재: {hold_neg['label'].value_counts().to_dict()}"
|
||||
|
||||
|
||||
def test_signal_samples_preserved_after_sampling(signal_producing_df):
|
||||
"""계층적 샘플링 후 source='signal' 샘플이 하나도 버려지지 않아야 한다."""
|
||||
result_signal_only = generate_dataset_vectorized(signal_producing_df, negative_ratio=0)
|
||||
result_with_hold = generate_dataset_vectorized(signal_producing_df, negative_ratio=3)
|
||||
|
||||
assert len(result_signal_only) > 0, "시그널이 발생하지 않아 테스트 불가"
|
||||
assert "source" in result_with_hold.columns
|
||||
signal_count = (result_with_hold["source"] == "signal").sum()
|
||||
assert signal_count == len(result_signal_only), \
|
||||
f"Signal 샘플 손실: 원본={len(result_signal_only)}, 유지={signal_count}"
|
||||
|
||||
|
||||
def test_stratified_undersample_preserves_signal():
|
||||
"""stratified_undersample은 signal 샘플을 전수 유지해야 한다."""
|
||||
from src.dataset_builder import stratified_undersample
|
||||
|
||||
y = np.array([1, 0, 0, 0, 0, 0, 0, 0, 1, 0])
|
||||
source = np.array(["signal", "signal", "signal", "hold_negative",
|
||||
"hold_negative", "hold_negative", "hold_negative",
|
||||
"hold_negative", "signal", "signal"])
|
||||
|
||||
idx = stratified_undersample(y, source, seed=42)
|
||||
|
||||
# signal 인덱스: 0, 1, 2, 8, 9 → 전부 포함
|
||||
signal_indices = np.where(source == "signal")[0]
|
||||
for si in signal_indices:
|
||||
assert si in idx, f"signal 인덱스 {si}가 누락됨"
|
||||
|
||||
@@ -45,6 +45,42 @@ def test_bollinger_bands(sample_df):
|
||||
assert (valid["bb_upper"] >= valid["bb_lower"]).all()
|
||||
|
||||
|
||||
def test_adx_column_exists(sample_df):
|
||||
"""calculate_all()이 adx 컬럼을 생성하는지 확인."""
|
||||
ind = Indicators(sample_df)
|
||||
df = ind.calculate_all()
|
||||
assert "adx" in df.columns
|
||||
valid = df["adx"].dropna()
|
||||
assert (valid >= 0).all()
|
||||
|
||||
|
||||
def test_adx_filter_blocks_low_adx(sample_df):
|
||||
"""ADX < 25일 때 가중치와 무관하게 HOLD를 반환해야 한다."""
|
||||
ind = Indicators(sample_df)
|
||||
df = ind.calculate_all()
|
||||
# 강한 LONG 신호가 나오도록 지표 조작
|
||||
df.loc[df.index[-1], "rsi"] = 20 # RSI 과매도 → +1
|
||||
df.loc[df.index[-2], "macd"] = -1 # MACD 골든크로스 → +2
|
||||
df.loc[df.index[-2], "macd_signal"] = 0
|
||||
df.loc[df.index[-1], "macd"] = 1
|
||||
df.loc[df.index[-1], "macd_signal"] = 0
|
||||
df.loc[df.index[-1], "volume"] = df.loc[df.index[-1], "vol_ma20"] * 2 # 거래량 서지
|
||||
# ADX를 강제로 낮은 값으로 설정
|
||||
df["adx"] = 15.0
|
||||
signal = ind.get_signal(df)
|
||||
assert signal == "HOLD"
|
||||
|
||||
|
||||
def test_adx_nan_falls_through(sample_df):
|
||||
"""ADX가 NaN(초기 캔들)이면 기존 가중치 로직으로 폴백해야 한다."""
|
||||
ind = Indicators(sample_df)
|
||||
df = ind.calculate_all()
|
||||
df["adx"] = float("nan")
|
||||
signal = ind.get_signal(df)
|
||||
# NaN이면 차단하지 않고 기존 로직 실행 → LONG/SHORT/HOLD 중 하나
|
||||
assert signal in ("LONG", "SHORT", "HOLD")
|
||||
|
||||
|
||||
def test_signal_returns_direction(sample_df):
|
||||
ind = Indicators(sample_df)
|
||||
df = ind.calculate_all()
|
||||
|
||||
Reference in New Issue
Block a user