26 Commits

Author SHA1 Message Date
21in7
6fe2158511 feat: enhance precision optimization in model training
- Introduced a new plan to modify the Optuna objective function to prioritize precision under a recall constraint of 0.35, improving model performance in scenarios where false positives are costly.
- Updated training scripts to implement precision-based metrics and adjusted the walk-forward cross-validation process to incorporate precision and recall calculations.
- Enhanced the active LGBM parameters and training log to reflect the new metrics and model configurations.
- Added a new design document outlining the implementation steps for the precision-focused optimization.

This update aims to refine the model's decision-making process by emphasizing precision, thereby reducing potential losses from false positives.
2026-03-03 00:57:19 +09:00
21in7
3613e3bf18 feat: update active LGBM parameters and training log with new metrics
- Updated active LGBM parameters with new timestamp, trial results, and model configurations to reflect recent training outcomes.
- Added new entries to the training log, capturing detailed metrics including AUC, precision, recall, and tuned parameters for the latest model iterations.

This update enhances the tracking of model performance and parameter tuning in the ML pipeline.
2026-03-03 00:21:43 +09:00
21in7
fce4d536ea feat: implement HOLD negative sampling and stratified undersampling in ML pipeline
Added HOLD candles as negative samples to increase training data from ~535 to ~3,200 samples. Introduced a negative_ratio parameter in generate_dataset_vectorized() for sampling HOLD candles alongside signal candles. Implemented stratified undersampling to ensure signal samples are preserved during training. Updated relevant tests to validate new functionality and maintain compatibility with existing tests.

- Modified dataset_builder.py to include HOLD negative sampling logic
- Updated train_model.py to apply stratified undersampling
- Added tests for new sampling methods

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:13:42 +09:00
21in7
74966590b5 feat: apply stratified undersampling to hyperparameter tuning
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:09:43 +09:00
21in7
6cd54b46d9 feat: apply stratified undersampling to training pipeline
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:03:09 +09:00
21in7
0af138d8ee feat: add stratified_undersample helper function
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:58:15 +09:00
21in7
b7ad358a0a fix: make HOLD negative sampling tests non-vacuous
The two HOLD negative tests (test_hold_negative_labels_are_all_zero,
test_signal_samples_preserved_after_sampling) were passing vacuously
because sample_df produces 0 signal candles (ADX ~18, below threshold
25). Added signal_producing_df fixture with higher volatility and volume
surges to reliably generate signals. Removed if-guards so assertions
are mandatory. Also restored the full docstring for
generate_dataset_vectorized() documenting btc_df/eth_df,
time_weight_decay, and negative_ratio parameters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:45:10 +09:00
21in7
8e56301d52 feat: add HOLD negative sampling to dataset_builder
Add negative_ratio parameter to generate_dataset_vectorized() that
samples HOLD candles as label=0 negatives alongside signal candles.
This increases training data from ~535 to ~3,200 samples when enabled.

- Split valid_rows into base_valid (shared) and sig_valid (signal-only)
- Add 'source' column ("signal" vs "hold_negative") for traceability
- HOLD samples get label=0 and random 50/50 side assignment
- Default negative_ratio=0 preserves backward compatibility
- Fix incorrect column count assertion in existing test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:34:45 +09:00
21in7
99fa508db7 feat: add CLAUDE.md and settings.json for project documentation and plugin configuration
Introduced CLAUDE.md to provide comprehensive guidance on the CoinTrader project, including architecture, common commands, testing, and deployment details. Added settings.json to enable the superpowers plugin for Claude. This enhances the project's documentation and configuration management.
2026-03-02 20:01:18 +09:00
21in7
eeb5e9d877 feat: add ADX filter to block sideways market entries
ADX < 25 now returns HOLD in get_signal(), preventing entries during
trendless (sideways) markets. NaN ADX values fall through to existing
weighted signal logic. Also syncs the vectorized dataset builder with
the same ADX filter to keep training data consistent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:55:12 +09:00
21in7
c8a2c36bfb feat: add ADX calculation to indicators
Add ADX (Average Directional Index) with period 14 to calculate_all()
for sideways market filtering. Includes test verifying the adx column
exists and contains non-negative values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:47:18 +09:00
21in7
b8b99da207 feat: update training log and enhance ML filter functionality
- Added a new entry to the training log with detailed metrics for a LightGBM model, including AUC, precision, recall, and tuned parameters.
- Enhanced the MLFilter class to include a guard clause that prevents execution if the filter is disabled, improving robustness.
2026-03-02 18:24:38 +09:00
21in7
77590accf2 feat: add architecture documentation for CoinTrader
- Introduced a comprehensive architecture document detailing the CoinTrader system, including an overview, core layer architecture, MLOps pipeline, and key operational scenarios.
- Updated README to reference the new architecture document and added a configuration option to disable the ML filter.
- Enhanced the ML filter to allow for complete signal acceptance when the NO_ML_FILTER environment variable is set.
2026-03-02 18:02:05 +09:00
21in7
a8cba2cb4c docs: enhance README with detailed listenKey auto-renewal process and error handling
- Updated the README to clarify the listenKey auto-renewal mechanism, including the use of `stream.recv()` for message reception.
- Added information on immediate reconnection upon detecting internal error payloads to prevent zombie connections.
2026-03-02 16:43:45 +09:00
21in7
52affb5532 feat: implement User Data Stream for real-time TP/SL detection and PnL tracking
- Introduced User Data Stream to detect TP/SL executions in real-time.
- Added a new class `UserDataStream` for managing the stream and handling events.
- Updated `bot.py` to initialize and run the User Data Stream in parallel with the candle stream.
- Enhanced `notifier.py` to send detailed Discord notifications including estimated vs actual PnL.
- Added methods in `exchange.py` for managing listenKey lifecycle (create, keepalive, delete).
- Refactored PnL recording and notification logic to streamline handling of position closures.

Made-with: Cursor
2026-03-02 16:33:08 +09:00
21in7
05ae88dc61 fix: remove manual listenKey mgmt, add symbol filter, fix reenter race condition
Made-with: Cursor
2026-03-02 16:31:40 +09:00
21in7
6237efe4d3 docs: update README with User Data Stream TP/SL detection feature
Made-with: Cursor
2026-03-02 16:27:50 +09:00
21in7
4e8e61b5cf fix: guard against None current_trade_side in _calc_estimated_pnl
Made-with: Cursor
2026-03-02 16:27:17 +09:00
21in7
4ffee0ae8b feat: run UserDataStream in parallel with candle stream
Made-with: Cursor
2026-03-02 16:25:13 +09:00
21in7
7e7f0f4f22 fix: restore entry_price and entry_quantity on position recovery
Made-with: Cursor
2026-03-02 16:24:27 +09:00
21in7
c4f806fc35 feat: add entry state tracking and _on_position_closed callback
- __init__에 _entry_price, _entry_quantity 상태 변수 추가 (None 초기화)
- _open_position()에서 current_trade_side 저장 직후 진입가/수량 저장
- _calc_estimated_pnl() 헬퍼: LONG/SHORT 방향별 예상 PnL 계산
- _on_position_closed() 콜백: UDS 청산 감지 시 PnL 기록·알림·상태 초기화

Made-with: Cursor
2026-03-02 16:21:59 +09:00
21in7
22f1debb3d fix: re-raise CancelledError in UserDataStream for proper task cancellation
Made-with: Cursor
2026-03-02 16:20:37 +09:00
21in7
4f3183df47 feat: add UserDataStream with keepalive and reconnect loop
Made-with: Cursor
2026-03-02 16:17:38 +09:00
21in7
223608bec0 refactor: remove duplicate pnl/notify from _close_position (handled by callback)
Made-with: Cursor
2026-03-02 16:16:25 +09:00
21in7
e72126516b feat: extend notify_close with close_reason, net_pnl, diff fields
Made-with: Cursor
2026-03-02 16:14:26 +09:00
21in7
63c2eb8927 feat: add listenKey create/keepalive/delete methods to exchange
Made-with: Cursor
2026-03-02 16:11:33 +09:00
26 changed files with 4409 additions and 901 deletions

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"superpowers@claude-plugins-official": true
}
}

646
ARCHITECTURE.md Normal file
View 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
View 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 |

View File

@@ -2,6 +2,8 @@
Binance Futures 자동매매 봇. 복합 기술 지표와 ML 필터(LightGBM / MLX 신경망)를 결합하여 XRPUSDT(기본) 선물 포지션을 자동으로 진입·청산하며, Discord로 실시간 알림을 전송합니다. Binance Futures 자동매매 봇. 복합 기술 지표와 ML 필터(LightGBM / MLX 신경망)를 결합하여 XRPUSDT(기본) 선물 포지션을 자동으로 진입·청산하며, Discord로 실시간 알림을 전송합니다.
> **아키텍처 문서**: 코드 구조, 레이어별 역할, MLOps 파이프라인, 동작 시나리오를 상세히 설명한 [ARCHITECTURE.md](./ARCHITECTURE.md)를 참고하세요.
--- ---
## 주요 기능 ## 주요 기능
@@ -19,6 +21,10 @@ Binance Futures 자동매매 봇. 복합 기술 지표와 ML 필터(LightGBM / M
- **반대 시그널 재진입**: 보유 포지션과 반대 신호 발생 시 즉시 청산 후 ML 필터 통과 시 반대 방향 재진입 - **반대 시그널 재진입**: 보유 포지션과 반대 신호 발생 시 즉시 청산 후 ML 필터 통과 시 반대 방향 재진입
- **리스크 관리**: 트레이드당 리스크 비율, 최대 포지션 수, 일일 손실 한도(5%) 제어 - **리스크 관리**: 트레이드당 리스크 비율, 최대 포지션 수, 일일 손실 한도(5%) 제어
- **포지션 복구**: 봇 재시작 시 기존 포지션 자동 감지 및 상태 복원 - **포지션 복구**: 봇 재시작 시 기존 포지션 자동 감지 및 상태 복원
- **실시간 TP/SL 감지**: Binance User Data Stream으로 TP/SL 작동을 즉시 감지 (캔들 마감 대기 없음)
- **순수익(Net PnL) 기록**: 바이낸스 `realizedProfit - commission`으로 정확한 순수익 계산
- **Discord 상세 청산 알림**: 예상 수익 vs 실제 순수익 + 슬리피지/수수료 차이 표시
- **listenKey 자동 갱신**: 30분 keepalive + 네트워크 단절 시 자동 재연결. `stream.recv()` 기반으로 수신하며, 라이브러리 내부 에러 페이로드(`{"e":"error"}`) 감지 시 즉시 재연결하여 좀비 커넥션 방지
- **Discord 알림**: 진입·청산·오류 이벤트 실시간 웹훅 알림 - **Discord 알림**: 진입·청산·오류 이벤트 실시간 웹훅 알림
- **CI/CD**: Jenkins + Gitea Container Registry 기반 Docker 이미지 자동 빌드·배포 (LXC 운영 서버 자동 적용) - **CI/CD**: Jenkins + Gitea Container Registry 기반 Docker 이미지 자동 빌드·배포 (LXC 운영 서버 자동 적용)
@@ -262,6 +268,7 @@ pytest tests/ -v
| `MARGIN_MAX_RATIO` | `0.50` | 최대 증거금 비율 (잔고 대비 50%) | | `MARGIN_MAX_RATIO` | `0.50` | 최대 증거금 비율 (잔고 대비 50%) |
| `MARGIN_MIN_RATIO` | `0.20` | 최소 증거금 비율 (잔고 대비 20%) | | `MARGIN_MIN_RATIO` | `0.20` | 최소 증거금 비율 (잔고 대비 20%) |
| `MARGIN_DECAY_RATE` | `0.0006` | 잔고 증가 시 증거금 비율 감소 속도 | | `MARGIN_DECAY_RATE` | `0.0006` | 잔고 증가 시 증거금 비율 감소 속도 |
| `NO_ML_FILTER` | — | `true`/`1`/`yes` 설정 시 ML 필터 완전 비활성화 — 모델 로드 없이 모든 신호 허용 |
--- ---

View 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"
```

View 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개 테스트 케이스 추가 |

View 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"
```

View 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) 처리 — 현재 봇은 전량 청산만 사용

View 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
```

View 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

View File

@@ -301,5 +301,130 @@
"max_depth": 6 "max_depth": 6
}, },
"weight_scale": 1.783105 "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
} }
] ]

View File

@@ -21,6 +21,5 @@ fi
cd "$PROJECT_ROOT" cd "$PROJECT_ROOT"
python -m pytest tests/ \ python -m pytest tests/ \
--ignore=tests/test_database.py \
-v \ -v \
"$@" "$@"

View File

@@ -17,12 +17,12 @@ import joblib
import lightgbm as lgb import lightgbm as lgb
import numpy as np import numpy as np
import pandas as pd 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.indicators import Indicators
from src.ml_features import build_features, FEATURE_COLS from src.ml_features import build_features, FEATURE_COLS
from src.label_builder import build_labels 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: def _cgroup_cpu_count() -> int:
"""cgroup v1/v2 쿼터를 읽어 실제 할당된 CPU 수를 반환한다. """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() df = df_raw[base_cols].copy()
print("데이터셋 생성 중...") 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: if dataset.empty or "label" not in dataset.columns:
raise ValueError(f"데이터셋 생성 실패: 샘플 0개. 위 오류 메시지를 확인하세요.") 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] X = dataset[actual_feature_cols]
y = dataset["label"] y = dataset["label"]
w = dataset["sample_weight"].values 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) split = int(len(X) * 0.8)
X_train, X_val = X.iloc[:split], X.iloc[split:] 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) lgbm_params, weight_scale = _load_lgbm_params(tuned_params_path)
w_train = (w[:split] * weight_scale).astype(np.float32) w_train = (w[:split] * weight_scale).astype(np.float32)
# --- 클래스 불균형 처리: 언더샘플링 (시간 가중치 인덱스 보존) --- # --- 계층적 샘플링: signal 전수 유지, HOLD negative만 양성 수 만큼 ---
pos_idx = np.where(y_train == 1)[0] source_train = source[:split]
neg_idx = np.where(y_train == 0)[0] balanced_idx = stratified_undersample(y_train.values, source_train, seed=42)
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])) # 시간 순서 유지
X_train = X_train.iloc[balanced_idx] X_train = X_train.iloc[balanced_idx]
y_train = y_train.iloc[balanced_idx] y_train = y_train.iloc[balanced_idx]
w_train = w_train[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())})") 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) auc = roc_auc_score(y_val, val_proba)
# 최적 임계값 탐색: 최소 재현율(0.15) 조건부 정밀도 최대화 # 최적 임계값 탐색: 최소 재현율(0.15) 조건부 정밀도 최대화
from sklearn.metrics import precision_recall_curve
precisions, recalls, thresholds = precision_recall_curve(y_val, val_proba) precisions, recalls, thresholds = precision_recall_curve(y_val, val_proba)
# precision_recall_curve의 마지막 원소는 (1.0, 0.0)이므로 제외 # precision_recall_curve의 마지막 원소는 (1.0, 0.0)이므로 제외
precisions, recalls = precisions[:-1], recalls[:-1] precisions, recalls = precisions[:-1], recalls[:-1]
@@ -354,13 +356,16 @@ def walk_forward_auc(
df = df_raw[base_cols].copy() df = df_raw[base_cols].copy()
dataset = generate_dataset_vectorized( 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] actual_feature_cols = [c for c in FEATURE_COLS if c in dataset.columns]
X = dataset[actual_feature_cols].values X = dataset[actual_feature_cols].values
y = dataset["label"].values y = dataset["label"].values
w = dataset["sample_weight"].values w = dataset["sample_weight"].values
n = len(dataset) 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) lgbm_params, weight_scale = _load_lgbm_params(tuned_params_path)
w = (w * weight_scale).astype(np.float32) w = (w * weight_scale).astype(np.float32)
@@ -369,6 +374,7 @@ def walk_forward_auc(
train_end_start = int(n * train_ratio) train_end_start = int(n * train_ratio)
aucs = [] aucs = []
fold_metrics = []
for i in range(n_splits): for i in range(n_splits):
tr_end = train_end_start + i * step tr_end = train_end_start + i * step
val_end = tr_end + 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_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] X_val, y_val = X[tr_end:val_end], y[tr_end:val_end]
pos_idx = np.where(y_tr == 1)[0] source_tr = source[:tr_end]
neg_idx = np.where(y_tr == 0)[0] idx = stratified_undersample(y_tr, source_tr, seed=42)
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]))
model = lgb.LGBMClassifier(**lgbm_params, random_state=42, verbose=-1) model = lgb.LGBMClassifier(**lgbm_params, random_state=42, verbose=-1)
with warnings.catch_warnings(): with warnings.catch_warnings():
@@ -393,12 +395,30 @@ def walk_forward_auc(
proba = model.predict_proba(X_val)[:, 1] proba = model.predict_proba(X_val)[:, 1]
auc = roc_auc_score(y_val, proba) if len(np.unique(y_val)) > 1 else 0.5 auc = roc_auc_score(y_val, proba) if len(np.unique(y_val)) > 1 else 0.5
aucs.append(auc) 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( print(
f" 폴드 {i+1}/{n_splits}: 학습={tr_end}개, " 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"\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]}") print(f" 폴드별: {[round(a, 4) for a in aucs]}")

View File

@@ -7,6 +7,7 @@ Optuna를 사용한 LightGBM 하이퍼파라미터 자동 탐색.
python scripts/tune_hyperparams.py --trials 10 --folds 3 # 빠른 테스트 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 --data data/combined_15m.parquet --trials 100
python scripts/tune_hyperparams.py --no-baseline # 베이스라인 측정 건너뜀 python scripts/tune_hyperparams.py --no-baseline # 베이스라인 측정 건너뜀
python scripts/tune_hyperparams.py --min-recall 0.4 # 최소 재현율 제약 조정
결과: 결과:
- 콘솔: Best Params + Walk-Forward 리포트 - 콘솔: Best Params + Walk-Forward 리포트
@@ -28,17 +29,17 @@ import lightgbm as lgb
import optuna import optuna
from optuna.samplers import TPESampler from optuna.samplers import TPESampler
from optuna.pruners import MedianPruner 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.ml_features import FEATURE_COLS
from src.dataset_builder import generate_dataset_vectorized from src.dataset_builder import generate_dataset_vectorized, stratified_undersample
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# 데이터 로드 및 데이터셋 생성 (1회 캐싱) # 데이터 로드 및 데이터셋 생성 (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 배열 반환. parquet 로드 → 벡터화 데이터셋 생성 → (X, y, w) numpy 배열 반환.
study 시작 전 1회만 호출하여 모든 trial이 공유한다. 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() df = df_raw[base_cols].copy()
print("\n데이터셋 생성 중 (1회만 실행)...") 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: if dataset.empty or "label" not in dataset.columns:
raise ValueError("데이터셋 생성 실패: 샘플 0개") 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) X = dataset[actual_feature_cols].values.astype(np.float32)
y = dataset["label"].values.astype(np.int8) y = dataset["label"].values.astype(np.int8)
w = dataset["sample_weight"].values.astype(np.float32) 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()) pos = int(y.sum())
neg = int((y == 0).sum()) neg = int((y == 0).sum())
print(f"데이터셋 완성: {len(dataset):,}개 샘플 (양성={pos}, 음성={neg})") print(f"데이터셋 완성: {len(dataset):,}개 샘플 (양성={pos}, 음성={neg})")
print(f"사용 피처: {len(actual_feature_cols)}\n") 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, X: np.ndarray,
y: np.ndarray, y: np.ndarray,
w: np.ndarray, w: np.ndarray,
source: np.ndarray,
params: dict, params: dict,
n_splits: int, n_splits: int,
train_ratio: float, train_ratio: float,
min_recall: float = 0.35,
trial: "optuna.Trial | None" = None, 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을 활성화한다. trial이 제공되면 각 폴드 후 Optuna에 중간 값을 보고하여 Pruning을 활성화한다.
Returns:
(mean_score, details) where details contains per-fold metrics.
""" """
n = len(X) n = len(X)
step = max(1, int(n * (1 - train_ratio) / n_splits)) step = max(1, int(n * (1 - train_ratio) / n_splits))
train_end_start = int(n * train_ratio) train_end_start = int(n * train_ratio)
fold_aucs: list[float] = [] 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): for fold_idx in range(n_splits):
tr_end = train_end_start + fold_idx * step 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_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] X_val, y_val = X[tr_end:val_end], y[tr_end:val_end]
# 클래스 불균형 처리: 언더샘플링 (시간 순서 유지) # 계층적 샘플링: signal 전수 유지, HOLD negative만 양성 수 만큼
pos_idx = np.where(y_tr == 1)[0] source_tr = source[:tr_end]
neg_idx = np.where(y_tr == 0)[0] bal_idx = stratified_undersample(y_tr, source_tr, seed=42)
if len(neg_idx) > len(pos_idx) and len(pos_idx) > 0:
rng = np.random.default_rng(42) n_pos = int(y_val.sum())
neg_idx = rng.choice(neg_idx, size=len(pos_idx), replace=False)
bal_idx = np.sort(np.concatenate([pos_idx, neg_idx]))
if len(bal_idx) < 20 or len(np.unique(y_val)) < 2: if len(bal_idx) < 20 or len(np.unique(y_val)) < 2:
fold_aucs.append(0.5) 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 continue
model = lgb.LGBMClassifier(**params, random_state=42, verbose=-1) 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 auc = roc_auc_score(y_val, proba) if len(np.unique(y_val)) > 1 else 0.5
fold_aucs.append(float(auc)) fold_aucs.append(float(auc))
# Optuna Pruning: 중간 값 보고 # Precision at recall-constrained threshold
if trial is not None: if n_pos >= 3:
trial.report(float(np.mean(fold_aucs)), step=fold_idx) prec, rec, thr = _find_best_precision_at_recall(y_val, proba, min_recall)
if trial.should_prune(): else:
raise optuna.TrialPruned() 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 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, X: np.ndarray,
y: np.ndarray, y: np.ndarray,
w: np.ndarray, w: np.ndarray,
source: np.ndarray,
n_splits: int, n_splits: int,
train_ratio: float, train_ratio: float,
min_recall: float = 0.35,
): ):
"""클로저로 데이터셋을 캡처한 목적 함수를 반환한다.""" """클로저로 데이터셋을 캡처한 목적 함수를 반환한다."""
@@ -191,33 +273,43 @@ def make_objective(
"reg_lambda": reg_lambda, "reg_lambda": reg_lambda,
} }
mean_auc, fold_aucs = _walk_forward_cv( mean_score, details = _walk_forward_cv(
X, y, w_scaled, params, X, y, w_scaled, source, params,
n_splits=n_splits, n_splits=n_splits,
train_ratio=train_ratio, train_ratio=train_ratio,
min_recall=min_recall,
trial=trial, trial=trial,
) )
# 폴드별 AUC를 user_attrs에 저장 (결과 리포트용) # 폴드별 상세 메트릭을 user_attrs에 저장 (결과 리포트용)
trial.set_user_attr("fold_aucs", fold_aucs) 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 return objective
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# 베이스라인 AUC 측정 (현재 고정 파라미터) # 베이스라인 측정 (현재 고정 파라미터)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
def measure_baseline( def measure_baseline(
X: np.ndarray, X: np.ndarray,
y: np.ndarray, y: np.ndarray,
w: np.ndarray, w: np.ndarray,
source: np.ndarray,
n_splits: int, n_splits: int,
train_ratio: float, train_ratio: float,
) -> tuple[float, list[float]]: min_recall: float = 0.35,
"""현재 실전 파라미터(active 파일 또는 하드코딩 기본값)로 베이스라인 AUC를 측정한다.""" ) -> tuple[float, dict]:
"""현재 실전 파라미터(active 파일 또는 하드코딩 기본값)로 베이스라인을 측정한다."""
active_path = Path("models/active_lgbm_params.json") active_path = Path("models/active_lgbm_params.json")
if active_path.exists(): if active_path.exists():
@@ -241,7 +333,11 @@ def measure_baseline(
} }
print("베이스라인 측정 중 (active 파일 없음 → 코드 내 기본 파라미터)...") 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( def print_report(
study: optuna.Study, study: optuna.Study,
baseline_auc: float, baseline_score: float,
baseline_folds: list[float], baseline_details: dict,
elapsed_sec: float, elapsed_sec: float,
output_path: Path, output_path: Path,
min_recall: float,
) -> None: ) -> None:
"""콘솔에 최종 리포트를 출력한다.""" """콘솔에 최종 리포트를 출력한다."""
best = study.best_trial best = study.best_trial
best_auc = best.value best_score = best.value
best_folds = best.user_attrs.get("fold_aucs", []) best_prec = best.user_attrs.get("mean_precision", 0.0)
improvement = best_auc - baseline_auc best_auc = best.user_attrs.get("mean_auc", 0.0)
improvement_pct = (improvement / baseline_auc * 100) if baseline_auc > 0 else 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_min = int(elapsed_sec // 60)
elapsed_s = int(elapsed_sec % 60) elapsed_s = int(elapsed_sec % 60)
@@ -276,11 +379,15 @@ def print_report(
f"(완료={len(completed)}, 조기종료={len(pruned)}) | " f"(완료={len(completed)}, 조기종료={len(pruned)}) | "
f"소요: {elapsed_min}{elapsed_s}") f"소요: {elapsed_min}{elapsed_s}")
print(sep) print(sep)
print(f" Best AUC : {best_auc:.4f} (Trial #{best.number})") print(f" 최적화 지표: Precision (recall >= {min_recall} 제약)")
if baseline_auc > 0: print(f" Best Prec : {best_prec:.4f} (Trial #{best.number})")
sign = "+" if improvement >= 0 else "" print(f" Best AUC : {best_auc:.4f}")
print(f" Baseline : {baseline_auc:.4f} (현재 train_model.py 고정값)") print(f" Best Recall: {best_rec:.4f}")
print(f" 개선폭 : {sign}{improvement:.4f} ({sign}{improvement_pct:.1f}%)") 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(dash)
print(" Best Parameters:") print(" Best Parameters:")
for k, v in best.params.items(): for k, v in best.params.items():
@@ -289,19 +396,42 @@ def print_report(
else: else:
print(f" {k:<22}: {v}") print(f" {k:<22}: {v}")
print(dash) print(dash)
print(" Walk-Forward 폴드별 AUC (Best Trial):")
for i, auc in enumerate(best_folds, 1): # 폴드별 상세
print(f" 폴드 {i}: {auc:.4f}") fold_aucs = best.user_attrs.get("fold_aucs", [])
if best_folds: fold_precs = best.user_attrs.get("fold_precisions", [])
arr = np.array(best_folds) fold_recs = best.user_attrs.get("fold_recalls", [])
print(f" 평균: {arr.mean():.4f} ± {arr.std():.4f}") fold_thrs = best.user_attrs.get("fold_thresholds", [])
if baseline_folds: 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(dash)
print(" Baseline 폴드별 AUC:") print(" Baseline 폴드별 상세:")
for i, auc in enumerate(baseline_folds, 1): for i, (auc, prec, rec, thr, npos) in enumerate(
print(f" 폴드 {i}: {auc:.4f}") zip(bl_folds, bl_precs, bl_recs, bl_thrs, bl_npos), 1
arr = np.array(baseline_folds) ):
print(f" 평균: {arr.mean():.4f} ± {arr.std():.4f}") print(f" 폴드 {i}: AUC={auc:.4f} Prec={prec:.3f} Rec={rec:.3f} Thr={thr:.3f} (양성={npos})")
print(dash) print(dash)
print(f" 결과 저장: {output_path}") print(f" 결과 저장: {output_path}")
print(f" 다음 단계: python scripts/train_model.py (파라미터 수동 반영 후)") print(f" 다음 단계: python scripts/train_model.py (파라미터 수동 반영 후)")
@@ -310,10 +440,11 @@ def print_report(
def save_results( def save_results(
study: optuna.Study, study: optuna.Study,
baseline_auc: float, baseline_score: float,
baseline_folds: list[float], baseline_details: dict,
elapsed_sec: float, elapsed_sec: float,
data_path: str, data_path: str,
min_recall: float,
) -> Path: ) -> Path:
"""결과를 JSON 파일로 저장하고 경로를 반환한다.""" """결과를 JSON 파일로 저장하고 경로를 반환한다."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
@@ -327,8 +458,12 @@ def save_results(
if t.state == optuna.trial.TrialState.COMPLETE: if t.state == optuna.trial.TrialState.COMPLETE:
all_trials.append({ all_trials.append({
"number": t.number, "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_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": { "params": {
k: (round(v, 6) if isinstance(v, float) else v) k: (round(v, 6) if isinstance(v, float) else v)
for k, v in t.params.items() for k, v in t.params.items()
@@ -336,19 +471,33 @@ def save_results(
}) })
result = { result = {
"timestamp": datetime.now().isoformat(), "timestamp": datetime.now().isoformat(),
"data_path": data_path, "data_path": data_path,
"n_trials_total": len(study.trials), "min_recall_constraint": min_recall,
"n_trials_complete": len(all_trials), "n_trials_total": len(study.trials),
"elapsed_sec": round(elapsed_sec, 1), "n_trials_complete": len(all_trials),
"elapsed_sec": round(elapsed_sec, 1),
"baseline": { "baseline": {
"auc": round(baseline_auc, 6), "score": round(baseline_score, 6),
"fold_aucs": [round(a, 6) for a in baseline_folds], "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": { "best_trial": {
"number": best.number, "number": best.number,
"auc": round(best.value, 6), "score": round(best.value, 6),
"fold_aucs": [round(a, 6) for a in best.user_attrs.get("fold_aucs", [])], "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": { "params": {
k: (round(v, 6) if isinstance(v, float) else v) k: (round(v, 6) if isinstance(v, float) else v)
for k, v in best.params.items() 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("--trials", type=int, default=50, help="Optuna trial 수 (기본: 50)")
parser.add_argument("--folds", type=int, default=5, help="Walk-Forward 폴드 수 (기본: 5)") 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("--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="베이스라인 측정 건너뜀") parser.add_argument("--no-baseline", action="store_true", help="베이스라인 측정 건너뜀")
args = parser.parse_args() args = parser.parse_args()
# 1. 데이터셋 로드 (1회) # 1. 데이터셋 로드 (1회)
X, y, w = load_dataset(args.data) X, y, w, source = load_dataset(args.data)
# 2. 베이스라인 측정 # 2. 베이스라인 측정
if args.no_baseline: if args.no_baseline:
baseline_auc, baseline_folds = 0.0, [] baseline_score, baseline_details = 0.0, {}
print("베이스라인 측정 건너뜀 (--no-baseline)\n") print("베이스라인 측정 건너뜀 (--no-baseline)\n")
else: 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( print(
f"베이스라인 AUC: {baseline_auc:.4f} " f"베이스라인: Prec={bl_prec:.4f}, AUC={bl_auc:.4f}, Recall={bl_rec:.4f} "
f"(폴드별: {[round(a, 4) for a in baseline_folds]})\n" f"(recall >= {args.min_recall} 제약)\n"
) )
# 3. Optuna study 실행 # 3. Optuna study 실행
optuna.logging.set_verbosity(optuna.logging.WARNING) optuna.logging.set_verbosity(optuna.logging.WARNING)
sampler = TPESampler(seed=42) 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( study = optuna.create_study(
direction="maximize", direction="maximize",
sampler=sampler, sampler=sampler,
pruner=pruner, 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"Optuna 탐색 시작: {args.trials} trials, {args.folds}폴드 Walk-Forward")
print(f"최적화 지표: Precision (recall >= {args.min_recall} 제약)")
print("(trial 완료마다 진행 상황 출력)\n") print("(trial 완료마다 진행 상황 출력)\n")
start_time = time.time() start_time = time.time()
@@ -411,12 +572,13 @@ def main():
def _progress_callback(study: optuna.Study, trial: optuna.trial.FrozenTrial) -> None: def _progress_callback(study: optuna.Study, trial: optuna.trial.FrozenTrial) -> None:
if trial.state == optuna.trial.TrialState.COMPLETE: if trial.state == optuna.trial.TrialState.COMPLETE:
best_so_far = study.best_value best_so_far = study.best_value
leaves = trial.params.get("num_leaves", "?") prec = trial.user_attrs.get("mean_precision", 0.0)
depth = trial.params.get("max_depth", "?") auc = trial.user_attrs.get("mean_auc", 0.0)
print( 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"| 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: elif trial.state == optuna.trial.TrialState.PRUNED:
print(f" Trial #{trial.number:3d} | PRUNED (조기 종료)") print(f" Trial #{trial.number:3d} | PRUNED (조기 종료)")
@@ -431,21 +593,32 @@ def main():
elapsed = time.time() - start_time elapsed = time.time() - start_time
# 4. 결과 저장 및 출력 # 4. 결과 저장 및 출력
output_path = save_results(study, baseline_auc, baseline_folds, elapsed, args.data) output_path = save_results(
print_report(study, baseline_auc, baseline_folds, elapsed, output_path) 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 파일 자동 갱신 # 5. 성능 개선 시 active 파일 자동 갱신
import shutil import shutil
active_path = Path("models/active_lgbm_params.json") 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) shutil.copy(output_path, active_path)
improvement = study.best_value - baseline_auc best_prec = study.best_trial.user_attrs.get("mean_precision", 0.0)
print(f"[MLOps] AUC +{improvement:.4f} 개선 → {active_path} 자동 갱신 완료") 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") print(f"[MLOps] 다음 train_model.py 실행 시 새 파라미터가 자동 적용됩니다.\n")
elif args.no_baseline: elif args.no_baseline:
print("[MLOps] --no-baseline 모드: 성능 비교 없이 active 파일 유지\n") print("[MLOps] --no-baseline 모드: 성능 비교 없이 active 파일 유지\n")
else: 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__": if __name__ == "__main__":

View File

@@ -9,6 +9,7 @@ from src.notifier import DiscordNotifier
from src.risk_manager import RiskManager from src.risk_manager import RiskManager
from src.ml_filter import MLFilter from src.ml_filter import MLFilter
from src.ml_features import build_features from src.ml_features import build_features
from src.user_data_stream import UserDataStream
class TradingBot: class TradingBot:
@@ -19,6 +20,9 @@ class TradingBot:
self.risk = RiskManager(config) self.risk = RiskManager(config)
self.ml_filter = MLFilter() self.ml_filter = MLFilter()
self.current_trade_side: str | None = None # "LONG" | "SHORT" self.current_trade_side: str | None = None # "LONG" | "SHORT"
self._entry_price: float | None = None
self._entry_quantity: float | None = None
self._is_reentering: bool = False # _close_and_reenter 중 콜백 상태 초기화 방지
self._prev_oi: float | None = None # OI 변화율 계산용 이전 값 self._prev_oi: float | None = None # OI 변화율 계산용 이전 값
self.stream = MultiSymbolStream( self.stream = MultiSymbolStream(
symbols=[config.symbol, "BTCUSDT", "ETHUSDT"], symbols=[config.symbol, "BTCUSDT", "ETHUSDT"],
@@ -39,6 +43,8 @@ class TradingBot:
if position is not None: if position is not None:
amt = float(position["positionAmt"]) amt = float(position["positionAmt"])
self.current_trade_side = "LONG" if amt > 0 else "SHORT" self.current_trade_side = "LONG" if amt > 0 else "SHORT"
self._entry_price = float(position["entryPrice"])
self._entry_quantity = abs(amt)
entry = float(position["entryPrice"]) entry = float(position["entryPrice"])
logger.info( logger.info(
f"기존 포지션 복구: {self.current_trade_side} | " f"기존 포지션 복구: {self.current_trade_side} | "
@@ -152,6 +158,8 @@ class TradingBot:
} }
self.current_trade_side = signal self.current_trade_side = signal
self._entry_price = price
self._entry_quantity = quantity
self.notifier.notify_open( self.notifier.notify_open(
symbol=self.config.symbol, symbol=self.config.symbol,
side=signal, side=signal,
@@ -183,26 +191,57 @@ class TradingBot:
reduce_only=True, reduce_only=True,
) )
async def _close_position(self, position: dict): def _calc_estimated_pnl(self, exit_price: float) -> float:
amt = abs(float(position["positionAmt"])) """진입가·수량 기반 예상 PnL 계산 (수수료 미반영)."""
side = "SELL" if float(position["positionAmt"]) > 0 else "BUY" if self._entry_price is None or self._entry_quantity is None or self.current_trade_side is None:
pos_side = "LONG" if side == "SELL" else "SHORT" return 0.0
await self.exchange.cancel_all_orders() if self.current_trade_side == "LONG":
await self.exchange.place_order(side=side, quantity=amt, reduce_only=True) return (exit_price - self._entry_price) * self._entry_quantity
return (self._entry_price - exit_price) * self._entry_quantity
entry = float(position["entryPrice"]) async def _on_position_closed(
mark = float(position["markPrice"]) self,
pnl = (mark - entry) * amt if side == "SELL" else (entry - mark) * amt net_pnl: float,
close_reason: str,
exit_price: float,
) -> None:
"""User Data Stream에서 청산 감지 시 호출되는 콜백."""
estimated_pnl = self._calc_estimated_pnl(exit_price)
diff = net_pnl - estimated_pnl
self.risk.record_pnl(net_pnl)
self.notifier.notify_close( self.notifier.notify_close(
symbol=self.config.symbol, symbol=self.config.symbol,
side=pos_side, side=self.current_trade_side or "UNKNOWN",
exit_price=mark, close_reason=close_reason,
pnl=pnl, exit_price=exit_price,
estimated_pnl=estimated_pnl,
net_pnl=net_pnl,
diff=diff,
) )
self.risk.record_pnl(pnl)
logger.success(
f"포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT"
)
# _close_and_reenter 중이면 신규 포지션 상태를 덮어쓰지 않는다
if self._is_reentering:
return
# Flat 상태로 초기화
self.current_trade_side = None self.current_trade_side = None
logger.success(f"포지션 청산: PnL={pnl:.4f} USDT") self._entry_price = None
self._entry_quantity = None
async def _close_position(self, position: dict):
"""포지션 청산 주문만 실행한다. PnL 기록/알림은 _on_position_closed 콜백이 담당."""
amt = abs(float(position["positionAmt"]))
side = "SELL" if float(position["positionAmt"]) > 0 else "BUY"
await self.exchange.cancel_all_orders()
await self.exchange.place_order(side=side, quantity=amt, reduce_only=True)
logger.info(f"청산 주문 전송 완료 (side={side}, qty={amt})")
async def _close_and_reenter( async def _close_and_reenter(
self, self,
@@ -215,23 +254,28 @@ class TradingBot:
funding_rate: float = 0.0, funding_rate: float = 0.0,
) -> None: ) -> None:
"""기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다.""" """기존 포지션을 청산하고, 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(): if not self.risk.can_open_new_position():
logger.info("최대 포지션 수 도달 — 재진입 건너뜀") 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} 재진입 무시")
return 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): async def run(self):
logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x") logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x")
@@ -239,7 +283,19 @@ class TradingBot:
balance = await self.exchange.get_balance() balance = await self.exchange.get_balance()
self.risk.set_base_balance(balance) self.risk.set_base_balance(balance)
logger.info(f"기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)") logger.info(f"기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)")
await self.stream.start(
api_key=self.config.api_key, user_stream = UserDataStream(
api_secret=self.config.api_secret, 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,
),
) )

View File

@@ -47,6 +47,10 @@ def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame:
d["stoch_k"] = stoch["STOCHRSIk_14_14_3_3"] d["stoch_k"] = stoch["STOCHRSIk_14_14_3_3"]
d["stoch_d"] = stoch["STOCHRSId_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 return d
@@ -112,6 +116,12 @@ def _calc_signals(d: pd.DataFrame) -> np.ndarray:
# 둘 다 해당하면 HOLD (충돌 방지) # 둘 다 해당하면 HOLD (충돌 방지)
signal_arr[long_enter & short_enter] = "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 return signal_arr
@@ -352,6 +362,7 @@ def generate_dataset_vectorized(
btc_df: pd.DataFrame | None = None, btc_df: pd.DataFrame | None = None,
eth_df: pd.DataFrame | None = None, eth_df: pd.DataFrame | None = None,
time_weight_decay: float = 0.0, time_weight_decay: float = 0.0,
negative_ratio: int = 0,
) -> pd.DataFrame: ) -> pd.DataFrame:
""" """
전체 시계열을 1회 계산해 학습 데이터셋을 생성한다. 전체 시계열을 1회 계산해 학습 데이터셋을 생성한다.
@@ -362,6 +373,9 @@ def generate_dataset_vectorized(
양수일수록 최신 샘플에 더 높은 가중치를 부여한다. 양수일수록 최신 샘플에 더 높은 가중치를 부여한다.
예) 2.0 → 최신 샘플이 가장 오래된 샘플보다 e^2 ≈ 7.4배 높은 가중치. 예) 2.0 → 최신 샘플이 가장 오래된 샘플보다 e^2 ≈ 7.4배 높은 가중치.
결과 DataFrame에 'sample_weight' 컬럼으로 포함된다. 결과 DataFrame에 'sample_weight' 컬럼으로 포함된다.
negative_ratio: 시그널 샘플 대비 HOLD negative 샘플 비율.
0이면 기존 동작 (시그널만). 5면 시그널의 5배만큼 HOLD 샘플 추가.
""" """
print(" [1/3] 전체 시계열 지표 계산 (1회)...") print(" [1/3] 전체 시계열 지표 계산 (1회)...")
d = _calc_indicators(df) 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) feat_all = _calc_features_vectorized(d, signal_arr, btc_df=btc_df, eth_df=eth_df)
# 신호 발생 + NaN 없음 + 미래 데이터 충분한 인덱스만 # 신호 발생 + NaN 없음 + 미래 데이터 충분한 인덱스만
# oi_change/funding_rate는 선택적 피처(컬럼 없으면 전체 nan)이므로 NaN 체크에서 제외
OPTIONAL_COLS = {"oi_change", "funding_rate"} OPTIONAL_COLS = {"oi_change", "funding_rate"}
available_cols_for_nan_check = [ available_cols_for_nan_check = [
c for c in FEATURE_COLS c for c in FEATURE_COLS
if c in feat_all.columns and c not in OPTIONAL_COLS if c in feat_all.columns and c not in OPTIONAL_COLS
] ]
valid_rows = ( base_valid = (
(signal_arr != "HOLD") &
(~feat_all[available_cols_for_nan_check].isna().any(axis=1).values) & (~feat_all[available_cols_for_nan_check].isna().any(axis=1).values) &
(np.arange(len(d)) >= WARMUP) & (np.arange(len(d)) >= WARMUP) &
(np.arange(len(d)) < len(d) - LOOKAHEAD) (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(f" 신호 발생 인덱스: {len(sig_idx):,}")
print(" [3/3] 레이블 계산...") print(" [3/3] 레이블 계산...")
labels, valid_mask = _calc_labels_vectorized(d, feat_all, sig_idx) labels, valid_mask = _calc_labels_vectorized(d, feat_all, sig_idx)
final_idx = sig_idx[valid_mask] final_sig_idx = sig_idx[valid_mask]
# btc_df/eth_df 제공 여부에 따라 실제 존재하는 피처 컬럼만 선택
available_feature_cols = [c for c in FEATURE_COLS if c in feat_all.columns] 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_signal = feat_all.iloc[final_sig_idx][available_feature_cols].copy()
feat_final["label"] = labels 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) n = len(feat_final)
if time_weight_decay > 0 and n > 1: 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 = 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}): " print(f" 시간 가중치 적용 (decay={time_weight_decay}): "
f"min={weights.min():.3f}, max={weights.max():.3f}") f"min={weights.min():.3f}, max={weights.max():.3f}")
else: else:
weights = np.ones(n, dtype=np.float32) weights = np.ones(n, dtype=np.float32)
feat_final = feat_final.reset_index(drop=True)
feat_final["sample_weight"] = weights 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 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]))

View File

@@ -172,3 +172,31 @@ class BinanceFuturesClient:
except Exception as e: except Exception as e:
logger.warning(f"펀딩비 조회 실패 (무시): {e}") logger.warning(f"펀딩비 조회 실패 (무시): {e}")
return None return None
async def create_listen_key(self) -> str:
"""POST /fapi/v1/listenKey — listenKey 신규 발급"""
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: self.client.futures_stream_get_listen_key(),
)
return result
async def keepalive_listen_key(self, listen_key: str) -> None:
"""PUT /fapi/v1/listenKey — listenKey 만료 연장 (60분 → 리셋)"""
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: self.client.futures_stream_keepalive(listenKey=listen_key),
)
async def delete_listen_key(self, listen_key: str) -> None:
"""DELETE /fapi/v1/listenKey — listenKey 삭제 (정상 종료 시)"""
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(
None,
lambda: self.client.futures_stream_close(listenKey=listen_key),
)
except Exception as e:
logger.warning(f"listenKey 삭제 실패 (무시): {e}")

View File

@@ -43,6 +43,10 @@ class Indicators:
df["stoch_k"] = stoch["STOCHRSIk_14_14_3_3"] df["stoch_k"] = stoch["STOCHRSIk_14_14_3_3"]
df["stoch_d"] = stoch["STOCHRSId_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) df["vol_ma20"] = ta.sma(df["volume"], length=20)
@@ -56,6 +60,12 @@ class Indicators:
last = df.iloc[-1] last = df.iloc[-1]
prev = df.iloc[-2] 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 long_signals = 0
short_signals = 0 short_signals = 0

View File

@@ -1,3 +1,4 @@
import os
from pathlib import Path from pathlib import Path
import joblib import joblib
import numpy as np import numpy as np
@@ -34,6 +35,7 @@ class MLFilter:
lgbm_path: str = str(LGBM_MODEL_PATH), lgbm_path: str = str(LGBM_MODEL_PATH),
threshold: float = 0.60, threshold: float = 0.60,
): ):
self._disabled = os.environ.get("NO_ML_FILTER", "").lower() in ("1", "true", "yes")
self._onnx_path = Path(onnx_path) self._onnx_path = Path(onnx_path)
self._lgbm_path = Path(lgbm_path) self._lgbm_path = Path(lgbm_path)
self._threshold = threshold self._threshold = threshold
@@ -41,7 +43,11 @@ class MLFilter:
self._lgbm_model = None self._lgbm_model = None
self._loaded_onnx_mtime: float = 0.0 self._loaded_onnx_mtime: float = 0.0
self._loaded_lgbm_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): def _try_load(self):
# 로드 여부와 무관하게 두 파일의 현재 mtime을 항상 기록한다. # 로드 여부와 무관하게 두 파일의 현재 mtime을 항상 기록한다.
@@ -101,6 +107,7 @@ class MLFilter:
모델 파일의 mtime을 확인해 변경됐으면 리로드한다. 모델 파일의 mtime을 확인해 변경됐으면 리로드한다.
실제로 리로드가 일어났으면 True 반환. 실제로 리로드가 일어났으면 True 반환.
""" """
if self._disabled: return False
onnx_changed = _mtime(self._onnx_path) != self._loaded_onnx_mtime onnx_changed = _mtime(self._onnx_path) != self._loaded_onnx_mtime
lgbm_changed = _mtime(self._lgbm_path) != self._loaded_lgbm_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: def should_enter(self, features: pd.Series) -> bool:
""" """
확률 >= threshold 이면 True (진입 허용). 확률 >= threshold 이면 True (진입 허용).
모델 없으면 True 반환 (폴백). NO_ML_FILTER=true 이거나 모델 없으면 True 반환 (폴백).
""" """
if self._disabled:
logger.debug("ML 필터 비활성화 모드 → 진입 허용")
return True
if not self.is_model_loaded(): if not self.is_model_loaded():
return True return True
try: try:

View File

@@ -49,13 +49,20 @@ class DiscordNotifier:
self, self,
symbol: str, symbol: str,
side: str, side: str,
close_reason: str,
exit_price: float, exit_price: float,
pnl: float, estimated_pnl: float,
net_pnl: float,
diff: float,
) -> None: ) -> None:
emoji = "" if pnl >= 0 else "" emoji_map = {"TP": "", "SL": "", "MANUAL": "🔶"}
emoji = emoji_map.get(close_reason, "🔶")
msg = ( msg = (
f"{emoji} **[{symbol}] {side} 청산**\n" f"{emoji} **[{symbol}] {side} {close_reason} 청산**\n"
f"청산가: `{exit_price:.4f}` | PnL: `{pnl:+.4f} USDT`" f"청산가: `{exit_price:.4f}`\n"
f"예상 수익: `{estimated_pnl:+.4f} USDT`\n"
f"실제 순수익: `{net_pnl:+.4f} USDT`\n"
f"차이(슬리피지+수수료): `{diff:+.4f} USDT`"
) )
self._send(msg) self._send(msg)

114
src/user_data_stream.py Normal file
View 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,
)

View File

@@ -17,6 +17,7 @@ def config():
"RISK_PER_TRADE": "0.02", "RISK_PER_TRADE": "0.02",
"NOTION_TOKEN": "secret_test", "NOTION_TOKEN": "secret_test",
"NOTION_DATABASE_ID": "db_test", "NOTION_DATABASE_ID": "db_test",
"DISCORD_WEBHOOK_URL": "",
}) })
return Config() return Config()

View File

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

View File

@@ -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) result = generate_dataset_vectorized(xrp_df, btc_df=btc_df, eth_df=eth_df)
if not result.empty: if not result.empty:
assert set(FEATURE_COLS).issubset(set(result.columns)) 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): def test_matches_original_generate_dataset(sample_df):
@@ -208,3 +208,61 @@ def test_rs_zero_denominator():
"xrp_btc_rs에 inf가 있으면 안 됨" "xrp_btc_rs에 inf가 있으면 안 됨"
assert not feat["xrp_btc_rs"].isna().all(), \ assert not feat["xrp_btc_rs"].isna().all(), \
"xrp_btc_rs가 전부 nan이면 안 됨" "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}가 누락됨"

View File

@@ -45,6 +45,42 @@ def test_bollinger_bands(sample_df):
assert (valid["bb_upper"] >= valid["bb_lower"]).all() 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): def test_signal_returns_direction(sample_df):
ind = Indicators(sample_df) ind = Indicators(sample_df)
df = ind.calculate_all() df = ind.calculate_all()