Compare commits
188 Commits
feature/oi
...
a0990c5fd5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0990c5fd5 | ||
|
|
82f4977dff | ||
|
|
4c40516559 | ||
|
|
17742da6af | ||
|
|
ff2566dfef | ||
|
|
0ddd1f6764 | ||
|
|
1135efc5be | ||
|
|
bd152a84e1 | ||
|
|
e2b0454825 | ||
|
|
aa5c0afce6 | ||
|
|
4fef073b0a | ||
|
|
dacefaa1ed | ||
|
|
d8f5d4f1fb | ||
|
|
b5a5510499 | ||
|
|
c29d3e0569 | ||
|
|
30ddb2fef4 | ||
|
|
6830549fd6 | ||
|
|
fe99885faa | ||
|
|
4533118aab | ||
|
|
c0da46c60a | ||
|
|
5bad7dd691 | ||
|
|
a34fc6f996 | ||
|
|
24f0faa540 | ||
|
|
0fe87bb366 | ||
|
|
0cc5835b3a | ||
|
|
75d1af7fcc | ||
|
|
41b0aa3f28 | ||
|
|
e3623293f7 | ||
|
|
13c2b95c8e | ||
|
|
9f0057e29d | ||
|
|
f14c521302 | ||
|
|
e648ae7ca0 | ||
|
|
e3a78974b3 | ||
|
|
181f82d3c0 | ||
|
|
24ed7ddec0 | ||
|
|
b86aa8b072 | ||
|
|
42e53b9ae4 | ||
|
|
4930140b19 | ||
|
|
f890009a92 | ||
|
|
5b3f6af13c | ||
|
|
9d9f4960fc | ||
|
|
8c1cd0422f | ||
|
|
4792b0f9cf | ||
|
|
652990082d | ||
|
|
5e3a207af4 | ||
|
|
ab032691d4 | ||
|
|
55c20012a3 | ||
|
|
106eaf182b | ||
|
|
64f56806d2 | ||
|
|
8803c71bf9 | ||
|
|
b188607d58 | ||
|
|
9644cf4ff0 | ||
|
|
805f1b0528 | ||
|
|
363234ac7c | ||
|
|
de27f85e6d | ||
|
|
cdde1795db | ||
|
|
d03012bb04 | ||
|
|
af91b36467 | ||
|
|
c6c60b274c | ||
|
|
97aef14d6c | ||
|
|
afdbacaabd | ||
|
|
9b76313500 | ||
|
|
60510c026b | ||
|
|
0a8748913e | ||
|
|
c577019793 | ||
|
|
2a767c35d4 | ||
|
|
6a6740d708 | ||
|
|
f47ad26156 | ||
|
|
1b1542d51f | ||
|
|
90d99a1662 | ||
|
|
58596785aa | ||
|
|
3b0335f57e | ||
|
|
35177bf345 | ||
|
|
9011344aab | ||
|
|
2e788c0d0f | ||
|
|
771b357f28 | ||
|
|
d8d4bf3e20 | ||
|
|
072910df39 | ||
|
|
89f44c96af | ||
|
|
dbc900d478 | ||
|
|
90a72e4c39 | ||
|
|
cd9d379bc2 | ||
|
|
67692b3ebd | ||
|
|
02e41881ac | ||
|
|
15fb9c158a | ||
|
|
2b3f39b5d1 | ||
| d92fae13f8 | |||
|
|
dfcd803db5 | ||
|
|
9f4c22b5e6 | ||
|
|
852d3a8265 | ||
|
|
9ac839fd83 | ||
|
|
b03182691e | ||
|
|
2bb2bf2896 | ||
|
|
909d6af944 | ||
|
|
ae5692cde4 | ||
|
|
7acbdca3f4 | ||
|
|
e7620248c7 | ||
|
|
2e09f5340a | ||
|
|
9318fb887e | ||
|
|
7aef391b69 | ||
|
|
39e55368fd | ||
|
|
aef161002d | ||
|
|
bfecf63f1c | ||
|
|
6f3ea44edb | ||
|
|
f75ad9f6b1 | ||
|
|
07ba510fd8 | ||
|
|
565414c5e0 | ||
|
|
c555afbddc | ||
|
|
2b92fd0dce | ||
|
|
8a8c714a7c | ||
|
|
b55ddd8868 | ||
|
|
d2773e4dbf | ||
|
|
f2303e1863 | ||
|
|
448b3e016b | ||
|
|
ffa6e443c1 | ||
|
|
ff9e639142 | ||
|
|
676ec6ef5e | ||
|
|
33271013d3 | ||
|
|
b50306d68b | ||
| 4a2349bdbd | |||
|
|
c39097bf70 | ||
|
|
9c6f5dbd76 | ||
|
|
0aeb15ecfb | ||
|
|
0b18a0b80d | ||
| 038a1f84ec | |||
|
|
a33283ecb3 | ||
|
|
292ecc3e33 | ||
|
|
6fe2158511 | ||
|
|
3613e3bf18 | ||
|
|
fce4d536ea | ||
|
|
74966590b5 | ||
|
|
6cd54b46d9 | ||
|
|
0af138d8ee | ||
|
|
b7ad358a0a | ||
|
|
8e56301d52 | ||
|
|
99fa508db7 | ||
|
|
eeb5e9d877 | ||
|
|
c8a2c36bfb | ||
|
|
b8b99da207 | ||
|
|
77590accf2 | ||
|
|
a8cba2cb4c | ||
|
|
52affb5532 | ||
|
|
05ae88dc61 | ||
|
|
6237efe4d3 | ||
|
|
4e8e61b5cf | ||
|
|
4ffee0ae8b | ||
|
|
7e7f0f4f22 | ||
|
|
c4f806fc35 | ||
|
|
22f1debb3d | ||
|
|
4f3183df47 | ||
|
|
223608bec0 | ||
|
|
e72126516b | ||
|
|
63c2eb8927 | ||
|
|
dcdaf9f90a | ||
|
|
6d82febab7 | ||
|
|
d5f8ed4789 | ||
|
|
ce02f1335c | ||
|
|
4afc7506d7 | ||
|
|
caaa81f5f9 | ||
|
|
8dd1389b16 | ||
|
|
4c09d63505 | ||
|
|
0fca14a1c2 | ||
|
|
2f5227222b | ||
|
|
10b1ecd273 | ||
|
|
016b13a8f1 | ||
|
|
3c3c7fd56b | ||
|
|
aa52047f14 | ||
|
|
b57b00051a | ||
|
|
3f4e7910fd | ||
|
|
dfd4990ae5 | ||
|
|
4669d08cb4 | ||
|
|
2b315ad6d7 | ||
|
|
7a1abc7b72 | ||
|
|
de2a402bc1 | ||
|
|
684c8a32b9 | ||
|
|
c89374410e | ||
|
|
9ec78d76bd | ||
|
|
725a4349ee | ||
|
|
5e6cdcc358 | ||
|
|
361b0f4e00 | ||
|
|
031adac977 | ||
|
|
747ab45bb0 | ||
|
|
6fa6e854ca | ||
|
|
518f1846b8 | ||
|
|
3bfd1ca5a3 | ||
|
|
7fdd8bff94 | ||
|
|
bcc717776d | ||
| 9cac8a4afd |
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"superpowers@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
23
.env.example
23
.env.example
@@ -1,6 +1,23 @@
|
|||||||
BINANCE_API_KEY=
|
BINANCE_API_KEY=
|
||||||
BINANCE_API_SECRET=
|
BINANCE_API_SECRET=
|
||||||
SYMBOL=XRPUSDT
|
SYMBOLS=XRPUSDT
|
||||||
LEVERAGE=10
|
CORRELATION_SYMBOLS=BTCUSDT,ETHUSDT
|
||||||
RISK_PER_TRADE=0.02
|
LEVERAGE=20
|
||||||
DISCORD_WEBHOOK_URL=
|
DISCORD_WEBHOOK_URL=
|
||||||
|
ML_THRESHOLD=0.55
|
||||||
|
NO_ML_FILTER=true
|
||||||
|
MAX_SAME_DIRECTION=2
|
||||||
|
# Global defaults (fallback when no per-symbol override)
|
||||||
|
ATR_SL_MULT=2.0
|
||||||
|
ATR_TP_MULT=2.0
|
||||||
|
SIGNAL_THRESHOLD=3
|
||||||
|
ADX_THRESHOLD=25
|
||||||
|
VOL_MULTIPLIER=2.5
|
||||||
|
|
||||||
|
# Per-symbol strategy params (2026-03-21 운영 설정)
|
||||||
|
ATR_SL_MULT_XRPUSDT=1.5
|
||||||
|
ATR_TP_MULT_XRPUSDT=4.0
|
||||||
|
ADX_THRESHOLD_XRPUSDT=25
|
||||||
|
DASHBOARD_API_URL=http://10.1.10.24:8000
|
||||||
|
BINANCE_TESTNET_API_KEY=
|
||||||
|
BINANCE_TESTNET_API_SECRET=
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -7,5 +7,14 @@ logs/
|
|||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
models/*.pkl
|
models/*.pkl
|
||||||
|
models/*.onnx
|
||||||
|
models/tune_results_*.json
|
||||||
data/*.parquet
|
data/*.parquet
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
.DS_Store
|
||||||
|
.cursor/
|
||||||
|
|
||||||
|
.worktrees/
|
||||||
|
.venv
|
||||||
|
dashboard/ui/node_modules/
|
||||||
|
dashboard/ui/dist/
|
||||||
765
ARCHITECTURE.md
Normal file
765
ARCHITECTURE.md
Normal file
@@ -0,0 +1,765 @@
|
|||||||
|
# CoinTrader — 아키텍처 문서
|
||||||
|
|
||||||
|
> 이 문서는 CoinTrader의 내부 구조를 설명합니다.
|
||||||
|
> **봇 사용법**은 [README.md](README.md)를 참고하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
|
||||||
|
1. [시스템 개요](#1-시스템-개요) — 봇이 무엇을 하는지, 어떤 구조인지
|
||||||
|
2. [매매 판단 과정](#2-매매-판단-과정) — 15분마다 어떤 과정을 거쳐 매매하는지
|
||||||
|
3. [5개 레이어 상세](#3-5개-레이어-상세) — 각 레이어의 역할과 동작 원리
|
||||||
|
4. [MLOps 파이프라인](#4-mlops-파이프라인) — ML 모델의 학습·배포·모니터링 전체 흐름
|
||||||
|
5. [핵심 동작 시나리오](#5-핵심-동작-시나리오) — 실제 상황별 봇의 동작 흐름도
|
||||||
|
6. [테스트 커버리지](#6-테스트-커버리지) — 무엇을 어떻게 테스트하는지
|
||||||
|
7. [파일 구조](#7-파일-구조) — 전체 파일 역할 요약
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 시스템 개요
|
||||||
|
|
||||||
|
CoinTrader는 **Binance Futures 자동매매 봇**입니다.
|
||||||
|
|
||||||
|
**한 줄 요약**: 15분마다 기술 지표로 매매 신호를 생성하고, ML 모델로 한 번 더 검증한 뒤, 조건을 충족하면 자동으로 주문을 넣습니다.
|
||||||
|
|
||||||
|
### 1.1 전체 흐름 (간략)
|
||||||
|
|
||||||
|
```
|
||||||
|
15분봉 마감 → 기술 지표 계산 → 매매 신호 생성 → ML 필터 검증 → 리스크 체크 → 주문 실행 → Discord 알림
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 멀티심볼 아키텍처
|
||||||
|
|
||||||
|
여러 심볼을 동시에 거래합니다. 각 심볼은 독립된 봇 인스턴스로 실행되며, 리스크 관리만 공유합니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
main.py
|
||||||
|
└─ Config (SYMBOLS=XRPUSDT) # 멀티심볼 지원, 현재 XRP만 운영
|
||||||
|
└─ RiskManager (공유 싱글턴, asyncio.Lock)
|
||||||
|
└─ asyncio.gather(
|
||||||
|
TradingBot(symbol="XRPUSDT", risk=shared_risk),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
> **운영 이력**: SOL/DOGE/TRX는 파라미터 스윕에서 모든 조합에서 PF < 1.0으로 제외 (2026-03-21).
|
||||||
|
|
||||||
|
- **독립**: 각 봇은 자체 `Exchange`, `MLFilter`, `DataStream`, `SymbolStrategyParams`를 소유
|
||||||
|
- **공유**: `RiskManager`만 싱글턴으로 글로벌 리스크(일일 손실 한도, 동일 방향 제한) 관리
|
||||||
|
- **병렬**: `asyncio.gather()`로 동시 실행, 서로 간섭 없음
|
||||||
|
- **심볼별 전략**: `config.get_symbol_params(symbol)`로 SL/TP/ADX 등을 심볼별 독립 설정 (`ATR_SL_MULT_XRPUSDT` 등 환경변수)
|
||||||
|
|
||||||
|
### 1.3 기술 스택
|
||||||
|
|
||||||
|
| 분류 | 기술 |
|
||||||
|
|------|------|
|
||||||
|
| 언어 | Python 3.11+ |
|
||||||
|
| 비동기 런타임 | `asyncio` + `python-binance` WebSocket |
|
||||||
|
| 기술 지표 | `pandas-ta` (RSI, MACD, BB, EMA, StochRSI, ATR, ADX) |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
### 1.4 데이터 파이프라인 전체 흐름도
|
||||||
|
|
||||||
|
```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/>26개 피처 추출<br/>(XRP 13 + BTC/ETH 8 + OI/FR 2 + OI파생 2 + ADX 1)"]
|
||||||
|
ML["ml_filter.py<br/>MLFilter<br/>ONNX 우선 / LightGBM 폴백<br/>확률 ≥ 0.55 시 진입 허용"]
|
||||||
|
RM["risk_manager.py<br/>RiskManager (공유 싱글턴)<br/>일일 손실 5% 한도<br/>동적 증거금 비율<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 → 운영 서버<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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 매매 판단 과정
|
||||||
|
|
||||||
|
봇이 매매를 결정하는 과정을 단계별로 설명합니다. 코드를 읽기 전에 이 섹션을 먼저 이해하면 전체 구조가 명확해집니다.
|
||||||
|
|
||||||
|
### 2.1 진입 판단 (5단계 게이트)
|
||||||
|
|
||||||
|
```
|
||||||
|
Gate 0: 킬스위치 확인
|
||||||
|
└─ 해당 심볼이 킬 상태인가? → 킬이면 즉시 return (신규 진입 차단)
|
||||||
|
└─ Fast Kill: 8연속 순손실 / Slow Kill: 최근 15거래 PF < 0.75
|
||||||
|
|
||||||
|
Gate 1: 추세 존재 확인
|
||||||
|
└─ ADX ≥ 25 인가? → 미만이면 HOLD (횡보장 진입 차단)
|
||||||
|
|
||||||
|
Gate 2: 기술 지표 신호 생성
|
||||||
|
└─ RSI, MACD, 볼린저, EMA, StochRSI 점수 합산
|
||||||
|
└─ 합계 ≥ SIGNAL_THRESHOLD(기본 3)인가?
|
||||||
|
|
||||||
|
Gate 3: 거래량 확인
|
||||||
|
└─ 거래량 ≥ 20MA × VOL_MULTIPLIER(기본 2.5)인가?
|
||||||
|
└─ 또는 신호 점수가 SIGNAL_THRESHOLD + 1 이상인가?
|
||||||
|
|
||||||
|
Gate 4: ML 필터 (활성화 시)
|
||||||
|
└─ 26개 피처로 성공 확률 예측
|
||||||
|
└─ 확률 ≥ ML_THRESHOLD(기본 0.55)인가?
|
||||||
|
|
||||||
|
Gate 5: 리스크 관리
|
||||||
|
└─ 일일 손실 한도 미초과?
|
||||||
|
└─ 동일 방향 포지션 2개 미만?
|
||||||
|
└─ 같은 심볼 기존 포지션 없음?
|
||||||
|
|
||||||
|
→ 6개 게이트 모두 통과 → 주문 실행
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 청산 메커니즘
|
||||||
|
|
||||||
|
| 청산 방식 | 설명 |
|
||||||
|
|-----------|------|
|
||||||
|
| **TP (익절)** | 진입가 ± ATR × ATR_TP_MULT 도달 시 자동 청산 |
|
||||||
|
| **SL (손절)** | 진입가 ∓ ATR × ATR_SL_MULT 도달 시 자동 청산 |
|
||||||
|
| **반대 시그널** | 보유 중 반대 방향 신호 → 즉시 청산 후 반대 방향 재진입 |
|
||||||
|
|
||||||
|
### 2.3 현재 ML 필터 상태
|
||||||
|
|
||||||
|
**현재 비활성화** (`NO_ML_FILTER=true`)
|
||||||
|
|
||||||
|
Walk-Forward 검증 결과 각 폴드 학습 세트에 유효 신호가 약 27건으로, LightGBM이 의미 있는 패턴을 학습하기엔 표본이 부족합니다. 전략 파라미터 스윕에서 ADX 필터 + 거래량 배수 조합만으로 PF 1.57~2.39를 달성하여, 충분한 트레이드 데이터가 축적될 때까지 ML 없이 운영합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 5개 레이어 상세
|
||||||
|
|
||||||
|
봇은 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 단일 연결로 주 거래 심볼 + 상관관계 심볼(BTC/ETH)의 15분봉 캔들을 동시에 수신합니다.
|
||||||
|
|
||||||
|
**핵심 동작:**
|
||||||
|
|
||||||
|
1. **프리로드**: 봇 시작 시 REST API로 과거 캔들 200개를 `deque`에 즉시 채웁니다. EMA50 안정화에 필요한 최소 캔들(100개)을 확보하여 첫 캔들부터 신호를 계산할 수 있게 합니다.
|
||||||
|
2. **버퍼 관리**: 심볼별 `deque(maxlen=200)`에 마감된 캔들만 추가합니다. 미마감 캔들(`is_closed=False`)은 무시합니다.
|
||||||
|
3. **콜백 트리거**: 주 거래 심볼 캔들이 마감되면 `bot._on_candle_closed()`를 호출합니다. 상관관계 심볼(BTC·ETH)은 버퍼에만 쌓이고 콜백을 트리거하지 않습니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
예: TRXUSDT 봇의 Combined WebSocket
|
||||||
|
├── trxusdt@kline_15m → buffers["trxusdt"] → 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 | 거래량 급증 감지 |
|
||||||
|
|
||||||
|
**신호 생성 로직:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[1단계] ADX 횡보장 필터:
|
||||||
|
ADX < 25 → 즉시 HOLD 반환 (추세 부재로 진입 차단)
|
||||||
|
|
||||||
|
[2단계] 롱 신호 점수:
|
||||||
|
RSI < 35 → +1
|
||||||
|
MACD 골든크로스 (전봉→현봉) → +2 ← 강한 신호
|
||||||
|
종가 < 볼린저 하단 → +1
|
||||||
|
EMA 정배열 (9 > 21 > 50) → +1
|
||||||
|
StochRSI K < 20 and K > D → +1
|
||||||
|
|
||||||
|
진입 조건: 점수 ≥ SIGNAL_THRESHOLD(기본 3)
|
||||||
|
AND (거래량 ≥ 20MA × VOL_MULTIPLIER(기본 2.5) OR 점수 ≥ SIGNAL_THRESHOLD + 1)
|
||||||
|
|
||||||
|
SL = 진입가 - ATR × ATR_SL_MULT (기본 2.0)
|
||||||
|
TP = 진입가 + ATR × ATR_TP_MULT (기본 2.0)
|
||||||
|
|
||||||
|
※ SL/TP/신호임계값/ADX/거래량배수 모두 환경변수로 설정 가능 (심볼별 오버라이드 지원)
|
||||||
|
```
|
||||||
|
|
||||||
|
숏 신호는 롱의 대칭 조건으로 계산됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Layer 3: ML Filter Layer
|
||||||
|
|
||||||
|
**파일:** `src/ml_filter.py`, `src/ml_features.py`
|
||||||
|
|
||||||
|
기술 지표 신호가 발생해도 ML 모델이 "이 타점은 실패 확률이 높다"고 판단하면 진입을 차단합니다. 오진입을 줄이는 2차 게이트키퍼입니다.
|
||||||
|
|
||||||
|
**모델 우선순위:**
|
||||||
|
|
||||||
|
```
|
||||||
|
ONNX (MLX 신경망) → LightGBM → 폴백(항상 허용)
|
||||||
|
```
|
||||||
|
|
||||||
|
모델 파일이 없으면 모든 신호를 허용합니다. 봇 재시작 없이 모델 파일을 교체하면 다음 캔들 마감 시 자동으로 핫리로드됩니다(`mtime` 감지).
|
||||||
|
|
||||||
|
**26개 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 파생 피처 (2개):
|
||||||
|
oi_change_ma5 ← OI 변화율 5캔들 이동평균 (스마트머니 추세)
|
||||||
|
oi_price_spread ← OI 변화율 - 가격 변화율 (OI-가격 괴리도)
|
||||||
|
|
||||||
|
추세 강도 (1개):
|
||||||
|
adx ← ADX 값 (ML 모델이 횡보/추세 판단에 활용)
|
||||||
|
```
|
||||||
|
|
||||||
|
`oi_change`와 `funding_rate`는 캔들 마감마다 Binance REST API로 실시간 조회합니다. API 실패 시 `0.0`으로 폴백하여 봇이 멈추지 않습니다.
|
||||||
|
|
||||||
|
**진입 판단:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
proba = model.predict_proba(features)[0][1] # 성공 확률
|
||||||
|
return proba >= 0.55 # 임계값 (ML_THRESHOLD 환경변수로 조절)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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 설정 (3회 재시도)
|
||||||
|
4. place_order(TAKE_PROFIT_MARKET) ← TP 설정 (3회 재시도)
|
||||||
|
※ SL/TP 최종 실패 시 → 긴급 시장가 청산 + Discord 알림
|
||||||
|
```
|
||||||
|
|
||||||
|
**SL/TP 원자성 보장:** SL/TP 배치는 `_place_sl_tp_with_retry()`로 3회 재시도합니다. 개별 추적(SL 성공 후 TP만 재시도)하여 불필요한 중복 주문을 방지합니다. 모든 재시도 실패 시 `_emergency_close()`가 포지션을 즉시 시장가 청산하고 Discord로 긴급 알림을 전송합니다.
|
||||||
|
|
||||||
|
**리스크 제어:**
|
||||||
|
|
||||||
|
| 제어 항목 | 기준 | 방어 대상 |
|
||||||
|
|----------|------|-----------|
|
||||||
|
| 일일 최대 손실 | 기준 잔고의 5% | 단일 충격 (하루 급락) |
|
||||||
|
| 킬스위치 Fast Kill | 8연속 순손실 | 전략 급격 붕괴 |
|
||||||
|
| 킬스위치 Slow Kill | 최근 15거래 PF < 0.75 | 점진적 엣지 소실 (Slow Bleed) |
|
||||||
|
| 최대 동시 포지션 | 3개 (전체 심볼 합산) | 과노출 |
|
||||||
|
| 동일 방향 제한 | 2개 (LONG 2개면 3번째 LONG 차단) | 방향 편중 |
|
||||||
|
| 같은 심볼 중복 | 차단 (1심볼 1포지션) | 중복 진입 |
|
||||||
|
| 최소 명목금액 | $5 USDT | 거래소 제약 |
|
||||||
|
|
||||||
|
**반대 시그널 재진입:** 보유 포지션과 반대 방향 신호 발생 시 기존 포지션을 즉시 청산하고, ML 필터 통과 시 반대 방향으로 재진입합니다. 재진입 중 User Data Stream 콜백이 신규 포지션 상태를 덮어쓰지 않도록 `_is_reentering` 플래그로 보호합니다.
|
||||||
|
|
||||||
|
**마진 균등 배분:** 멀티심볼 모드에서 각 봇은 전체 잔고를 심볼 수로 나눈 금액만큼만 사용합니다 (`balance / len(symbols)`). 공유 `RiskManager`의 `asyncio.Lock`으로 동시 포지션 등록/해제 시 경합 조건을 방지합니다.
|
||||||
|
|
||||||
|
**Graceful Shutdown:** `main.py`에서 `SIGTERM`/`SIGINT` 시그널을 수신하면 `_graceful_shutdown()`이 실행됩니다. 각 봇의 오픈 주문을 심볼별로 취소(5초 타임아웃)한 후 모든 asyncio 태스크를 정리합니다. Docker `docker stop` 또는 `kill` 시 고아 주문이 거래소에 남지 않습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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 == self.symbol ← 심볼 필터 (봇별 독립)
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. MLOps 파이프라인
|
||||||
|
|
||||||
|
봇의 ML 모델은 고정된 것이 아니라 주기적으로 재학습·개선됩니다.
|
||||||
|
|
||||||
|
### 4.1 전체 라이프사이클
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["주말 수동 트리거<br/>tune_hyperparams.py<br/>(Optuna 50 trials)"]
|
||||||
|
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["크론탭 또는 수동 실행<br/>train_and_deploy.sh<br/>(데이터 수집 → 학습 → 배포)"]
|
||||||
|
F["운영 서버<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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.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/{symbol}/tune_results_YYYYMMDD_HHMMSS.json`에 저장됩니다.
|
||||||
|
|
||||||
|
#### Step 2: Active Config 패턴으로 파라미터 승인
|
||||||
|
|
||||||
|
Optuna가 찾은 파라미터는 **자동으로 적용되지 않습니다.** 사람이 결과를 검토하고 직접 `models/{symbol}/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단계를 자동으로 실행합니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
[심볼별 반복] --symbol 지정 시 단일 심볼, --all 시 전체 심볼 순차 처리
|
||||||
|
|
||||||
|
[1/3] 데이터 수집 (fetch_history.py --symbol {SYM})
|
||||||
|
- data/{symbol}/combined_15m.parquet 없음 → 1년치(365일) 전체 수집
|
||||||
|
- 있음 → 35일치 Upsert (OI/펀딩비 0.0 구간 보충)
|
||||||
|
|
||||||
|
[2/3] 모델 학습 (train_model.py --symbol {SYM})
|
||||||
|
- models/{symbol}/active_lgbm_params.json 파라미터 로드
|
||||||
|
- 벡터화 데이터셋 생성 (dataset_builder.py)
|
||||||
|
- Walk-Forward 5폴드 검증 후 최종 모델 저장
|
||||||
|
- 학습 로그: models/{symbol}/training_log.json
|
||||||
|
|
||||||
|
[3/3] 운영 서버 배포 (deploy_model.sh --symbol {SYM})
|
||||||
|
- rsync로 models/{symbol}/lgbm_filter.pkl → 운영 서버 전송
|
||||||
|
- 기존 모델 자동 백업 (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`을 확인합니다. 변경이 감지되면 즉시 리로드합니다.
|
||||||
|
|
||||||
|
### 4.3 주간 전략 모니터링
|
||||||
|
|
||||||
|
`scripts/weekly_report.py`가 매주 자동으로 전략 성능을 측정하고 Discord로 리포트를 전송합니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
[매주 일요일 크론탭]
|
||||||
|
|
||||||
|
[1/7] 데이터 수집 (fetch_history.py × 심볼 수, 최근 35일 Upsert)
|
||||||
|
[2/7] Walk-Forward 백테스트 (심볼별 → 합산 PF/승률/MDD)
|
||||||
|
[3/7] 운영 대시보드 API 조회 (GET /api/trades + GET /api/stats → 실전 거래 통계)
|
||||||
|
[4/7] 추이 분석 (이전 리포트에서 PF/승률/MDD 추이 로드)
|
||||||
|
[5/7] 킬스위치 모니터링 (심볼별 연속 손실/15거래 PF → 2단계 경고 출력)
|
||||||
|
[6/7] ML 재학습 체크 (누적 트레이드 ≥ 150, PF < 1.0, PF 3주 하락 → 2/3 충족 시 권장)
|
||||||
|
[7/7] PF < 1.0이면 파라미터 스윕 실행 → 상위 3개 대안 제시
|
||||||
|
|
||||||
|
→ Discord 알림 + results/weekly/report_YYYY-MM-DD.json 저장
|
||||||
|
```
|
||||||
|
|
||||||
|
**전략 파라미터 스윕**: 성능 저하 감지 시 324개 파라미터 조합(SL/TP/ADX/신호임계값/거래량배수)을 자동 탐색하여 현재보다 높은 PF의 대안을 제시합니다. 자동 적용되지 않으며, 사람이 검토 후 승인해야 합니다.
|
||||||
|
|
||||||
|
### 4.4 레이블 생성 방식
|
||||||
|
|
||||||
|
학습 데이터의 레이블은 **미래 6시간(24캔들) 룩어헤드**로 생성됩니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
신호 발생 시점 기준:
|
||||||
|
SL = 진입가 - ATR × ATR_SL_MULT (기본 2.0)
|
||||||
|
TP = 진입가 + ATR × ATR_TP_MULT (기본 2.0)
|
||||||
|
|
||||||
|
향후 24캔들 동안:
|
||||||
|
- 저가가 SL에 먼저 닿으면 → label = 0 (실패)
|
||||||
|
- 고가가 TP에 먼저 닿으면 → label = 1 (성공)
|
||||||
|
- 둘 다 안 닿으면 → 샘플 제외
|
||||||
|
```
|
||||||
|
|
||||||
|
보수적 접근: SL 체크를 TP보다 먼저 수행하여 동시 돌파 시 실패로 처리합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 핵심 동작 시나리오
|
||||||
|
|
||||||
|
### 시나리오 1: 15분 캔들 마감 → 진입 판단
|
||||||
|
|
||||||
|
> "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 (26개 피처 Series)
|
||||||
|
BOT->>ML: should_enter(features)
|
||||||
|
ML-->>BOT: proba=0.73 ≥ 0.55 → 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: _append_trade(net_pnl, "TP") [JSONL 파일에 기록]
|
||||||
|
BOT->>BOT: _check_kill_switch() [8연패/PF<0.75 검사]
|
||||||
|
|
||||||
|
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` 플래그: 반대 시그널 재진입 중에는 콜백이 신규 포지션 상태를 초기화하지 않음
|
||||||
|
- `_close_lock`: 콜백(`_on_position_closed`)과 포지션 모니터(`_position_monitor` SYNC 경로) 간 PnL 이중기록 방지. asyncio await 포인트 사이 경쟁 조건을 Lock으로 원자화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 테스트 커버리지
|
||||||
|
|
||||||
|
### 6.1 테스트 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/ -v # 전체 실행
|
||||||
|
bash scripts/run_tests.sh # 래퍼 스크립트 실행
|
||||||
|
```
|
||||||
|
|
||||||
|
`tests/` 폴더에 15개 테스트 파일, 총 **138개의 테스트 케이스**가 작성되어 있습니다.
|
||||||
|
|
||||||
|
### 6.2 모듈별 테스트 현황
|
||||||
|
|
||||||
|
| 테스트 파일 | 대상 모듈 | 케이스 | 주요 검증 항목 |
|
||||||
|
|------------|----------|:------:|--------------|
|
||||||
|
| `test_bot.py` | `src/bot.py` | 11 | 반대 시그널 재진입, ML 차단 시 스킵, OI/펀딩비 피처 전달 |
|
||||||
|
| `test_indicators.py` | `src/indicators.py` | 7 | RSI 범위, MACD 컬럼, 볼린저 대소관계, ADX 횡보장 차단 |
|
||||||
|
| `test_ml_features.py` | `src/ml_features.py` | 11 | 26개 피처 수, RS 분모 0 처리, NaN 없음 |
|
||||||
|
| `test_ml_filter.py` | `src/ml_filter.py` | 5 | 모델 없을 때 폴백, 임계값 판단, 핫리로드 |
|
||||||
|
| `test_risk_manager.py` | `src/risk_manager.py` | 13 | 일일 손실 한도, 동일 방향 제한, 동적 증거금 비율 |
|
||||||
|
| `test_exchange.py` | `src/exchange.py` | 8 | 수량 계산, OI·펀딩비 조회 정상/오류 |
|
||||||
|
| `test_data_stream.py` | `src/data_stream.py` | 6 | 3심볼 버퍼, 캔들 파싱, 프리로드 200개 |
|
||||||
|
| `test_label_builder.py` | `src/label_builder.py` | 4 | TP/SL 도달 레이블, 미결 → None |
|
||||||
|
| `test_dataset_builder.py` | `src/dataset_builder.py` | 9 | DataFrame 반환, 필수 컬럼, inf/NaN 없음 |
|
||||||
|
| `test_mlx_filter.py` | `src/mlx_filter.py` | 5 | GPU 학습, 저장/로드 동일 예측 (Apple Silicon 전용) |
|
||||||
|
| `test_fetch_history.py` | `scripts/fetch_history.py` | 5 | OI=0 Upsert, 중복 방지, 타임스탬프 정렬 |
|
||||||
|
| `test_config.py` | `src/config.py` | 6 | 환경변수 로드, symbols 리스트 파싱 |
|
||||||
|
| `test_weekly_report.py` | `scripts/weekly_report.py` | 15 | 백테스트, 대시보드 API, 추이 분석, ML 트리거, 스윕 |
|
||||||
|
|
||||||
|
> `test_mlx_filter.py`는 Apple Silicon(`mlx` 패키지)이 없는 환경에서 자동 스킵됩니다.
|
||||||
|
|
||||||
|
### 6.3 커버리지 매트릭스
|
||||||
|
|
||||||
|
| 기능 | 단위 | 통합 | 비고 |
|
||||||
|
|------|:----:|:----:|------|
|
||||||
|
| 기술 지표 계산 | ✅ | ✅ | `test_indicators` + `test_ml_features` + `test_dataset_builder` |
|
||||||
|
| 신호 생성 (가중치 합산) | ✅ | ✅ | `test_indicators` + `test_dataset_builder` |
|
||||||
|
| ADX 횡보장 필터 | ✅ | ✅ | `test_indicators` |
|
||||||
|
| ML 피처 추출 (26개) | ✅ | ✅ | `test_ml_features` + `test_dataset_builder` |
|
||||||
|
| 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_risk_manager` |
|
||||||
|
| 포지션 수량 계산 | ✅ | — | `test_exchange` |
|
||||||
|
| OI/펀딩비 API 조회 | ✅ | ✅ | `test_exchange` + `test_bot` |
|
||||||
|
| 반대 시그널 재진입 | ✅ | ✅ | `test_bot` |
|
||||||
|
| OI 변화율 계산 | ✅ | ✅ | `test_bot` |
|
||||||
|
| Parquet Upsert | ✅ | — | `test_fetch_history` |
|
||||||
|
| 주간 리포트 | ✅ | ✅ | `test_weekly_report` |
|
||||||
|
| User Data Stream TP/SL | ❌ | — | 미작성 (WebSocket 의존) |
|
||||||
|
| Discord 알림 전송 | ❌ | — | 미작성 (외부 웹훅 의존) |
|
||||||
|
|
||||||
|
### 6.4 테스트 전략
|
||||||
|
|
||||||
|
- **Mock 원칙**: Binance API 호출은 모두 `unittest.mock.AsyncMock`으로 대체. 외부 의존성(Discord, WebSocket)은 테스트 대상에서 제외.
|
||||||
|
- **비동기 테스트**: `pytest-asyncio` + `@pytest.mark.asyncio`
|
||||||
|
- **경계값 중심**: 분모 0 처리, API 실패 폴백, 최소 주문 금액 미달, OI=0 구간 Upsert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 파일 구조
|
||||||
|
|
||||||
|
| 파일 | 레이어 | 역할 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `main.py` | — | 진입점. 심볼별 `TradingBot` 생성 + 공유 `RiskManager` + `asyncio.gather()` + SIGTERM/SIGINT graceful shutdown |
|
||||||
|
| `src/bot.py` | 오케스트레이터 | 심볼별 독립 트레이딩 루프 + 듀얼 레이어 킬스위치 |
|
||||||
|
| `src/config.py` | — | 환경변수 기반 설정 (`symbols` 리스트, `correlation_symbols`, 심볼별 `SymbolStrategyParams`) |
|
||||||
|
| `src/data_stream.py` | Data | Combined WebSocket 캔들 수신·버퍼 관리 |
|
||||||
|
| `src/indicators.py` | Signal | 기술 지표 계산 및 복합 신호 생성 |
|
||||||
|
| `src/ml_features.py` | ML Filter | 26개 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/backtester.py` | MLOps | 백테스트 엔진 (단일 + Walk-Forward) |
|
||||||
|
| `src/logger_setup.py` | — | Loguru 로거 설정 |
|
||||||
|
| `scripts/fetch_history.py` | MLOps | 과거 캔들 + OI/펀딩비 수집 |
|
||||||
|
| `scripts/train_model.py` | MLOps | LightGBM 모델 학습 |
|
||||||
|
| `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 | 모델 파일 운영 서버 전송 |
|
||||||
|
| `scripts/strategy_sweep.py` | MLOps | 전략 파라미터 그리드 스윕 (324개 조합) |
|
||||||
|
| `scripts/weekly_report.py` | MLOps | 주간 전략 리포트 (백테스트+킬스위치+대시보드API+추이+스윕+Discord) |
|
||||||
|
| `scripts/compare_symbols.py` | MLOps | 종목 비교 백테스트 (심볼별 파라미터 sweep) |
|
||||||
|
| `scripts/position_sizing_analysis.py` | MLOps | Robust Monte Carlo 포지션 사이징 분석 |
|
||||||
|
| `scripts/run_backtest.py` | MLOps | 단일 백테스트 CLI |
|
||||||
|
| `models/{symbol}/active_lgbm_params.json` | MLOps | 심볼별 승인된 LightGBM 파라미터 |
|
||||||
153
CLAUDE.md
Normal file
153
CLAUDE.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# 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 supports multi-symbol simultaneous trading (XRP, TRX, DOGE etc.) 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 (all symbols)
|
||||||
|
bash scripts/train_and_deploy.sh
|
||||||
|
|
||||||
|
# Single symbol training
|
||||||
|
bash scripts/train_and_deploy.sh --symbol TRXUSDT
|
||||||
|
|
||||||
|
# MLX GPU training (macOS Apple Silicon)
|
||||||
|
bash scripts/train_and_deploy.sh mlx --symbol XRPUSDT
|
||||||
|
|
||||||
|
# Hyperparameter tuning (50 trials, 5-fold walk-forward)
|
||||||
|
python scripts/tune_hyperparams.py --symbol XRPUSDT
|
||||||
|
|
||||||
|
# Weekly strategy report (manual, skip data fetch)
|
||||||
|
python scripts/weekly_report.py --skip-fetch
|
||||||
|
|
||||||
|
# Weekly report with data refresh
|
||||||
|
python scripts/weekly_report.py
|
||||||
|
|
||||||
|
# Fetch historical data (single symbol with auto correlation)
|
||||||
|
python scripts/fetch_history.py --symbol TRXUSDT --interval 15m --days 365
|
||||||
|
|
||||||
|
# Fetch historical data (explicit symbols)
|
||||||
|
python scripts/fetch_history.py --symbols XRPUSDT BTCUSDT ETHUSDT --interval 15m --days 365
|
||||||
|
|
||||||
|
# Deploy models to production
|
||||||
|
bash scripts/deploy_model.sh --symbol XRPUSDT
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**Entry point**: `main.py` → creates `Config` → shared `RiskManager` → per-symbol `TradingBot` instances → `asyncio.gather()`
|
||||||
|
|
||||||
|
**Multi-symbol architecture**: Each symbol gets its own `TradingBot` instance with independent `Exchange`, `MLFilter`, and `DataStream`. The `RiskManager` is shared as a singleton across all bots, enforcing global daily loss limits and same-direction position limits via `asyncio.Lock`.
|
||||||
|
|
||||||
|
**5-layer data flow on each 15m candle close:**
|
||||||
|
1. `src/data_stream.py` — Combined WebSocket for primary+correlation symbols, 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` — 26-feature extraction (ADX + OI 파생 피처 포함), ONNX priority > LightGBM fallback, threshold ≥ 0.55
|
||||||
|
4. `src/exchange.py` + `src/risk_manager.py` — Dynamic margin, MARKET orders with SL/TP, daily loss limit (5%), same-direction limit
|
||||||
|
5. `src/user_data_stream.py` + `src/notifier.py` — Real-time TP/SL detection via WebSocket, Discord webhooks
|
||||||
|
|
||||||
|
**Dual-layer kill switch** (per-symbol, in `src/bot.py`): Fast Kill (8 consecutive net losses) + Slow Kill (last 15 trades PF < 0.75). Trade history persisted to `data/trade_history/{symbol}.jsonl`. Blocks new entries only; existing SL/TP exits work normally. Manual reset via `RESET_KILL_SWITCH_{SYMBOL}=True` env var + restart.
|
||||||
|
|
||||||
|
**Parallel execution**: Per-symbol bots run independently via `asyncio.gather()`. Each bot's `user_data_stream` also runs in parallel.
|
||||||
|
|
||||||
|
**Model/data directories**: `models/{symbol}/` and `data/{symbol}/` for per-symbol models. Falls back to `models/` root if symbol dir doesn't exist.
|
||||||
|
|
||||||
|
## 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`, `SYMBOLS` (comma-separated, currently `XRPUSDT` only — SOL/DOGE/TRX removed due to PF < 1.0), `CORRELATION_SYMBOLS` (default `BTCUSDT,ETHUSDT`), `LEVERAGE`, `DISCORD_WEBHOOK_URL`, `MARGIN_MAX_RATIO`, `MARGIN_MIN_RATIO`, `MAX_SAME_DIRECTION` (default 2), `NO_ML_FILTER` (default `true` — ML disabled due to insufficient feature alpha).
|
||||||
|
|
||||||
|
`src/config.py` uses `@dataclass` with `__post_init__` to load and validate all env vars. Per-symbol strategy params supported via `SymbolStrategyParams` — override with `ATR_SL_MULT_{SYMBOL}`, `ATR_TP_MULT_{SYMBOL}`, `SIGNAL_THRESHOLD_{SYMBOL}`, `ADX_THRESHOLD_{SYMBOL}`, `VOL_MULTIPLIER_{SYMBOL}`. Access via `config.get_symbol_params(symbol)`.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- **Docker**: `Dockerfile` (Python 3.12-slim) + `docker-compose.yml`
|
||||||
|
- **CI/CD**: Jenkins pipeline (Gitea → Docker registry → LXC production server)
|
||||||
|
- Models stored in `models/{symbol}/`, data cache in `data/{symbol}/`, 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 | `position-monitor-logging` | Completed |
|
||||||
|
| 2026-03-03 | `adx-ml-feature-migration` (design + plan) | Completed |
|
||||||
|
| 2026-03-03 | `optuna-precision-objective-plan` | Completed |
|
||||||
|
| 2026-03-03 | `demo-1m-125x` (design + plan) | In Progress |
|
||||||
|
| 2026-03-04 | `oi-derived-features` (design + plan) | Completed |
|
||||||
|
| 2026-03-05 | `multi-symbol-trading` (design + plan) | Completed |
|
||||||
|
| 2026-03-06 | `multi-symbol-dashboard` (design + plan) | Completed |
|
||||||
|
| 2026-03-06 | `strategy-parameter-sweep` (plan) | Completed |
|
||||||
|
| 2026-03-07 | `weekly-report` (plan) | Completed |
|
||||||
|
| 2026-03-07 | `code-review-improvements` | Partial (#1,#2,#4,#5,#6,#8 완료) |
|
||||||
|
| 2026-03-19 | `critical-bugfixes` (C5,C1,C3,C8) | Completed |
|
||||||
|
| 2026-03-21 | `dashboard-code-review-r2` (#14,#19) | Completed |
|
||||||
|
| 2026-03-21 | `code-review-fixes-r2` (9 issues) | Completed |
|
||||||
|
| 2026-03-21 | `ml-pipeline-fixes` (C1,C3,I1,I3,I4,I5) | Completed |
|
||||||
|
| 2026-03-21 | `training-threshold-relaxation` (plan) | Completed |
|
||||||
|
| 2026-03-21 | `purged-gap-and-ablation` (plan) | Completed |
|
||||||
|
| 2026-03-21 | `ml-validation-result` | ML OFF > ML ON 확정, SOL/DOGE/TRX 제외, XRP 단독 운영 |
|
||||||
|
| 2026-03-21 | `ml-validation-pipeline` (plan) | Completed |
|
||||||
|
| 2026-03-22 | `backtest-market-context` (design) | 설계 완료, 구현 대기 |
|
||||||
|
| 2026-03-22 | `testnet-uds-verification` (design) | 설계 완료, 구현 대기 |
|
||||||
136
Jenkinsfile
vendored
136
Jenkinsfile
vendored
@@ -2,18 +2,19 @@ pipeline {
|
|||||||
agent any
|
agent any
|
||||||
|
|
||||||
environment {
|
environment {
|
||||||
REGISTRY = '10.1.10.28:3000'
|
REGISTRY = 'git.gihyeon.com'
|
||||||
IMAGE_NAME = 'gihyeon/cointrader'
|
IMAGE_NAME = 'gihyeon/cointrader'
|
||||||
IMAGE_TAG = "${env.BUILD_NUMBER}"
|
IMAGE_TAG = "${env.BUILD_NUMBER}"
|
||||||
FULL_IMAGE = "${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
|
FULL_IMAGE = "${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
|
||||||
LATEST_IMAGE = "${REGISTRY}/${IMAGE_NAME}:latest"
|
LATEST_IMAGE = "${REGISTRY}/${IMAGE_NAME}:latest"
|
||||||
|
|
||||||
// 젠킨스 자격 증명에 저장해둔 디스코드 웹훅 주소를 불러옵니다.
|
DASH_API_IMAGE = "${REGISTRY}/gihyeon/cointrader-dashboard-api"
|
||||||
|
DASH_UI_IMAGE = "${REGISTRY}/gihyeon/cointrader-dashboard-ui"
|
||||||
|
|
||||||
DISCORD_WEBHOOK = credentials('discord-webhook')
|
DISCORD_WEBHOOK = credentials('discord-webhook')
|
||||||
}
|
}
|
||||||
|
|
||||||
stages {
|
stages {
|
||||||
// 빌드가 시작되자마자 알림을 보냅니다.
|
|
||||||
stage('Notify Build Start') {
|
stage('Notify Build Start') {
|
||||||
steps {
|
steps {
|
||||||
sh """
|
sh """
|
||||||
@@ -29,13 +30,63 @@ pipeline {
|
|||||||
steps {
|
steps {
|
||||||
git branch: 'main',
|
git branch: 'main',
|
||||||
credentialsId: 'gitea-cred',
|
credentialsId: 'gitea-cred',
|
||||||
url: 'http://10.1.10.28:3000/gihyeon/cointrader.git'
|
url: 'https://git.gihyeon.com/gihyeon/cointrader.git'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Build Docker Image') {
|
stage('Detect Changes') {
|
||||||
steps {
|
steps {
|
||||||
sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ."
|
script {
|
||||||
|
// 이전 성공 빌드 커밋과 비교 (없으면 HEAD~5 fallback)
|
||||||
|
def baseCommit = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT ?: sh(script: 'git rev-parse HEAD~5 2>/dev/null || echo ""', returnStdout: true).trim()
|
||||||
|
def diffCmd = baseCommit ? "git diff --name-only ${baseCommit}..HEAD" : 'git diff --name-only HEAD~1'
|
||||||
|
def changes = sh(script: "${diffCmd} || echo \"ALL\"", returnStdout: true).trim()
|
||||||
|
echo "Base commit: ${baseCommit ?: 'HEAD~1 (fallback)'}"
|
||||||
|
echo "Changed files:\n${changes}"
|
||||||
|
|
||||||
|
if (changes == 'ALL') {
|
||||||
|
// 첫 빌드이거나 diff 실패 시 전체 빌드
|
||||||
|
env.BOT_CHANGED = 'true'
|
||||||
|
env.DASH_API_CHANGED = 'true'
|
||||||
|
env.DASH_UI_CHANGED = 'true'
|
||||||
|
} else {
|
||||||
|
env.BOT_CHANGED = (changes =~ /(?m)^(src\/|scripts\/|main\.py|requirements\.txt|Dockerfile)/).find() ? 'true' : 'false'
|
||||||
|
env.DASH_API_CHANGED = (changes =~ /(?m)^dashboard\/api\//).find() ? 'true' : 'false'
|
||||||
|
env.DASH_UI_CHANGED = (changes =~ /(?m)^dashboard\/ui\//).find() ? 'true' : 'false'
|
||||||
|
}
|
||||||
|
|
||||||
|
// docker-compose.yml 변경 시에도 배포 필요
|
||||||
|
if (changes.contains('docker-compose.yml') || changes.contains('Jenkinsfile')) {
|
||||||
|
env.COMPOSE_CHANGED = 'true'
|
||||||
|
} else {
|
||||||
|
env.COMPOSE_CHANGED = 'false'
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "BOT_CHANGED=${env.BOT_CHANGED}, DASH_API_CHANGED=${env.DASH_API_CHANGED}, DASH_UI_CHANGED=${env.DASH_UI_CHANGED}, COMPOSE_CHANGED=${env.COMPOSE_CHANGED}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Build Docker Images') {
|
||||||
|
parallel {
|
||||||
|
stage('Bot') {
|
||||||
|
when { expression { env.BOT_CHANGED == 'true' } }
|
||||||
|
steps {
|
||||||
|
sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Dashboard API') {
|
||||||
|
when { expression { env.DASH_API_CHANGED == 'true' } }
|
||||||
|
steps {
|
||||||
|
sh "docker build -t ${DASH_API_IMAGE}:${IMAGE_TAG} -t ${DASH_API_IMAGE}:latest ./dashboard/api"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Dashboard UI') {
|
||||||
|
when { expression { env.DASH_UI_CHANGED == 'true' } }
|
||||||
|
steps {
|
||||||
|
sh "docker build -t ${DASH_UI_IMAGE}:${IMAGE_TAG} -t ${DASH_UI_IMAGE}:latest ./dashboard/ui"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,41 +94,80 @@ pipeline {
|
|||||||
steps {
|
steps {
|
||||||
withCredentials([usernamePassword(credentialsId: 'gitea-registry-cred', passwordVariable: 'GITEA_TOKEN', usernameVariable: 'GITEA_USER')]) {
|
withCredentials([usernamePassword(credentialsId: 'gitea-registry-cred', passwordVariable: 'GITEA_TOKEN', usernameVariable: 'GITEA_USER')]) {
|
||||||
sh "echo \$GITEA_TOKEN | docker login ${REGISTRY} -u \$GITEA_USER --password-stdin"
|
sh "echo \$GITEA_TOKEN | docker login ${REGISTRY} -u \$GITEA_USER --password-stdin"
|
||||||
sh "docker push ${FULL_IMAGE}"
|
script {
|
||||||
sh "docker push ${LATEST_IMAGE}"
|
if (env.BOT_CHANGED == 'true') {
|
||||||
|
sh "docker push ${FULL_IMAGE}"
|
||||||
|
sh "docker push ${LATEST_IMAGE}"
|
||||||
|
}
|
||||||
|
if (env.DASH_API_CHANGED == 'true') {
|
||||||
|
sh "docker push ${DASH_API_IMAGE}:${IMAGE_TAG}"
|
||||||
|
sh "docker push ${DASH_API_IMAGE}:latest"
|
||||||
|
}
|
||||||
|
if (env.DASH_UI_CHANGED == 'true') {
|
||||||
|
sh "docker push ${DASH_UI_IMAGE}:${IMAGE_TAG}"
|
||||||
|
sh "docker push ${DASH_UI_IMAGE}:latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Deploy to Prod LXC') {
|
stage('Deploy to Prod LXC') {
|
||||||
steps {
|
steps {
|
||||||
sh 'ssh root@10.1.10.24 "mkdir -p /root/cointrader"'
|
script {
|
||||||
sh 'scp docker-compose.yml root@10.1.10.24:/root/cointrader/'
|
// docker-compose.yml이 변경되었으면 항상 전송
|
||||||
sh '''
|
if (env.COMPOSE_CHANGED == 'true') {
|
||||||
ssh root@10.1.10.24 "cd /root/cointrader/ && \
|
sh 'ssh root@10.1.10.24 "mkdir -p /root/cointrader"'
|
||||||
docker compose down && \
|
sh 'scp docker-compose.yml root@10.1.10.24:/root/cointrader/'
|
||||||
docker compose pull && \
|
}
|
||||||
docker compose up -d"
|
|
||||||
'''
|
// 변경된 서비스만 pull & recreate (나머지는 중단 없음)
|
||||||
|
def services = []
|
||||||
|
if (env.BOT_CHANGED == 'true') {
|
||||||
|
services.add('cointrader')
|
||||||
|
services.add('ls-ratio-collector')
|
||||||
|
}
|
||||||
|
if (env.DASH_API_CHANGED == 'true') services.add('dashboard-api')
|
||||||
|
if (env.DASH_UI_CHANGED == 'true') services.add('dashboard-ui')
|
||||||
|
|
||||||
|
if (env.COMPOSE_CHANGED == 'true' && services.isEmpty()) {
|
||||||
|
// compose만 변경된 경우 전체 재시작
|
||||||
|
sh 'ssh root@10.1.10.24 "cd /root/cointrader/ && docker compose up -d"'
|
||||||
|
} else if (!services.isEmpty()) {
|
||||||
|
def svcList = services.join(' ')
|
||||||
|
sh "ssh root@10.1.10.24 \"cd /root/cointrader/ && docker compose pull ${svcList} && docker compose up -d ${svcList}\""
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Cleanup') {
|
stage('Cleanup') {
|
||||||
steps {
|
steps {
|
||||||
sh "docker rmi ${FULL_IMAGE} || true"
|
script {
|
||||||
sh "docker rmi ${LATEST_IMAGE} || true"
|
if (env.BOT_CHANGED == 'true') {
|
||||||
|
sh "docker rmi ${FULL_IMAGE} || true"
|
||||||
|
sh "docker rmi ${LATEST_IMAGE} || true"
|
||||||
|
}
|
||||||
|
if (env.DASH_API_CHANGED == 'true') {
|
||||||
|
sh "docker rmi ${DASH_API_IMAGE}:${IMAGE_TAG} || true"
|
||||||
|
sh "docker rmi ${DASH_API_IMAGE}:latest || true"
|
||||||
|
}
|
||||||
|
if (env.DASH_UI_CHANGED == 'true') {
|
||||||
|
sh "docker rmi ${DASH_UI_IMAGE}:${IMAGE_TAG} || true"
|
||||||
|
sh "docker rmi ${DASH_UI_IMAGE}:latest || true"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파이프라인 결과에 따른 디스코드 알림
|
|
||||||
post {
|
post {
|
||||||
success {
|
success {
|
||||||
echo "Build #${env.BUILD_NUMBER} 성공: ${FULL_IMAGE} → 운영 LXC(10.1.10.24) 배포 완료"
|
echo "Build #${env.BUILD_NUMBER} 성공"
|
||||||
sh """
|
sh """
|
||||||
curl -H "Content-Type: application/json" \
|
curl -H "Content-Type: application/json" \
|
||||||
-X POST \
|
-X POST \
|
||||||
-d '{"content": "✅ **[배포 성공]** `cointrader` (Build #${env.BUILD_NUMBER}) 운영 서버(10.1.10.24) 배포 완료!\\n- 📦 이미지: `${FULL_IMAGE}`"}' \
|
-d '{"content": "✅ **[배포 성공]** `cointrader` (Build #${env.BUILD_NUMBER}) 운영 서버(10.1.10.24) 배포 완료!\\n- 🤖 봇: ${env.BOT_CHANGED}\\n- 📊 API: ${env.DASH_API_CHANGED}\\n- 🖥️ UI: ${env.DASH_UI_CHANGED}"}' \
|
||||||
${DISCORD_WEBHOOK}
|
${DISCORD_WEBHOOK}
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
@@ -91,4 +181,4 @@ pipeline {
|
|||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
496
README.md
496
README.md
@@ -1,132 +1,106 @@
|
|||||||
# CoinTrader
|
# CoinTrader
|
||||||
|
|
||||||
Binance Futures 자동매매 봇. 복합 기술 지표와 LightGBM ML 필터를 결합하여 XRPUSDT(기본) 선물 포지션을 자동으로 진입·청산하며, Discord로 실시간 알림을 전송합니다.
|
Binance Futures 자동매매 봇. 복합 기술 지표와 킬스위치로 XRPUSDT 선물 포지션을 자동 진입·청산하며, Discord로 실시간 알림을 전송합니다. 멀티심볼 아키텍처를 지원하지만, 현재 XRP만 운영 중입니다.
|
||||||
|
|
||||||
|
> **심볼 운영 이력**: SOL, DOGE, TRX는 파라미터 스윕에서 모든 ADX 수준에서 PF < 1.0으로, 현재 전략으로는 수익을 낼 수 없어 제외되었습니다 (2026-03-21). ML 필터도 기술 지표 기반 피처의 예측력 한계로 비활성화 상태 (`NO_ML_FILTER=true`).
|
||||||
|
|
||||||
|
> **이 봇은 실제 자산을 거래합니다.** 운영 전 반드시 Binance Testnet에서 충분히 검증하세요.
|
||||||
|
> 과거 수익이 미래 수익을 보장하지 않습니다. 투자 손실에 대한 책임은 사용자 본인에게 있습니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 주요 기능
|
## 주요 기능
|
||||||
|
|
||||||
- **복합 기술 지표 신호**: RSI, MACD 크로스, 볼린저 밴드, EMA 정/역배열, Stochastic RSI, 거래량 급증 — 3개 이상 일치 시 진입
|
- **멀티심볼 동시 거래**: 심볼별 독립 봇 인스턴스를 병렬 실행, 공유 RiskManager로 글로벌 리스크 관리
|
||||||
- **ML 필터 (LightGBM)**: 기술 지표 신호를 한 번 더 검증하여 오진입 차단 (모델 없으면 자동 폴백)
|
- **복합 기술 지표 신호**: RSI, MACD, 볼린저 밴드, EMA, Stochastic RSI, ADX, 거래량 급증 — 가중치 합산 시스템
|
||||||
- **ATR 기반 손절/익절**: 변동성에 따라 동적으로 SL/TP 계산 (1.5× / 3.0× ATR)
|
- **ML 필터 (선택)**: LightGBM / ONNX 모델로 오진입 차단 (비활성화 가능)
|
||||||
- **리스크 관리**: 트레이드당 리스크 비율, 최대 포지션 수, 일일 손실 한도 제어
|
- **ATR 기반 손절/익절**: 변동성에 따라 동적으로 SL/TP 계산, 환경변수로 배수 조절
|
||||||
- **포지션 복구**: 봇 재시작 시 기존 포지션 자동 감지 및 상태 복원
|
- **반대 시그널 재진입**: 보유 포지션과 반대 신호 발생 시 즉시 청산 후 재진입
|
||||||
- **Discord 알림**: 진입·청산·오류 이벤트 실시간 웹훅 알림
|
- **리스크 관리**: 동일 방향 포지션 제한, 일일 손실 한도(5%), 동적 증거금 비율
|
||||||
- **CI/CD**: Jenkins + Gitea Container Registry 기반 Docker 이미지 자동 빌드·배포
|
- **듀얼 레이어 킬스위치**: Fast Kill(8연속 순손실) + Slow Kill(15거래 PF<0.75) — 심볼별 독립 차단, 기존 포지션 청산은 정상 작동
|
||||||
|
- **SL/TP 원자성 보장**: SL/TP 배치 3회 재시도 + 최종 실패 시 긴급 시장가 청산
|
||||||
|
- **실시간 TP/SL 감지**: Binance User Data Stream으로 즉시 감지
|
||||||
|
- **Graceful Shutdown**: SIGTERM/SIGINT 시 심볼별 오픈 주문 취소 후 정상 종료
|
||||||
|
- **Discord 알림**: 진입·청산·킬스위치 발동·긴급 청산·오류 이벤트 실시간 웹훅 알림
|
||||||
|
- **모니터링 대시보드**: 거래 내역, 수익 통계, 차트를 웹에서 조회
|
||||||
|
- **주간 전략 리포트**: 자동 성능 측정, 추이 추적, 킬스위치 모니터링, ML 재학습 시점 판단
|
||||||
|
- **종목 비교 분석**: 심볼별 파라미터 sweep + Robust Monte Carlo 포지션 사이징
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 프로젝트 구조
|
# 봇 사용 가이드
|
||||||
|
|
||||||
```
|
봇을 설치하고 운영하려는 사용자를 위한 섹션입니다.
|
||||||
cointrader/
|
|
||||||
├── main.py # 진입점
|
|
||||||
├── src/
|
|
||||||
│ ├── bot.py # 메인 트레이딩 루프
|
|
||||||
│ ├── config.py # 환경변수 기반 설정
|
|
||||||
│ ├── exchange.py # Binance Futures API 클라이언트
|
|
||||||
│ ├── data_stream.py # WebSocket 1분봉 스트림
|
|
||||||
│ ├── indicators.py # 기술 지표 계산 및 신호 생성
|
|
||||||
│ ├── ml_filter.py # LightGBM 진입 필터
|
|
||||||
│ ├── ml_features.py # ML 피처 빌더
|
|
||||||
│ ├── label_builder.py # 학습 레이블 생성
|
|
||||||
│ ├── dataset_builder.py # 벡터화 데이터셋 빌더 (학습용)
|
|
||||||
│ ├── risk_manager.py # 리스크 관리
|
|
||||||
│ ├── notifier.py # Discord 웹훅 알림
|
|
||||||
│ └── logger_setup.py # Loguru 로거 설정
|
|
||||||
├── scripts/
|
|
||||||
│ ├── fetch_history.py # 과거 데이터 수집
|
|
||||||
│ └── train_model.py # ML 모델 수동 학습
|
|
||||||
├── models/ # 학습된 모델 저장 (.pkl)
|
|
||||||
├── data/ # 과거 데이터 캐시
|
|
||||||
├── logs/ # 로그 파일
|
|
||||||
├── tests/ # 테스트 코드
|
|
||||||
├── Dockerfile
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── Jenkinsfile
|
|
||||||
└── requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
## 요구사항
|
||||||
|
|
||||||
|
- Python 3.11+ (또는 Docker)
|
||||||
|
- Binance Futures 계정 + API 키
|
||||||
|
- (선택) Discord 웹훅 URL
|
||||||
|
|
||||||
## 빠른 시작
|
## 빠른 시작
|
||||||
|
|
||||||
### 1. 환경변수 설정
|
### 1. 환경변수 설정
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd cointrader
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
`.env` 파일을 열어 아래 값을 채웁니다.
|
`.env` 파일을 열어 아래 필수 값을 채웁니다.
|
||||||
|
|
||||||
```env
|
```env
|
||||||
|
# 필수
|
||||||
BINANCE_API_KEY=your_api_key
|
BINANCE_API_KEY=your_api_key
|
||||||
BINANCE_API_SECRET=your_api_secret
|
BINANCE_API_SECRET=your_api_secret
|
||||||
SYMBOL=XRPUSDT
|
SYMBOLS=XRPUSDT # 거래할 심볼 (쉼표 구분, 멀티심볼 지원)
|
||||||
LEVERAGE=10
|
|
||||||
RISK_PER_TRADE=0.02
|
# 권장
|
||||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||||
|
LEVERAGE=10
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 로컬 실행
|
> 처음 사용 시 Binance Testnet에서 먼저 테스트하는 것을 권장합니다. `BINANCE_TESTNET_API_KEY`와 `BINANCE_TESTNET_API_SECRET`을 설정하세요.
|
||||||
|
|
||||||
```bash
|
### 2-A. Docker로 실행 (권장)
|
||||||
pip install -r requirements.txt
|
|
||||||
python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Docker Compose로 실행
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
로그 확인:
|
로그 확인:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs -f cointrader
|
docker compose logs -f cointrader
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### 2-B. 로컬 실행
|
||||||
|
|
||||||
## ML 모델 학습
|
|
||||||
|
|
||||||
봇은 모델 파일(`models/lgbm_filter.pkl`)이 없으면 ML 필터 없이 동작합니다. 최초 실행 전 또는 수동 재학습 시 아래 순서로 진행합니다.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 과거 데이터 수집
|
pip install -r requirements.txt
|
||||||
python scripts/fetch_history.py
|
python main.py
|
||||||
|
|
||||||
# 2. 모델 학습 (LightGBM, CPU)
|
|
||||||
python scripts/train_model.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
학습된 모델은 `models/lgbm_filter.pkl`에 저장됩니다. 재학습이 필요하면 맥미니에서 위 스크립트를 다시 실행하고 모델 파일을 컨테이너에 배포합니다.
|
### 3. 정상 동작 확인
|
||||||
|
|
||||||
### Apple Silicon GPU 가속 학습 (M1/M2/M3/M4)
|
봇이 정상 실행되면 다음과 같은 로그가 출력됩니다:
|
||||||
|
|
||||||
M 시리즈 맥에서는 MLX를 사용해 통합 GPU(Metal)로 학습할 수 있습니다.
|
```
|
||||||
|
INFO | 기준 잔고 설정: 1000.00 USDT
|
||||||
> **설치**: `mlx`는 Apple Silicon 전용이며 `requirements.txt`에 포함되지 않습니다.
|
INFO | [XRPUSDT] 봇 시작, 레버리지 10x | SL=2.0x TP=2.0x Signal≥3 ADX≥25.0 Vol≥2.5x
|
||||||
> 맥미니에서 별도 설치: `pip install mlx`
|
INFO | [XRPUSDT] 기존 포지션 없음 - 신규 진입 대기
|
||||||
|
INFO | [XRPUSDT] OI 히스토리 초기화: 5개
|
||||||
```bash
|
INFO | Kline WebSocket 연결 완료
|
||||||
# MLX 별도 설치 (맥미니 전용)
|
|
||||||
pip install mlx
|
|
||||||
|
|
||||||
# MLX 신경망 필터 학습 (GPU 자동 사용)
|
|
||||||
python scripts/train_mlx_model.py
|
|
||||||
|
|
||||||
# train_and_deploy.sh에서 MLX 백엔드 사용
|
|
||||||
TRAIN_BACKEND=mlx bash scripts/train_and_deploy.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> **참고**: LightGBM은 Apple Silicon GPU를 공식 지원하지 않습니다. MLX는 Apple이 만든 ML 프레임워크로 통합 GPU를 자동으로 활용합니다. Neural Engine(NPU)은 Apple 내부 전용으로 Python에서 직접 제어할 수 없습니다.
|
Discord 웹훅을 설정했다면 진입/청산 시 실시간 알림을 받게 됩니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 매매 전략
|
## 매매 전략
|
||||||
|
|
||||||
|
### 기술 지표 신호 (15분봉)
|
||||||
|
|
||||||
| 지표 | 롱 조건 | 숏 조건 | 가중치 |
|
| 지표 | 롱 조건 | 숏 조건 | 가중치 |
|
||||||
|------|---------|---------|--------|
|
|------|---------|---------|--------|
|
||||||
| RSI (14) | < 35 | > 65 | 1 |
|
| RSI (14) | < 35 | > 65 | 1 |
|
||||||
@@ -134,54 +108,364 @@ TRAIN_BACKEND=mlx bash scripts/train_and_deploy.sh
|
|||||||
| 볼린저 밴드 | 하단 이탈 | 상단 돌파 | 1 |
|
| 볼린저 밴드 | 하단 이탈 | 상단 돌파 | 1 |
|
||||||
| EMA 정배열 (9/21/50) | 정배열 | 역배열 | 1 |
|
| EMA 정배열 (9/21/50) | 정배열 | 역배열 | 1 |
|
||||||
| Stochastic RSI | < 20 + K>D | > 80 + K<D | 1 |
|
| Stochastic RSI | < 20 + K>D | > 80 + K<D | 1 |
|
||||||
| 거래량 | 20MA × 1.5 이상 시 신호 강화 | — | 보조 |
|
| 거래량 | 20MA × `VOL_MULTIPLIER` 이상 시 신호 강화 | — | 보조 |
|
||||||
|
|
||||||
**진입 조건**: 가중치 합계 ≥ 3 + (거래량 급증 또는 가중치 합계 ≥ 4)
|
**진입 조건**: 가중치 합계 ≥ `SIGNAL_THRESHOLD` + (거래량 급증 또는 가중치 합계 ≥ `SIGNAL_THRESHOLD` + 1)
|
||||||
**손절/익절**: ATR × 1.5 / ATR × 3.0 (리스크:리워드 = 1:2)
|
**ADX 필터**: ADX < `ADX_THRESHOLD` 시 횡보장으로 판단, 진입 차단
|
||||||
**ML 필터**: LightGBM 예측 확률 ≥ 0.60 이어야 최종 진입
|
**손절/익절**: ATR × `ATR_SL_MULT` / ATR × `ATR_TP_MULT`
|
||||||
|
|
||||||
---
|
### 전략 파라미터 조절
|
||||||
|
|
||||||
## CI/CD
|
환경변수로 전략 파라미터를 조절할 수 있습니다. 기본값은 Walk-Forward 백테스트 스윕 결과에서 선정된 값입니다.
|
||||||
|
|
||||||
`main` 브랜치에 푸시하면 Jenkins 파이프라인이 자동으로 실행됩니다.
|
**전역 기본값** (심볼별 오버라이드 없을 때 적용):
|
||||||
|
|
||||||
1. **Checkout** — 소스 체크아웃
|
| 환경변수 | 기본값 | 설명 |
|
||||||
2. **Build Image** — Docker 이미지 빌드 (`:{BUILD_NUMBER}` + `:latest` 태그)
|
|---------|--------|------|
|
||||||
3. **Push** — Gitea Container Registry(`10.1.10.28:3000`)에 푸시
|
| `ATR_SL_MULT` | `2.0` | 손절 ATR 배수 |
|
||||||
4. **Cleanup** — 로컬 이미지 정리
|
| `ATR_TP_MULT` | `2.0` | 익절 ATR 배수 |
|
||||||
|
| `SIGNAL_THRESHOLD` | `3` | 진입을 위한 최소 가중치 점수 |
|
||||||
|
| `ADX_THRESHOLD` | `25` | ADX 횡보장 필터 (0=비활성) |
|
||||||
|
| `VOL_MULTIPLIER` | `2.5` | 거래량 급증 감지 배수 |
|
||||||
|
|
||||||
배포 서버에서 최신 이미지를 반영하려면:
|
**심볼별 오버라이드**: `{환경변수}_{심볼}` 형태로 심볼마다 독립 설정 가능. 미설정 시 전역 기본값 사용.
|
||||||
|
|
||||||
```bash
|
```env
|
||||||
docker compose pull && docker compose up -d
|
# 현재 운영 설정 (2026-03-21)
|
||||||
|
ATR_SL_MULT_XRPUSDT=1.5
|
||||||
|
ATR_TP_MULT_XRPUSDT=4.0
|
||||||
|
ADX_THRESHOLD_XRPUSDT=25
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **제외된 심볼**: SOLUSDT(PF 0.00~0.83), DOGEUSDT(PF 0.70~0.83), TRXUSDT(PF 0.08) — 모든 파라미터 조합에서 PF < 1.0.
|
||||||
|
|
||||||
|
### ML 필터
|
||||||
|
|
||||||
|
ML 필터는 기술 지표 신호를 한 번 더 검증하여 오진입을 차단합니다. 기본적으로 **비활성화** 상태입니다.
|
||||||
|
|
||||||
|
- `NO_ML_FILTER=true` (기본값) — ML 없이 기술 지표만으로 운영
|
||||||
|
- `NO_ML_FILTER=false` — ML 필터 활성화 (모델 파일 필요)
|
||||||
|
|
||||||
|
> **비활성화 이유 (2026-03-21)**: Walk-Forward 백테스트에서 ML ON이 ML OFF보다 오히려 PF가 낮았습니다 (XRP: ML OFF PF 1.16 vs ML ON PF 0.71). Feature ablation 분석 결과, 모델 예측력의 대부분이 signal_strength/side 피처에 의존하며 (A→C AUC 드롭 0.08~0.09), 기술 지표 z-score만으로는 수수료를 이기는 알파를 만들 수 없었습니다. 오더북/청산 데이터 등 새로운 피처 소스에서 알파가 확인되면 재활성화 예정.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 리스크 관리
|
||||||
|
|
||||||
|
| 설정 | 기본값 | 설명 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `LEVERAGE` | `10` | 레버리지 배수 |
|
||||||
|
| `MAX_SAME_DIRECTION` | `2` | 동일 방향 최대 포지션 수 |
|
||||||
|
| `MARGIN_MAX_RATIO` | `0.50` | 최대 증거금 비율 (잔고 대비) |
|
||||||
|
| `MARGIN_MIN_RATIO` | `0.20` | 최소 증거금 비율 (잔고 대비) |
|
||||||
|
| `MARGIN_DECAY_RATE` | `0.0006` | 잔고 증가 시 증거금 비율 감소 속도 |
|
||||||
|
|
||||||
|
- **일일 손실 한도**: 기준 잔고의 5% 초과 시 당일 거래 중단 (단일 충격 방어)
|
||||||
|
- **듀얼 레이어 킬스위치**: 구조적 엣지 소실에 의한 점진적 계좌 우하향(Slow Bleed) 방어
|
||||||
|
- **동적 증거금**: 잔고가 늘어날수록 비율을 선형으로 줄여 과노출 방지
|
||||||
|
- **포지션 복구**: 봇 재시작 시 기존 포지션 자동 감지 및 상태 복원
|
||||||
|
|
||||||
|
### 킬스위치
|
||||||
|
|
||||||
|
일일 손실 한도는 단일 충격 방어용이지, 누적 승률 하락 방어용이 아닙니다. 매일 한도 근처까지 손실을 내고 멈추는 패턴이 반복되면 한 달 뒤 계좌의 30~40%가 조용히 증발합니다. 킬스위치는 이 Slow Bleed를 자동으로 차단합니다.
|
||||||
|
|
||||||
|
| 레이어 | 조건 | 방어 대상 |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| **Fast Kill** | 8연속 순손실 (net_pnl, 수수료 포함) | 급격한 전략 붕괴 |
|
||||||
|
| **Slow Kill** | 최근 15거래 Profit Factor < 0.75 | 점진적 엣지 소실 |
|
||||||
|
|
||||||
|
**동작 방식:**
|
||||||
|
- 심볼별 독립 제어: 한 심볼이 킬되어도 다른 심볼은 정상 운영
|
||||||
|
- 진입만 차단: 기존 포지션의 SL/TP 청산은 정상 작동 (물린 상태 방치 방지)
|
||||||
|
- 거래 이력 persist: `data/trade_history/{symbol}.jsonl`에 매 청산마다 기록
|
||||||
|
- 봇 재시작 시 소급 검증: 이력 파일에서 마지막 15건을 읽어 킬스위치 상태 복원
|
||||||
|
- 수동 해제: `.env`에 `RESET_KILL_SWITCH_{SYMBOL}=True` 추가 후 봇 재시작
|
||||||
|
|
||||||
|
**주간 리포트 모니터링:**
|
||||||
|
```
|
||||||
|
[킬스위치 모니터링]
|
||||||
|
XRP: 연속손실 2/8 | 15거래PF 1.42
|
||||||
|
```
|
||||||
|
|
||||||
|
| 환경변수 | 설명 |
|
||||||
|
|---------|------|
|
||||||
|
| `RESET_KILL_SWITCH_{SYMBOL}` | `True`로 설정 후 재시작하면 해당 심볼 킬스위치 해제. 해제 후 반드시 제거할 것 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대시보드
|
||||||
|
|
||||||
|
봇 로그를 실시간으로 파싱하여 거래 내역, 수익 통계, 차트를 웹에서 조회할 수 있습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
# 접속: http://<서버IP>:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
| 탭 | 내용 |
|
||||||
|
|----|------|
|
||||||
|
| **Overview** | 총 수익, 승률, 거래 수, 최대 수익/손실 KPI + 일별 PnL 차트 + 누적 수익 곡선 |
|
||||||
|
| **Trades** | 전체 거래 내역 — 진입/청산가, 방향, 레버리지, 기술 지표, SL/TP, 순익 상세 |
|
||||||
|
| **Chart** | 15분봉 가격 차트 + RSI 지표 + ADX 추세 강도 |
|
||||||
|
|
||||||
|
### API 엔드포인트
|
||||||
|
|
||||||
|
| 엔드포인트 | 설명 |
|
||||||
|
|-----------|------|
|
||||||
|
| `GET /api/position` | 현재 포지션 + 봇 상태 |
|
||||||
|
| `GET /api/trades` | 청산 거래 내역 (페이지네이션) |
|
||||||
|
| `GET /api/daily` | 일별 PnL 집계 |
|
||||||
|
| `GET /api/stats` | 전체 통계 (총 거래, 승률, 수수료 등) |
|
||||||
|
| `GET /api/candles` | 최근 캔들 + 기술 지표 |
|
||||||
|
| `GET /api/health` | 헬스 체크 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 환경변수 전체 레퍼런스
|
||||||
|
|
||||||
|
| 변수 | 기본값 | 필수 | 설명 |
|
||||||
|
|------|--------|:----:|------|
|
||||||
|
| `BINANCE_API_KEY` | — | ✅ | Binance API 키 |
|
||||||
|
| `BINANCE_API_SECRET` | — | ✅ | Binance API 시크릿 |
|
||||||
|
| `SYMBOLS` | `XRPUSDT` | | 거래 심볼 목록 (쉼표 구분) |
|
||||||
|
| `CORRELATION_SYMBOLS` | `BTCUSDT,ETHUSDT` | | 상관관계 심볼 (BTC/ETH 피처용) |
|
||||||
|
| `LEVERAGE` | `10` | | 레버리지 배수 |
|
||||||
|
| `MAX_SAME_DIRECTION` | `2` | | 동일 방향 최대 포지션 수 |
|
||||||
|
| `DISCORD_WEBHOOK_URL` | — | | Discord 웹훅 URL |
|
||||||
|
| `MARGIN_MAX_RATIO` | `0.50` | | 최대 증거금 비율 |
|
||||||
|
| `MARGIN_MIN_RATIO` | `0.20` | | 최소 증거금 비율 |
|
||||||
|
| `MARGIN_DECAY_RATE` | `0.0006` | | 잔고 증가 시 감소 속도 |
|
||||||
|
| `NO_ML_FILTER` | `true` | | ML 필터 비활성화 |
|
||||||
|
| `ML_THRESHOLD` | `0.55` | | ML 예측 확률 임계값 |
|
||||||
|
| `ATR_SL_MULT` | `2.0` | | 손절 ATR 배수 (전역 기본값) |
|
||||||
|
| `ATR_TP_MULT` | `2.0` | | 익절 ATR 배수 (전역 기본값) |
|
||||||
|
| `SIGNAL_THRESHOLD` | `3` | | 최소 가중치 점수 (전역 기본값) |
|
||||||
|
| `ADX_THRESHOLD` | `25` | | ADX 횡보장 필터 (전역 기본값, 0=비활성) |
|
||||||
|
| `VOL_MULTIPLIER` | `2.5` | | 거래량 급증 배수 (전역 기본값) |
|
||||||
|
| `ATR_SL_MULT_{SYMBOL}` | — | | 심볼별 손절 ATR 배수 오버라이드 |
|
||||||
|
| `ATR_TP_MULT_{SYMBOL}` | — | | 심볼별 익절 ATR 배수 오버라이드 |
|
||||||
|
| `SIGNAL_THRESHOLD_{SYMBOL}` | — | | 심볼별 최소 가중치 점수 오버라이드 |
|
||||||
|
| `ADX_THRESHOLD_{SYMBOL}` | — | | 심볼별 ADX 필터 오버라이드 |
|
||||||
|
| `VOL_MULTIPLIER_{SYMBOL}` | — | | 심볼별 거래량 배수 오버라이드 |
|
||||||
|
| `DASHBOARD_API_URL` | `http://10.1.10.24:8000` | | 대시보드 API 주소 (주간 리포트용) |
|
||||||
|
| `MARGIN_MAX_RATIO_{SYMBOL}` | — | | 심볼별 최대 증거금 비율 오버라이드 |
|
||||||
|
| `RESET_KILL_SWITCH_{SYMBOL}` | — | | `True`로 설정 후 재시작하면 킬스위치 해제 (해제 후 반드시 제거) |
|
||||||
|
| `BINANCE_TESTNET_API_KEY` | — | | Testnet API 키 |
|
||||||
|
| `BINANCE_TESTNET_API_SECRET` | — | | Testnet API 시크릿 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 개발 가이드
|
||||||
|
|
||||||
|
코드를 수정하거나 기능을 추가하려는 개발자를 위한 섹션입니다.
|
||||||
|
|
||||||
|
> **아키텍처 문서**: 5-레이어 구조, 데이터 흐름, MLOps 파이프라인, 동작 시나리오를 상세히 설명한 [ARCHITECTURE.md](./ARCHITECTURE.md)를 참고하세요.
|
||||||
|
|
||||||
|
## 프로젝트 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
cointrader/
|
||||||
|
├── main.py # 진입점 (심볼별 봇 인스턴스 생성 + asyncio.gather)
|
||||||
|
├── src/
|
||||||
|
│ ├── bot.py # 메인 트레이딩 루프 (심볼별 독립 인스턴스)
|
||||||
|
│ ├── config.py # 환경변수 기반 설정 (symbols 리스트 지원)
|
||||||
|
│ ├── exchange.py # Binance Futures API 클라이언트 (심볼별 독립)
|
||||||
|
│ ├── data_stream.py # WebSocket 15분봉 멀티심볼 스트림
|
||||||
|
│ ├── indicators.py # 기술 지표 계산 및 신호 생성
|
||||||
|
│ ├── ml_filter.py # ML 필터 (ONNX 우선 / LightGBM 폴백 / 핫리로드)
|
||||||
|
│ ├── ml_features.py # ML 피처 빌더 (26개 피처)
|
||||||
|
│ ├── mlx_filter.py # MLX 신경망 필터 (Apple Silicon GPU 학습 + ONNX export)
|
||||||
|
│ ├── label_builder.py # 학습 레이블 생성
|
||||||
|
│ ├── dataset_builder.py # 벡터화 데이터셋 빌더 (학습용)
|
||||||
|
│ ├── backtester.py # 백테스트 엔진 (단일 + Walk-Forward)
|
||||||
|
│ ├── risk_manager.py # 공유 리스크 관리 (asyncio.Lock, 동일 방향 제한)
|
||||||
|
│ ├── notifier.py # Discord 웹훅 알림
|
||||||
|
│ └── logger_setup.py # Loguru 로거 설정
|
||||||
|
├── scripts/
|
||||||
|
│ ├── fetch_history.py # 과거 데이터 수집 (--symbol 단일 / --symbols 다중)
|
||||||
|
│ ├── train_model.py # LightGBM 모델 학습 (--symbol 지원)
|
||||||
|
│ ├── train_mlx_model.py # MLX 신경망 학습 (Apple Silicon GPU)
|
||||||
|
│ ├── train_and_deploy.sh # 전체 파이프라인 (--symbol / --all 지원)
|
||||||
|
│ ├── tune_hyperparams.py # Optuna 하이퍼파라미터 자동 탐색 (--symbol 지원)
|
||||||
|
│ ├── strategy_sweep.py # 전략 파라미터 그리드 스윕 (324개 조합)
|
||||||
|
│ ├── compare_symbols.py # 종목 비교 백테스트 (심볼별 파라미터 sweep)
|
||||||
|
│ ├── position_sizing_analysis.py # Robust Monte Carlo 포지션 사이징 분석
|
||||||
|
│ ├── weekly_report.py # 주간 전략 리포트 (백테스트+킬스위치+대시보드API+추이+Discord)
|
||||||
|
│ ├── run_backtest.py # 단일 백테스트 CLI
|
||||||
|
│ ├── deploy_model.sh # 모델 파일 LXC 서버 전송 (--symbol 지원)
|
||||||
|
│ └── run_tests.sh # 전체 테스트 실행
|
||||||
|
├── dashboard/
|
||||||
|
│ ├── api/ # FastAPI 백엔드 (로그 파서 + REST API)
|
||||||
|
│ └── ui/ # React 프론트엔드 (Vite + Recharts)
|
||||||
|
├── models/ # 학습된 모델 저장 (심볼별 하위 디렉토리)
|
||||||
|
├── data/ # 과거 데이터 캐시 (심볼별 하위 디렉토리)
|
||||||
|
│ └── trade_history/ # 킬스위치용 실전 거래 이력 (심볼별 JSONL)
|
||||||
|
├── results/
|
||||||
|
│ └── weekly/ # 주간 리포트 JSON 저장
|
||||||
|
├── logs/ # 로그 파일
|
||||||
|
├── docs/plans/ # 설계 문서 및 구현 플랜
|
||||||
|
├── tests/ # 테스트 코드 (15파일, 138개 케이스)
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── Jenkinsfile
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 개발 환경 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 가상환경 생성 및 활성화
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
# 의존성 설치
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 환경변수 설정
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
## 테스트
|
## 테스트
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 전체 테스트 (138개)
|
||||||
|
bash scripts/run_tests.sh
|
||||||
|
|
||||||
|
# 특정 키워드 필터
|
||||||
|
bash scripts/run_tests.sh -k bot
|
||||||
|
|
||||||
|
# pytest 직접 실행
|
||||||
pytest tests/ -v
|
pytest tests/ -v
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
모든 외부 API(Binance, Discord)는 `unittest.mock.AsyncMock`으로 대체되며, 비동기 테스트는 `@pytest.mark.asyncio`를 사용합니다.
|
||||||
|
|
||||||
## 환경변수 레퍼런스
|
## ML 모델 학습
|
||||||
|
|
||||||
| 변수 | 기본값 | 설명 |
|
봇은 모델 파일이 없으면 ML 필터 없이 동작합니다. 모델을 학습하려면:
|
||||||
|------|--------|------|
|
|
||||||
| `BINANCE_API_KEY` | — | Binance API 키 |
|
|
||||||
| `BINANCE_API_SECRET` | — | Binance API 시크릿 |
|
|
||||||
| `SYMBOL` | `XRPUSDT` | 거래 심볼 |
|
|
||||||
| `LEVERAGE` | `10` | 레버리지 배수 |
|
|
||||||
| `RISK_PER_TRADE` | `0.02` | 트레이드당 리스크 비율 (2%) |
|
|
||||||
| `DISCORD_WEBHOOK_URL` | — | Discord 웹훅 URL |
|
|
||||||
|
|
||||||
---
|
### 전체 파이프라인 (권장)
|
||||||
|
|
||||||
## 주의사항
|
```bash
|
||||||
|
# 전체 심볼 학습 + 배포
|
||||||
|
bash scripts/train_and_deploy.sh
|
||||||
|
|
||||||
> **이 봇은 실제 자산을 거래합니다.** 운영 전 반드시 Binance Testnet에서 충분히 검증하세요.
|
# 단일 심볼만 학습 + 배포
|
||||||
> 과거 수익이 미래 수익을 보장하지 않습니다. 투자 손실에 대한 책임은 사용자 본인에게 있습니다.
|
bash scripts/train_and_deploy.sh --symbol TRXUSDT
|
||||||
> 성투기원합니다.
|
|
||||||
|
# MLX GPU 학습 (Apple Silicon, 단일 심볼)
|
||||||
|
bash scripts/train_and_deploy.sh mlx --symbol XRPUSDT
|
||||||
|
|
||||||
|
# 학습만 (배포 없이)
|
||||||
|
bash scripts/train_and_deploy.sh lgbm 0
|
||||||
|
```
|
||||||
|
|
||||||
|
> **자동 분기**: `data/{symbol}/combined_15m.parquet`가 없으면 1년치 전체 수집, 있으면 35일치 Upsert로 자동 전환.
|
||||||
|
|
||||||
|
### 단계별 수동 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 과거 데이터 수집
|
||||||
|
python scripts/fetch_history.py --symbol TRXUSDT --interval 15m --days 365
|
||||||
|
|
||||||
|
# 2. LightGBM 모델 학습
|
||||||
|
python scripts/train_model.py --symbol TRXUSDT
|
||||||
|
|
||||||
|
# 3. 서버에 모델 배포
|
||||||
|
bash scripts/deploy_model.sh --symbol TRXUSDT
|
||||||
|
```
|
||||||
|
|
||||||
|
> **모델 핫리로드**: 봇 실행 중 모델 파일을 교체하면, 다음 캔들 마감 시 자동으로 감지해 리로드합니다.
|
||||||
|
|
||||||
|
### 하이퍼파라미터 튜닝 (Optuna)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 심볼별 튜닝 (50 trials, 5폴드 Walk-Forward, ~30분)
|
||||||
|
python scripts/tune_hyperparams.py --symbol XRPUSDT
|
||||||
|
|
||||||
|
# 빠른 테스트 (10 trials, 3폴드, ~5분)
|
||||||
|
python scripts/tune_hyperparams.py --symbol TRXUSDT --trials 10 --folds 3
|
||||||
|
```
|
||||||
|
|
||||||
|
결과는 `models/{symbol}/tune_results_YYYYMMDD_HHMMSS.json`에 저장됩니다. Optuna가 찾은 파라미터는 과적합 위험이 있으므로 폴드별 AUC 분산과 개선폭을 반드시 검토하세요.
|
||||||
|
|
||||||
|
### Apple Silicon GPU 가속 (M1/M2/M3/M4)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install mlx # Apple Silicon 전용, requirements.txt에 미포함
|
||||||
|
bash scripts/train_and_deploy.sh mlx --symbol XRPUSDT
|
||||||
|
```
|
||||||
|
|
||||||
|
MLX로 학습한 모델은 ONNX 포맷으로 export되어 Linux 서버에서 `onnxruntime`으로 추론합니다.
|
||||||
|
|
||||||
|
## 전략 파라미터 스윕
|
||||||
|
|
||||||
|
기술 지표 전략의 최적 파라미터를 Walk-Forward 백테스트로 탐색합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 전체 스윕 (324개 조합, ~30분)
|
||||||
|
python scripts/strategy_sweep.py --symbols XRPUSDT --train-months 3 --test-months 1
|
||||||
|
```
|
||||||
|
|
||||||
|
5개 파라미터 × 3~4개 값 = 324개 조합을 순차 테스트:
|
||||||
|
|
||||||
|
| 파라미터 | 값 | 설명 |
|
||||||
|
|---------|------|------|
|
||||||
|
| `ATR_SL_MULT` | 1.0, 1.5, 2.0 | 손절 ATR 배수 |
|
||||||
|
| `ATR_TP_MULT` | 2.0, 3.0, 4.0 | 익절 ATR 배수 |
|
||||||
|
| `SIGNAL_THRESHOLD` | 3, 4, 5 | 최소 가중치 점수 |
|
||||||
|
| `ADX_THRESHOLD` | 0, 20, 25, 30 | ADX 필터 |
|
||||||
|
| `VOL_MULTIPLIER` | 1.5, 2.0, 2.5 | 거래량 급증 배수 |
|
||||||
|
|
||||||
|
> **핵심 발견**: ADX ≥ 25 필터가 가장 영향력 있는 파라미터. 횡보장 노이즈 신호를 효과적으로 필터링.
|
||||||
|
|
||||||
|
## 주간 전략 리포트
|
||||||
|
|
||||||
|
매주 자동으로 전략 성능을 측정하고 Discord로 리포트를 전송합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 수동 실행 (데이터 수집 스킵)
|
||||||
|
python scripts/weekly_report.py --skip-fetch
|
||||||
|
|
||||||
|
# 전체 실행 (데이터 수집 포함)
|
||||||
|
python scripts/weekly_report.py
|
||||||
|
|
||||||
|
# 특정 날짜 리포트
|
||||||
|
python scripts/weekly_report.py --date 2026-03-07
|
||||||
|
```
|
||||||
|
|
||||||
|
**리포트 내용:**
|
||||||
|
- Walk-Forward 백테스트 성능 (심볼별 PF/승률/MDD)
|
||||||
|
- 운영 대시보드 API에서 실전 트레이드 통계 조회 (거래 수/순수익/승률)
|
||||||
|
- 성능 추이 (최근 4주 PF/승률/MDD 변화)
|
||||||
|
- ML 재도전 체크리스트 (3개 조건 자동 판단)
|
||||||
|
- PF < 1.0 시 파라미터 스윕 대안 제시
|
||||||
|
|
||||||
|
> 실전 데이터는 운영 대시보드 API(`GET /api/trades`, `GET /api/stats`)에서 조회합니다. `DASHBOARD_API_URL` 환경변수로 주소를 설정하세요.
|
||||||
|
|
||||||
|
**크론탭 설정:**
|
||||||
|
```bash
|
||||||
|
# 매주 일요일 새벽 3시 KST
|
||||||
|
0 18 * * 6 cd /app && python scripts/weekly_report.py >> logs/cron.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
`main` 브랜치에 푸시하면 Jenkins 파이프라인이 자동 실행됩니다.
|
||||||
|
|
||||||
|
1. **Notify Build Start** — Discord 빌드 시작 알림
|
||||||
|
2. **Git Clone from Gitea** — 소스 체크아웃
|
||||||
|
3. **Build Docker Image** — Docker 이미지 빌드 (`:{BUILD_NUMBER}` + `:latest`)
|
||||||
|
4. **Push to Gitea Registry** — Container Registry에 푸시
|
||||||
|
5. **Deploy to Prod** — 운영 서버에 자동 배포 (`docker compose pull && up -d`)
|
||||||
|
6. **Cleanup** — 로컬 이미지 정리
|
||||||
|
|
||||||
|
빌드 성공/실패 결과는 Discord로 자동 알림됩니다.
|
||||||
|
|
||||||
|
## 설계 문서
|
||||||
|
|
||||||
|
모든 설계 문서와 구현 계획은 `docs/plans/`에 저장됩니다.
|
||||||
|
|
||||||
|
- `YYYY-MM-DD-feature-name-design.md` — 설계 결정 문서
|
||||||
|
- `YYYY-MM-DD-feature-name-plan.md` — 단계별 구현 계획
|
||||||
|
- [ARCHITECTURE.md](./ARCHITECTURE.md) — 전체 아키텍처 (5-레이어, MLOps 파이프라인, 동작 시나리오, 테스트 커버리지)
|
||||||
|
|||||||
47
dashboard/SETUP.md
Normal file
47
dashboard/SETUP.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Trading Dashboard
|
||||||
|
|
||||||
|
봇과 통합된 대시보드. 봇 로그를 읽기 전용으로 마운트하여 실시간 시각화합니다.
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
dashboard/
|
||||||
|
├── SETUP.md
|
||||||
|
├── api/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── log_parser.py
|
||||||
|
│ ├── dashboard_api.py
|
||||||
|
│ └── entrypoint.sh
|
||||||
|
└── ui/
|
||||||
|
├── Dockerfile
|
||||||
|
├── nginx.conf
|
||||||
|
├── package.json
|
||||||
|
├── vite.config.js
|
||||||
|
├── index.html
|
||||||
|
└── src/
|
||||||
|
├── main.jsx
|
||||||
|
└── App.jsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 실행
|
||||||
|
|
||||||
|
루트 디렉토리에서 봇과 함께 실행:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 전체 (봇 + 대시보드)
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 대시보드만
|
||||||
|
docker compose up -d --build dashboard-api dashboard-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
## 접속
|
||||||
|
|
||||||
|
`http://<서버IP>:8080`
|
||||||
|
|
||||||
|
## 동작 방식
|
||||||
|
|
||||||
|
- `dashboard-api`: 로그 파서 + FastAPI 서버 (봇 로그 → SQLite → REST API)
|
||||||
|
- `dashboard-ui`: React + Vite (빌드 후 nginx에서 서빙, API 프록시)
|
||||||
|
- 봇 로그 디렉토리를 `:ro` (읽기 전용) 마운트
|
||||||
|
- 대시보드 DB는 Docker named volume (`dashboard-data`)에 저장
|
||||||
9
dashboard/api/Dockerfile
Normal file
9
dashboard/api/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
RUN pip install --no-cache-dir fastapi uvicorn
|
||||||
|
COPY log_parser.py .
|
||||||
|
COPY dashboard_api.py .
|
||||||
|
COPY entrypoint.sh .
|
||||||
|
RUN chmod +x entrypoint.sh
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["./entrypoint.sh"]
|
||||||
197
dashboard/api/dashboard_api.py
Normal file
197
dashboard/api/dashboard_api.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"""
|
||||||
|
dashboard_api.py — 멀티심볼 대시보드 API
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
from fastapi import FastAPI, Query, Header, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db")
|
||||||
|
PARSER_PID_FILE = os.environ.get("PARSER_PID_FILE", "/tmp/parser.pid")
|
||||||
|
DASHBOARD_RESET_KEY = os.environ.get("DASHBOARD_RESET_KEY", "")
|
||||||
|
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "").split(",") if os.environ.get("CORS_ORIGINS") else ["*"]
|
||||||
|
|
||||||
|
app = FastAPI(title="Trading Dashboard API")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=CORS_ORIGINS,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/symbols")
|
||||||
|
def get_symbols():
|
||||||
|
"""활성 심볼 목록 반환."""
|
||||||
|
with get_db() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT DISTINCT key FROM bot_status WHERE key LIKE '%:%'"
|
||||||
|
).fetchall()
|
||||||
|
symbols = {r["key"].split(":")[0] for r in rows}
|
||||||
|
return {"symbols": sorted(symbols)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/position")
|
||||||
|
def get_position(symbol: Optional[str] = None):
|
||||||
|
with get_db() as db:
|
||||||
|
if symbol:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT * FROM trades WHERE status='OPEN' AND symbol=? ORDER BY id DESC",
|
||||||
|
(symbol,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT * FROM trades WHERE status='OPEN' ORDER BY id DESC"
|
||||||
|
).fetchall()
|
||||||
|
status_rows = db.execute("SELECT key, value FROM bot_status").fetchall()
|
||||||
|
bot = {r["key"]: r["value"] for r in status_rows}
|
||||||
|
return {"positions": [dict(r) for r in rows], "bot": bot}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/trades")
|
||||||
|
def get_trades(
|
||||||
|
symbol: Optional[str] = None,
|
||||||
|
limit: int = Query(50, ge=1, le=500),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
):
|
||||||
|
with get_db() as db:
|
||||||
|
if symbol:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT * FROM trades WHERE status='CLOSED' AND symbol=? ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||||
|
(symbol, limit, offset),
|
||||||
|
).fetchall()
|
||||||
|
total = db.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM trades WHERE status='CLOSED' AND symbol=?",
|
||||||
|
(symbol,),
|
||||||
|
).fetchone()["cnt"]
|
||||||
|
else:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT * FROM trades WHERE status='CLOSED' ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||||
|
(limit, offset),
|
||||||
|
).fetchall()
|
||||||
|
total = db.execute("SELECT COUNT(*) as cnt FROM trades WHERE status='CLOSED'").fetchone()["cnt"]
|
||||||
|
return {"trades": [dict(r) for r in rows], "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/daily")
|
||||||
|
def get_daily(symbol: Optional[str] = None, days: int = Query(30, ge=1, le=365)):
|
||||||
|
with get_db() as db:
|
||||||
|
if symbol:
|
||||||
|
rows = db.execute("""
|
||||||
|
SELECT date,
|
||||||
|
SUM(trade_count) as total_trades,
|
||||||
|
SUM(wins) as wins,
|
||||||
|
SUM(losses) as losses,
|
||||||
|
ROUND(SUM(cumulative_pnl), 4) as net_pnl
|
||||||
|
FROM daily_pnl
|
||||||
|
WHERE symbol=?
|
||||||
|
GROUP BY date ORDER BY date DESC LIMIT ?
|
||||||
|
""", (symbol, days)).fetchall()
|
||||||
|
else:
|
||||||
|
rows = db.execute("""
|
||||||
|
SELECT date,
|
||||||
|
SUM(trade_count) as total_trades,
|
||||||
|
SUM(wins) as wins,
|
||||||
|
SUM(losses) as losses,
|
||||||
|
ROUND(SUM(cumulative_pnl), 4) as net_pnl
|
||||||
|
FROM daily_pnl
|
||||||
|
GROUP BY date ORDER BY date DESC LIMIT ?
|
||||||
|
""", (days,)).fetchall()
|
||||||
|
return {"daily": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/stats")
|
||||||
|
def get_stats(symbol: Optional[str] = None):
|
||||||
|
with get_db() as db:
|
||||||
|
if symbol:
|
||||||
|
row = db.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_trades,
|
||||||
|
COALESCE(SUM(CASE WHEN net_pnl > 0 THEN 1 ELSE 0 END), 0) as wins,
|
||||||
|
COALESCE(SUM(CASE WHEN net_pnl <= 0 THEN 1 ELSE 0 END), 0) as losses,
|
||||||
|
COALESCE(SUM(net_pnl), 0) as total_pnl,
|
||||||
|
COALESCE(SUM(commission), 0) as total_fees,
|
||||||
|
COALESCE(AVG(net_pnl), 0) as avg_pnl,
|
||||||
|
COALESCE(MAX(net_pnl), 0) as best_trade,
|
||||||
|
COALESCE(MIN(net_pnl), 0) as worst_trade
|
||||||
|
FROM trades WHERE status='CLOSED' AND symbol=?
|
||||||
|
""", (symbol,)).fetchone()
|
||||||
|
else:
|
||||||
|
row = db.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_trades,
|
||||||
|
COALESCE(SUM(CASE WHEN net_pnl > 0 THEN 1 ELSE 0 END), 0) as wins,
|
||||||
|
COALESCE(SUM(CASE WHEN net_pnl <= 0 THEN 1 ELSE 0 END), 0) as losses,
|
||||||
|
COALESCE(SUM(net_pnl), 0) as total_pnl,
|
||||||
|
COALESCE(SUM(commission), 0) as total_fees,
|
||||||
|
COALESCE(AVG(net_pnl), 0) as avg_pnl,
|
||||||
|
COALESCE(MAX(net_pnl), 0) as best_trade,
|
||||||
|
COALESCE(MIN(net_pnl), 0) as worst_trade
|
||||||
|
FROM trades WHERE status='CLOSED'
|
||||||
|
""").fetchone()
|
||||||
|
status_rows = db.execute("SELECT key, value FROM bot_status").fetchall()
|
||||||
|
bot = {r["key"]: r["value"] for r in status_rows}
|
||||||
|
result = dict(row)
|
||||||
|
if symbol:
|
||||||
|
result["current_price"] = bot.get(f"{symbol}:current_price")
|
||||||
|
result["balance"] = bot.get("balance")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/candles")
|
||||||
|
def get_candles(symbol: str = Query(...), limit: int = Query(96, ge=1, le=1000)):
|
||||||
|
with get_db() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT * FROM candles WHERE symbol=? ORDER BY ts DESC LIMIT ?",
|
||||||
|
(symbol, limit),
|
||||||
|
).fetchall()
|
||||||
|
return {"candles": [dict(r) for r in reversed(rows)]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
cnt = db.execute("SELECT COUNT(*) as c FROM candles").fetchone()["c"]
|
||||||
|
return {"status": "ok", "candles_count": cnt}
|
||||||
|
except Exception:
|
||||||
|
return {"status": "error", "detail": "database unavailable"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/reset")
|
||||||
|
def reset_db(x_api_key: Optional[str] = Header(None)):
|
||||||
|
"""DB 초기화 + 파서에 SIGHUP으로 재파싱 요청."""
|
||||||
|
# C1: API key 인증 (DASHBOARD_RESET_KEY가 설정된 경우)
|
||||||
|
if DASHBOARD_RESET_KEY and x_api_key != DASHBOARD_RESET_KEY:
|
||||||
|
raise HTTPException(status_code=403, detail="invalid api key")
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
for table in ["trades", "daily_pnl", "parse_state", "bot_status", "candles"]:
|
||||||
|
db.execute(f"DELETE FROM {table}")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# C2: PID file + SIGHUP으로 파서에 재파싱 요청 (프로세스 재시작 불필요)
|
||||||
|
try:
|
||||||
|
with open(PARSER_PID_FILE) as f:
|
||||||
|
pid = int(f.read().strip())
|
||||||
|
os.kill(pid, signal.SIGHUP)
|
||||||
|
except (FileNotFoundError, ValueError, ProcessLookupError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"status": "ok", "message": "DB 초기화 완료, 파서 재파싱 시작"}
|
||||||
34
dashboard/api/entrypoint.sh
Normal file
34
dashboard/api/entrypoint.sh
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== Trading Dashboard ==="
|
||||||
|
echo "LOG_DIR=${LOG_DIR:-/app/logs}"
|
||||||
|
echo "DB_PATH=${DB_PATH:-/app/data/dashboard.db}"
|
||||||
|
|
||||||
|
# 로그 파서를 백그라운드로 실행
|
||||||
|
python -u log_parser.py &
|
||||||
|
PARSER_PID=$!
|
||||||
|
echo "Log parser started (PID: $PARSER_PID)"
|
||||||
|
|
||||||
|
# 파서가 기존 로그를 처리할 시간 부여
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# SIGTERM/SIGINT → 파서에도 전달 후 대기
|
||||||
|
cleanup() {
|
||||||
|
echo "Shutting down..."
|
||||||
|
kill -TERM "$PARSER_PID" 2>/dev/null
|
||||||
|
wait "$PARSER_PID" 2>/dev/null
|
||||||
|
kill -TERM "$UVICORN_PID" 2>/dev/null
|
||||||
|
wait "$UVICORN_PID" 2>/dev/null
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
trap cleanup SIGTERM SIGINT
|
||||||
|
|
||||||
|
# FastAPI 서버를 백그라운드로 실행 (exec 대신 — 셸이 PID 1을 유지해야 signal forwarding 가능)
|
||||||
|
echo "Starting API server on :8080"
|
||||||
|
uvicorn dashboard_api:app --host 0.0.0.0 --port 8080 --log-level info &
|
||||||
|
UVICORN_PID=$!
|
||||||
|
|
||||||
|
# 자식 프로세스 중 하나라도 종료되면 전체 종료
|
||||||
|
wait -n "$PARSER_PID" "$UVICORN_PID" 2>/dev/null
|
||||||
|
cleanup
|
||||||
595
dashboard/api/log_parser.py
Normal file
595
dashboard/api/log_parser.py
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
"""
|
||||||
|
log_parser.py — 봇 로그 파일을 감시하고 파싱하여 SQLite에 저장
|
||||||
|
봇 코드 수정 없이 동작. logs/ 디렉토리만 마운트하면 됨.
|
||||||
|
|
||||||
|
실행: python log_parser.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ── 설정 ──────────────────────────────────────────────────────────
|
||||||
|
LOG_DIR = os.environ.get("LOG_DIR", "/app/logs")
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db")
|
||||||
|
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "5")) # 초
|
||||||
|
PID_FILE = os.environ.get("PARSER_PID_FILE", "/tmp/parser.pid")
|
||||||
|
|
||||||
|
# ── 정규식 패턴 (멀티심볼 [SYMBOL] 프리픽스 포함) ─────────────────
|
||||||
|
PATTERNS = {
|
||||||
|
"signal": re.compile(
|
||||||
|
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||||
|
r".*\[(?P<symbol>\w+)\] 신호: (?P<signal>\w+) \|.*현재가: (?P<price>[\d.]+)"
|
||||||
|
),
|
||||||
|
|
||||||
|
"adx": re.compile(
|
||||||
|
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||||
|
r".*\[(?P<symbol>\w+)\] ADX: (?P<adx>[\d.]+)"
|
||||||
|
),
|
||||||
|
|
||||||
|
"microstructure": re.compile(
|
||||||
|
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||||
|
r".*\[(?P<symbol>\w+)\] OI=(?P<oi>[\d.]+), OI변화율=(?P<oi_change>[-\d.]+), 펀딩비=(?P<funding>[-\d.]+)"
|
||||||
|
),
|
||||||
|
|
||||||
|
"position_recover": re.compile(
|
||||||
|
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||||
|
r".*\[(?P<symbol>\w+)\] 기존 포지션 복구: (?P<direction>\w+) \| 진입가=(?P<entry_price>[\d.]+) \| 수량=(?P<qty>[\d.]+)"
|
||||||
|
),
|
||||||
|
|
||||||
|
"entry": re.compile(
|
||||||
|
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||||
|
r".*\[(?P<symbol>\w+)\] (?P<direction>SHORT|LONG) 진입: "
|
||||||
|
r"가격=(?P<entry_price>[\d.]+), "
|
||||||
|
r"수량=(?P<qty>[\d.]+), "
|
||||||
|
r"SL=(?P<sl>[\d.]+), "
|
||||||
|
r"TP=(?P<tp>[\d.]+)"
|
||||||
|
r"(?:, RSI=(?P<rsi>[\d.]+))?"
|
||||||
|
r"(?:, MACD_H=(?P<macd_hist>[+\-\d.]+))?"
|
||||||
|
r"(?:, ATR=(?P<atr>[\d.]+))?"
|
||||||
|
),
|
||||||
|
|
||||||
|
"close_detect": re.compile(
|
||||||
|
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||||
|
r".*\[(?P<symbol>\w+)\] 청산 감지\((?P<reason>\w+)\):\s*"
|
||||||
|
r"exit=(?P<exit_price>[\d.]+),\s*"
|
||||||
|
r"rp=(?P<expected>[+\-\d.]+),\s*"
|
||||||
|
r"commission=(?P<commission>[\d.]+),\s*"
|
||||||
|
r"net_pnl=(?P<net_pnl>[+\-\d.]+)"
|
||||||
|
),
|
||||||
|
|
||||||
|
"daily_pnl": re.compile(
|
||||||
|
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||||
|
r".*\[(?P<symbol>\w+)\] 오늘 누적 PnL: (?P<pnl>[+\-\d.]+) USDT"
|
||||||
|
),
|
||||||
|
|
||||||
|
"position_monitor": re.compile(
|
||||||
|
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||||
|
r".*\[(?P<symbol>\w+)\] 포지션 모니터 \| (?P<direction>\w+) \| "
|
||||||
|
r"현재가=(?P<price>[\d.]+) \| PnL=(?P<pnl>[+\-\d.]+) USDT \((?P<pnl_pct>[+\-\d.]+)%\)"
|
||||||
|
),
|
||||||
|
|
||||||
|
"bot_start": re.compile(
|
||||||
|
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||||
|
r".*\[(?P<symbol>\w+)\] 봇 시작, 레버리지 (?P<leverage>\d+)x"
|
||||||
|
),
|
||||||
|
|
||||||
|
"balance": re.compile(
|
||||||
|
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||||
|
r".*\[(?P<symbol>\w+)\] 기준 잔고 설정: (?P<balance>[\d.]+) USDT"
|
||||||
|
),
|
||||||
|
|
||||||
|
"ml_filter": re.compile(
|
||||||
|
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||||
|
r".*ML 필터 로드.*임계값=(?P<threshold>[\d.]+)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LogParser:
|
||||||
|
def __init__(self):
|
||||||
|
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
|
self.conn.row_factory = sqlite3.Row
|
||||||
|
self.conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
self.conn.execute("PRAGMA busy_timeout=5000")
|
||||||
|
self._init_db()
|
||||||
|
|
||||||
|
self._file_positions = {}
|
||||||
|
self._current_positions = {} # {symbol: position_dict}
|
||||||
|
self._pending_candles = {} # {symbol: {ts_key: {data}}}
|
||||||
|
self._balance = 0
|
||||||
|
self._shutdown = False
|
||||||
|
self._dirty = False # batch commit 플래그
|
||||||
|
|
||||||
|
# PID 파일 기록
|
||||||
|
with open(PID_FILE, "w") as f:
|
||||||
|
f.write(str(os.getpid()))
|
||||||
|
|
||||||
|
# 시그널 핸들러
|
||||||
|
signal.signal(signal.SIGTERM, self._handle_sigterm)
|
||||||
|
signal.signal(signal.SIGHUP, self._handle_sighup)
|
||||||
|
|
||||||
|
def _init_db(self):
|
||||||
|
self.conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS trades (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
direction TEXT NOT NULL,
|
||||||
|
entry_time TEXT NOT NULL,
|
||||||
|
exit_time TEXT,
|
||||||
|
entry_price REAL NOT NULL,
|
||||||
|
exit_price REAL,
|
||||||
|
quantity REAL,
|
||||||
|
leverage INTEGER DEFAULT 10,
|
||||||
|
sl REAL,
|
||||||
|
tp REAL,
|
||||||
|
rsi REAL,
|
||||||
|
macd_hist REAL,
|
||||||
|
atr REAL,
|
||||||
|
adx REAL,
|
||||||
|
expected_pnl REAL,
|
||||||
|
actual_pnl REAL,
|
||||||
|
commission REAL,
|
||||||
|
net_pnl REAL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'OPEN',
|
||||||
|
close_reason TEXT,
|
||||||
|
extra TEXT,
|
||||||
|
UNIQUE(symbol, entry_time, direction)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS candles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
price REAL NOT NULL,
|
||||||
|
signal TEXT,
|
||||||
|
adx REAL,
|
||||||
|
oi REAL,
|
||||||
|
oi_change REAL,
|
||||||
|
funding_rate REAL,
|
||||||
|
UNIQUE(symbol, ts)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_pnl (
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
cumulative_pnl REAL DEFAULT 0,
|
||||||
|
trade_count INTEGER DEFAULT 0,
|
||||||
|
wins INTEGER DEFAULT 0,
|
||||||
|
losses INTEGER DEFAULT 0,
|
||||||
|
last_updated TEXT,
|
||||||
|
PRIMARY KEY(symbol, date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bot_status (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS parse_state (
|
||||||
|
filepath TEXT PRIMARY KEY,
|
||||||
|
position INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_candles_symbol_ts ON candles(symbol, ts);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trades_symbol ON trades(symbol);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_trades_unique
|
||||||
|
ON trades(symbol, entry_time, direction);
|
||||||
|
""")
|
||||||
|
self.conn.commit()
|
||||||
|
self._migrate_deduplicate()
|
||||||
|
self._load_state()
|
||||||
|
|
||||||
|
def _migrate_deduplicate(self):
|
||||||
|
"""기존 DB에 중복 trades가 있으면 제거 (가장 오래된 id만 유지)."""
|
||||||
|
dupes = self.conn.execute("""
|
||||||
|
SELECT symbol, entry_time, direction, MIN(id) AS keep_id, COUNT(*) AS cnt
|
||||||
|
FROM trades
|
||||||
|
GROUP BY symbol, entry_time, direction
|
||||||
|
HAVING cnt > 1
|
||||||
|
""").fetchall()
|
||||||
|
if not dupes:
|
||||||
|
return
|
||||||
|
for row in dupes:
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM trades WHERE symbol=? AND entry_time=? AND direction=? AND id!=?",
|
||||||
|
(row["symbol"], row["entry_time"], row["direction"], row["keep_id"]),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
total = sum(r["cnt"] - 1 for r in dupes)
|
||||||
|
print(f"[LogParser] 마이그레이션: 중복 trades {total}건 제거")
|
||||||
|
|
||||||
|
def _load_state(self):
|
||||||
|
rows = self.conn.execute("SELECT filepath, position FROM parse_state").fetchall()
|
||||||
|
self._file_positions = {r["filepath"]: r["position"] for r in rows}
|
||||||
|
|
||||||
|
# 심볼별 열린 포지션 복원
|
||||||
|
open_trades = self.conn.execute(
|
||||||
|
"SELECT * FROM trades WHERE status='OPEN' ORDER BY id DESC"
|
||||||
|
).fetchall()
|
||||||
|
for row in open_trades:
|
||||||
|
sym = row["symbol"]
|
||||||
|
if sym not in self._current_positions:
|
||||||
|
self._current_positions[sym] = dict(row)
|
||||||
|
|
||||||
|
def _save_position(self, filepath, pos):
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO parse_state(filepath, position) VALUES(?,?) "
|
||||||
|
"ON CONFLICT(filepath) DO UPDATE SET position=?",
|
||||||
|
(filepath, pos, pos)
|
||||||
|
)
|
||||||
|
self._dirty = True
|
||||||
|
|
||||||
|
def _handle_sigterm(self, signum, frame):
|
||||||
|
"""Graceful shutdown — DB 커넥션을 안전하게 닫음."""
|
||||||
|
print("[LogParser] SIGTERM 수신 — 종료")
|
||||||
|
self._shutdown = True
|
||||||
|
try:
|
||||||
|
if self._dirty:
|
||||||
|
self.conn.commit()
|
||||||
|
self.conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
os.unlink(PID_FILE)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
def _handle_sighup(self, signum, frame):
|
||||||
|
"""SIGHUP → 파싱 상태 초기화, 처음부터 재파싱."""
|
||||||
|
print("[LogParser] SIGHUP 수신 — 상태 초기화, 재파싱 시작")
|
||||||
|
self._file_positions = {}
|
||||||
|
self._current_positions = {}
|
||||||
|
self._pending_candles = {}
|
||||||
|
self.conn.execute("DELETE FROM parse_state")
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def _batch_commit(self):
|
||||||
|
"""배치 커밋 — _dirty 플래그가 설정된 경우에만 커밋."""
|
||||||
|
if self._dirty:
|
||||||
|
self.conn.commit()
|
||||||
|
self._dirty = False
|
||||||
|
|
||||||
|
def _cleanup_pending_candles(self, max_per_symbol=50):
|
||||||
|
"""오래된 pending candle 데이터 정리 (I4: 메모리 누적 방지)."""
|
||||||
|
for symbol in list(self._pending_candles):
|
||||||
|
pending = self._pending_candles[symbol]
|
||||||
|
if len(pending) > max_per_symbol:
|
||||||
|
keys = sorted(pending.keys())
|
||||||
|
for k in keys[:-max_per_symbol]:
|
||||||
|
del pending[k]
|
||||||
|
|
||||||
|
def _set_status(self, key, value):
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO bot_status(key, value, updated_at) VALUES(?,?,?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value=?, updated_at=?",
|
||||||
|
(key, str(value), now, str(value), now)
|
||||||
|
)
|
||||||
|
self._dirty = True
|
||||||
|
|
||||||
|
# ── 메인 루프 ────────────────────────────────────────────────
|
||||||
|
def run(self):
|
||||||
|
print(f"[LogParser] 시작 — LOG_DIR={LOG_DIR}, DB={DB_PATH}, 폴링={POLL_INTERVAL}s")
|
||||||
|
while not self._shutdown:
|
||||||
|
try:
|
||||||
|
self._scan_logs()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LogParser] 에러: {e}")
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
def _scan_logs(self):
|
||||||
|
log_files = sorted(set(glob.glob(os.path.join(LOG_DIR, "bot*.log"))))
|
||||||
|
for filepath in log_files:
|
||||||
|
self._parse_file(filepath)
|
||||||
|
self._batch_commit()
|
||||||
|
self._cleanup_pending_candles()
|
||||||
|
|
||||||
|
def _parse_file(self, filepath):
|
||||||
|
last_pos = self._file_positions.get(filepath, 0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_size = os.path.getsize(filepath)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
if file_size < last_pos:
|
||||||
|
last_pos = 0
|
||||||
|
|
||||||
|
if file_size == last_pos:
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
||||||
|
f.seek(last_pos)
|
||||||
|
new_lines = f.readlines()
|
||||||
|
new_pos = f.tell()
|
||||||
|
|
||||||
|
for line in new_lines:
|
||||||
|
self._parse_line(line.strip())
|
||||||
|
|
||||||
|
self._file_positions[filepath] = new_pos
|
||||||
|
self._save_position(filepath, new_pos)
|
||||||
|
|
||||||
|
# ── 한 줄 파싱 ──────────────────────────────────────────────
|
||||||
|
def _parse_line(self, line):
|
||||||
|
if not line:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 봇 시작
|
||||||
|
m = PATTERNS["bot_start"].search(line)
|
||||||
|
if m:
|
||||||
|
symbol = m.group("symbol")
|
||||||
|
self._set_status(f"{symbol}:leverage", m.group("leverage"))
|
||||||
|
self._set_status(f"{symbol}:last_start", m.group("ts"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# 잔고
|
||||||
|
m = PATTERNS["balance"].search(line)
|
||||||
|
if m:
|
||||||
|
self._balance = float(m.group("balance"))
|
||||||
|
self._set_status("balance", m.group("balance"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# ML 필터
|
||||||
|
m = PATTERNS["ml_filter"].search(line)
|
||||||
|
if m:
|
||||||
|
self._set_status("ml_threshold", m.group("threshold"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# 포지션 모니터 (5분 간격 현재가·PnL 갱신)
|
||||||
|
m = PATTERNS["position_monitor"].search(line)
|
||||||
|
if m:
|
||||||
|
symbol = m.group("symbol")
|
||||||
|
self._set_status(f"{symbol}:current_price", m.group("price"))
|
||||||
|
self._set_status(f"{symbol}:unrealized_pnl", m.group("pnl"))
|
||||||
|
self._set_status(f"{symbol}:unrealized_pnl_pct", m.group("pnl_pct"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# 포지션 복구 (재시작 시)
|
||||||
|
m = PATTERNS["position_recover"].search(line)
|
||||||
|
if m:
|
||||||
|
self._handle_entry(
|
||||||
|
ts=m.group("ts"),
|
||||||
|
symbol=m.group("symbol"),
|
||||||
|
direction=m.group("direction"),
|
||||||
|
entry_price=float(m.group("entry_price")),
|
||||||
|
qty=float(m.group("qty")),
|
||||||
|
is_recovery=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 포지션 진입
|
||||||
|
m = PATTERNS["entry"].search(line)
|
||||||
|
if m:
|
||||||
|
self._handle_entry(
|
||||||
|
ts=m.group("ts"),
|
||||||
|
symbol=m.group("symbol"),
|
||||||
|
direction=m.group("direction"),
|
||||||
|
entry_price=float(m.group("entry_price")),
|
||||||
|
qty=float(m.group("qty")),
|
||||||
|
sl=float(m.group("sl")),
|
||||||
|
tp=float(m.group("tp")),
|
||||||
|
rsi=float(m.group("rsi")) if m.group("rsi") else None,
|
||||||
|
macd_hist=float(m.group("macd_hist")) if m.group("macd_hist") else None,
|
||||||
|
atr=float(m.group("atr")) if m.group("atr") else None,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# OI/펀딩비 (캔들 데이터에 합침)
|
||||||
|
m = PATTERNS["microstructure"].search(line)
|
||||||
|
if m:
|
||||||
|
symbol = m.group("symbol")
|
||||||
|
ts_key = m.group("ts")[:16]
|
||||||
|
if symbol not in self._pending_candles:
|
||||||
|
self._pending_candles[symbol] = {}
|
||||||
|
if ts_key not in self._pending_candles[symbol]:
|
||||||
|
self._pending_candles[symbol][ts_key] = {}
|
||||||
|
self._pending_candles[symbol][ts_key].update({
|
||||||
|
"oi": float(m.group("oi")),
|
||||||
|
"oi_change": float(m.group("oi_change")),
|
||||||
|
"funding": float(m.group("funding")),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
# ADX
|
||||||
|
m = PATTERNS["adx"].search(line)
|
||||||
|
if m:
|
||||||
|
symbol = m.group("symbol")
|
||||||
|
ts_key = m.group("ts")[:16]
|
||||||
|
if symbol not in self._pending_candles:
|
||||||
|
self._pending_candles[symbol] = {}
|
||||||
|
if ts_key not in self._pending_candles[symbol]:
|
||||||
|
self._pending_candles[symbol][ts_key] = {}
|
||||||
|
self._pending_candles[symbol][ts_key]["adx"] = float(m.group("adx"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# 신호 + 현재가 → 캔들 저장
|
||||||
|
m = PATTERNS["signal"].search(line)
|
||||||
|
if m:
|
||||||
|
symbol = m.group("symbol")
|
||||||
|
ts = m.group("ts")
|
||||||
|
ts_key = ts[:16]
|
||||||
|
price = float(m.group("price"))
|
||||||
|
signal = m.group("signal")
|
||||||
|
extra = self._pending_candles.get(symbol, {}).pop(ts_key, {})
|
||||||
|
|
||||||
|
self._set_status(f"{symbol}:current_price", str(price))
|
||||||
|
self._set_status(f"{symbol}:current_signal", signal)
|
||||||
|
self._set_status(f"{symbol}:last_candle_time", ts)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.conn.execute(
|
||||||
|
"""INSERT INTO candles(symbol, ts, price, signal, adx, oi, oi_change, funding_rate)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(symbol, ts) DO UPDATE SET
|
||||||
|
price=?, signal=?, adx=?, oi=?, oi_change=?, funding_rate=?""",
|
||||||
|
(symbol, ts, price, signal,
|
||||||
|
extra.get("adx"), extra.get("oi"), extra.get("oi_change"), extra.get("funding"),
|
||||||
|
price, signal,
|
||||||
|
extra.get("adx"), extra.get("oi"), extra.get("oi_change"), extra.get("funding")),
|
||||||
|
)
|
||||||
|
self._dirty = True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LogParser] 캔들 저장 에러: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 청산 감지
|
||||||
|
m = PATTERNS["close_detect"].search(line)
|
||||||
|
if m:
|
||||||
|
self._handle_close(
|
||||||
|
ts=m.group("ts"),
|
||||||
|
symbol=m.group("symbol"),
|
||||||
|
exit_price=float(m.group("exit_price")),
|
||||||
|
expected_pnl=float(m.group("expected")),
|
||||||
|
commission=float(m.group("commission")),
|
||||||
|
net_pnl=float(m.group("net_pnl")),
|
||||||
|
reason=m.group("reason"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 일일 누적 PnL
|
||||||
|
m = PATTERNS["daily_pnl"].search(line)
|
||||||
|
if m:
|
||||||
|
symbol = m.group("symbol")
|
||||||
|
ts = m.group("ts")
|
||||||
|
day = ts[:10]
|
||||||
|
pnl = float(m.group("pnl"))
|
||||||
|
self.conn.execute(
|
||||||
|
"""INSERT INTO daily_pnl(symbol, date, cumulative_pnl, last_updated)
|
||||||
|
VALUES(?,?,?,?)
|
||||||
|
ON CONFLICT(symbol, date) DO UPDATE SET cumulative_pnl=?, last_updated=?""",
|
||||||
|
(symbol, day, pnl, ts, pnl, ts)
|
||||||
|
)
|
||||||
|
self._dirty = True
|
||||||
|
self._set_status(f"{symbol}:daily_pnl", str(pnl))
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── 포지션 진입 핸들러 ───────────────────────────────────────
|
||||||
|
def _handle_entry(self, ts, symbol, direction, entry_price, qty,
|
||||||
|
leverage=None, sl=None, tp=None, is_recovery=False,
|
||||||
|
rsi=None, macd_hist=None, atr=None):
|
||||||
|
if leverage is None:
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT value FROM bot_status WHERE key=?",
|
||||||
|
(f"{symbol}:leverage",),
|
||||||
|
).fetchone()
|
||||||
|
leverage = int(row["value"]) if row else 10
|
||||||
|
|
||||||
|
# 중복 체크 — 같은 심볼+방향의 OPEN 포지션이 이미 있으면 스킵
|
||||||
|
current = self._current_positions.get(symbol)
|
||||||
|
if current and current.get("direction") == direction:
|
||||||
|
return
|
||||||
|
|
||||||
|
existing = self.conn.execute(
|
||||||
|
"SELECT id, entry_price FROM trades WHERE status='OPEN' AND symbol=? AND direction=?",
|
||||||
|
(symbol, direction),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
self._current_positions[symbol] = {
|
||||||
|
"id": existing["id"],
|
||||||
|
"direction": direction,
|
||||||
|
"entry_price": existing["entry_price"],
|
||||||
|
"entry_time": ts,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"""INSERT OR IGNORE INTO trades(symbol, direction, entry_time, entry_price,
|
||||||
|
quantity, leverage, sl, tp, status, extra, rsi, macd_hist, atr)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(symbol, direction, ts,
|
||||||
|
entry_price, qty, leverage, sl, tp, "OPEN",
|
||||||
|
json.dumps({"recovery": is_recovery}),
|
||||||
|
rsi, macd_hist, atr),
|
||||||
|
)
|
||||||
|
self._dirty = True
|
||||||
|
self._current_positions[symbol] = {
|
||||||
|
"id": cur.lastrowid,
|
||||||
|
"direction": direction,
|
||||||
|
"entry_price": entry_price,
|
||||||
|
"entry_time": ts,
|
||||||
|
}
|
||||||
|
self._set_status(f"{symbol}:position_status", "OPEN")
|
||||||
|
self._set_status(f"{symbol}:position_direction", direction)
|
||||||
|
self._set_status(f"{symbol}:position_entry_price", str(entry_price))
|
||||||
|
print(f"[LogParser] {symbol} 포지션 진입: {direction} @ {entry_price} (recovery={is_recovery})")
|
||||||
|
|
||||||
|
# ── 포지션 청산 핸들러 ───────────────────────────────────────
|
||||||
|
def _handle_close(self, ts, symbol, exit_price, expected_pnl, commission, net_pnl, reason):
|
||||||
|
# 해당 심볼의 OPEN 거래만 닫음
|
||||||
|
open_trades = self.conn.execute(
|
||||||
|
"SELECT id FROM trades WHERE status='OPEN' AND symbol=? ORDER BY id DESC",
|
||||||
|
(symbol,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
if not open_trades:
|
||||||
|
print(f"[LogParser] 경고: {symbol} 청산 감지했으나 열린 포지션 없음")
|
||||||
|
self._current_positions.pop(symbol, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
primary_id = open_trades[0]["id"]
|
||||||
|
self.conn.execute(
|
||||||
|
"""UPDATE trades SET
|
||||||
|
exit_time=?, exit_price=?, expected_pnl=?,
|
||||||
|
actual_pnl=?, commission=?, net_pnl=?,
|
||||||
|
status='CLOSED', close_reason=?
|
||||||
|
WHERE id=?""",
|
||||||
|
(ts, exit_price, expected_pnl,
|
||||||
|
expected_pnl, commission, net_pnl,
|
||||||
|
reason, primary_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._dirty = True
|
||||||
|
|
||||||
|
if len(open_trades) > 1:
|
||||||
|
stale_ids = [r["id"] for r in open_trades[1:]]
|
||||||
|
self.conn.execute(
|
||||||
|
f"DELETE FROM trades WHERE id IN ({','.join('?' * len(stale_ids))})",
|
||||||
|
stale_ids,
|
||||||
|
)
|
||||||
|
print(f"[LogParser] {symbol} 중복 OPEN 거래 {len(stale_ids)}건 삭제")
|
||||||
|
|
||||||
|
# 심볼별 일별 요약 (trades 테이블에서 재계산 — idempotent)
|
||||||
|
day = ts[:10]
|
||||||
|
row = self.conn.execute(
|
||||||
|
"""SELECT COUNT(*) as cnt,
|
||||||
|
SUM(CASE WHEN net_pnl > 0 THEN 1 ELSE 0 END) as wins,
|
||||||
|
SUM(CASE WHEN net_pnl <= 0 THEN 1 ELSE 0 END) as losses
|
||||||
|
FROM trades WHERE status='CLOSED' AND symbol=? AND exit_time LIKE ?""",
|
||||||
|
(symbol, f"{day}%"),
|
||||||
|
).fetchone()
|
||||||
|
self.conn.execute(
|
||||||
|
"""INSERT INTO daily_pnl(symbol, date, trade_count, wins, losses, last_updated)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(symbol, date) DO UPDATE SET
|
||||||
|
trade_count=?, wins=?, losses=?, last_updated=?""",
|
||||||
|
(symbol, day, row["cnt"], row["wins"], row["losses"], ts,
|
||||||
|
row["cnt"], row["wins"], row["losses"], ts),
|
||||||
|
)
|
||||||
|
self._dirty = True
|
||||||
|
|
||||||
|
self._set_status(f"{symbol}:position_status", "NONE")
|
||||||
|
print(f"[LogParser] {symbol} 포지션 청산: {reason} @ {exit_price}, PnL={net_pnl}")
|
||||||
|
self._current_positions.pop(symbol, None)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = LogParser()
|
||||||
|
try:
|
||||||
|
parser.run()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(PID_FILE)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
3
dashboard/ui/.dockerignore
Normal file
3
dashboard/ui/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
11
dashboard/ui/Dockerfile
Normal file
11
dashboard/ui/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json .
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 3000
|
||||||
111
dashboard/ui/dist/assets/index-50uRhrJe.js
vendored
Normal file
111
dashboard/ui/dist/assets/index-50uRhrJe.js
vendored
Normal file
File diff suppressed because one or more lines are too long
20
dashboard/ui/dist/index.html
vendored
Normal file
20
dashboard/ui/dist/index.html
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Trading Dashboard</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,700&family=JetBrains+Mono:wght@300;400;500;600&display=swap" rel="stylesheet" />
|
||||||
|
<link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700&display=swap" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { background: #08080f; }
|
||||||
|
::-webkit-scrollbar { width: 5px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; }
|
||||||
|
</style>
|
||||||
|
<script type="module" crossorigin src="/assets/index-50uRhrJe.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
dashboard/ui/index.html
Normal file
20
dashboard/ui/index.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Trading Dashboard</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,700&family=JetBrains+Mono:wght@300;400;500;600&display=swap" rel="stylesheet" />
|
||||||
|
<link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700&display=swap" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { background: #08080f; }
|
||||||
|
::-webkit-scrollbar { width: 5px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
dashboard/ui/nginx.conf
Normal file
25
dashboard/ui/nginx.conf
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
server {
|
||||||
|
listen 3000;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA — 모든 경로를 index.html로
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API 프록시 → 백엔드 컨테이너
|
||||||
|
location /api/ {
|
||||||
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
set $backend http://dashboard-api:8080;
|
||||||
|
proxy_pass $backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 캐시 설정
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
2055
dashboard/ui/package-lock.json
generated
Normal file
2055
dashboard/ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
dashboard/ui/package.json
Normal file
20
dashboard/ui/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "trading-dashboard",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"recharts": "^2.12.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
770
dashboard/ui/src/App.jsx
Normal file
770
dashboard/ui/src/App.jsx
Normal file
@@ -0,0 +1,770 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||||
|
AreaChart, Area, LineChart, Line, CartesianGrid, Cell,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
/* ── API ──────────────────────────────────────────────────────── */
|
||||||
|
const api = async (path) => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api${path}`);
|
||||||
|
if (!r.ok) throw new Error(r.statusText);
|
||||||
|
return await r.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`API [${path}]:`, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 유틸 ─────────────────────────────────────────────────────── */
|
||||||
|
const fmt = (n, d = 4) => (n != null ? Number(n).toFixed(d) : "—");
|
||||||
|
const fmtTime = (iso) => {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
const fmtDate = (s) => (s ? s.slice(5, 10).replace("-", "/") : "—");
|
||||||
|
const pnlColor = (v) => (v > 0 ? "#34d399" : v < 0 ? "#f87171" : "rgba(255,255,255,0.5)");
|
||||||
|
const pnlSign = (v) => (v > 0 ? `+${fmt(v)}` : fmt(v));
|
||||||
|
|
||||||
|
/* ── 스타일 변수 ──────────────────────────────────────────────── */
|
||||||
|
const S = {
|
||||||
|
sans: "'Satoshi','DM Sans',system-ui,sans-serif",
|
||||||
|
mono: "'JetBrains Mono','Fira Code',monospace",
|
||||||
|
bg: "#08080f",
|
||||||
|
surface: "rgba(255,255,255,0.015)",
|
||||||
|
surface2: "rgba(255,255,255,0.03)",
|
||||||
|
border: "rgba(255,255,255,0.06)",
|
||||||
|
text3: "rgba(255,255,255,0.35)",
|
||||||
|
text4: "rgba(255,255,255,0.2)",
|
||||||
|
green: "#34d399",
|
||||||
|
red: "#f87171",
|
||||||
|
indigo: "#818cf8",
|
||||||
|
amber: "#f59e0b",
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Badge ────────────────────────────────────────────────────── */
|
||||||
|
const Badge = ({ children, bg = "rgba(255,255,255,0.06)", color = "rgba(255,255,255,0.5)" }) => (
|
||||||
|
<span style={{
|
||||||
|
display: "inline-block", fontSize: 10, fontWeight: 600, padding: "2px 8px",
|
||||||
|
borderRadius: 6, background: bg, color, fontFamily: S.mono,
|
||||||
|
letterSpacing: 0.5, marginLeft: 4,
|
||||||
|
}}>{children}</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ── StatCard ─────────────────────────────────────────────────── */
|
||||||
|
const StatCard = ({ icon, label, value, sub, accent }) => (
|
||||||
|
<div style={{
|
||||||
|
background: `linear-gradient(135deg, ${S.surface2} 0%, rgba(255,255,255,0.008) 100%)`,
|
||||||
|
border: `1px solid ${S.border}`, borderRadius: 14,
|
||||||
|
padding: "18px 20px", position: "relative", overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", top: -20, right: -20, width: 70, height: 70,
|
||||||
|
borderRadius: "50%", background: accent, filter: "blur(28px)",
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
fontSize: 10, color: S.text3, letterSpacing: 1.5,
|
||||||
|
textTransform: "uppercase", fontFamily: S.mono, marginBottom: 6,
|
||||||
|
}}>
|
||||||
|
{icon && <span style={{ marginRight: 5 }}>{icon}</span>}{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 26, fontWeight: 700, color: "#fff", fontFamily: S.sans, letterSpacing: -0.5 }}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{sub && (
|
||||||
|
<div style={{ fontSize: 11, color: accent, fontFamily: S.mono, marginTop: 2 }}>{sub}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ── ChartTooltip ─────────────────────────────────────────────── */
|
||||||
|
const ChartTooltip = ({ active, payload, label }) => {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: "rgba(10,10,18,0.95)", border: "1px solid rgba(255,255,255,0.08)",
|
||||||
|
borderRadius: 10, padding: "10px 14px", fontSize: 11, fontFamily: S.mono,
|
||||||
|
}}>
|
||||||
|
<div style={{ color: "rgba(255,255,255,0.4)", marginBottom: 4 }}>{label}</div>
|
||||||
|
{payload.filter(p => p.name !== "과매수" && p.name !== "과매도" && p.name !== "임계값").map((p, i) => (
|
||||||
|
<div key={i} style={{ color: p.color || "#fff", marginBottom: 1 }}>
|
||||||
|
{p.name}: {typeof p.value === "number" ? p.value.toFixed(4) : p.value}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── TradeRow ──────────────────────────────────────────────────── */
|
||||||
|
const TradeRow = ({ trade, isExpanded, onToggle }) => {
|
||||||
|
const pnl = trade.net_pnl || 0;
|
||||||
|
const isShort = trade.direction === "SHORT";
|
||||||
|
const priceDiff = trade.entry_price && trade.exit_price
|
||||||
|
? ((trade.entry_price - trade.exit_price) / trade.entry_price * 100 * (isShort ? 1 : -1)).toFixed(2)
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
title: "리스크 관리",
|
||||||
|
items: [
|
||||||
|
["손절가 (SL)", trade.sl, S.red],
|
||||||
|
["익절가 (TP)", trade.tp, S.green],
|
||||||
|
["수량", trade.quantity, "rgba(255,255,255,0.6)"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "기술 지표",
|
||||||
|
items: [
|
||||||
|
["RSI", trade.rsi, trade.rsi > 70 ? S.amber : S.indigo],
|
||||||
|
["MACD Hist", trade.macd_hist, trade.macd_hist >= 0 ? S.green : S.red],
|
||||||
|
["ATR", trade.atr, "rgba(255,255,255,0.6)"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "손익 상세",
|
||||||
|
items: [
|
||||||
|
["예상 수익", trade.expected_pnl, S.green],
|
||||||
|
["순수익", trade.net_pnl, pnlColor(trade.net_pnl)],
|
||||||
|
["수수료", trade.commission ? -trade.commission : null, S.red],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 6 }}>
|
||||||
|
<div
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{
|
||||||
|
background: isExpanded ? "rgba(99,102,241,0.06)" : S.surface,
|
||||||
|
border: `1px solid ${isExpanded ? "rgba(99,102,241,0.15)" : "rgba(255,255,255,0.04)"}`,
|
||||||
|
borderRadius: isExpanded ? "14px 14px 0 0" : 14,
|
||||||
|
padding: "14px 18px", cursor: "pointer",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "36px 1.5fr 0.8fr 0.8fr 0.8fr 32px",
|
||||||
|
alignItems: "center", gap: 10, transition: "all 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 30, height: 30, borderRadius: 8,
|
||||||
|
background: isShort ? "rgba(239,68,68,0.1)" : "rgba(52,211,153,0.1)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: 12, fontWeight: 700,
|
||||||
|
color: isShort ? S.red : S.green, fontFamily: S.mono,
|
||||||
|
}}>
|
||||||
|
{isShort ? "S" : "L"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: "#fff", fontFamily: S.sans }}>
|
||||||
|
{(trade.symbol || "XRPUSDT").replace("USDT", "/USDT")}
|
||||||
|
<Badge
|
||||||
|
bg={isShort ? "rgba(239,68,68,0.1)" : "rgba(52,211,153,0.1)"}
|
||||||
|
color={isShort ? S.red : S.green}
|
||||||
|
>
|
||||||
|
{trade.direction}
|
||||||
|
</Badge>
|
||||||
|
<Badge>{trade.leverage || 10}x</Badge>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: S.text3, marginTop: 2, fontFamily: S.mono }}>
|
||||||
|
{fmtDate(trade.entry_time)} {fmtTime(trade.entry_time)} → {fmtTime(trade.exit_time)}
|
||||||
|
{trade.close_reason && (
|
||||||
|
<span style={{ marginLeft: 6, color: S.text4 }}>({trade.close_reason})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ textAlign: "right" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "rgba(255,255,255,0.6)", fontFamily: S.mono }}>
|
||||||
|
{fmt(trade.entry_price)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 9, color: S.text4 }}>진입가</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ textAlign: "right" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "rgba(255,255,255,0.6)", fontFamily: S.mono }}>
|
||||||
|
{fmt(trade.exit_price)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 9, color: S.text4 }}>청산가</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ textAlign: "right" }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: pnlColor(pnl), fontFamily: S.mono }}>
|
||||||
|
{pnlSign(pnl)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 9, color: pnlColor(pnl), opacity: 0.7 }}>{priceDiff}%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
textAlign: "center", color: S.text4, fontSize: 12,
|
||||||
|
transition: "transform 0.15s",
|
||||||
|
transform: isExpanded ? "rotate(180deg)" : "",
|
||||||
|
}}>▾</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div style={{
|
||||||
|
background: "rgba(99,102,241,0.025)",
|
||||||
|
border: "1px solid rgba(99,102,241,0.15)",
|
||||||
|
borderTop: "none", borderRadius: "0 0 14px 14px",
|
||||||
|
padding: "18px 22px",
|
||||||
|
display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 14,
|
||||||
|
}}>
|
||||||
|
{sections.map((sec, si) => (
|
||||||
|
<div key={si}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 9, color: S.text4, letterSpacing: 1.2,
|
||||||
|
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 10,
|
||||||
|
}}>
|
||||||
|
{sec.title}
|
||||||
|
</div>
|
||||||
|
{sec.items.map(([label, val, color], ii) => (
|
||||||
|
<div key={ii} style={{ display: "flex", justifyContent: "space-between", marginBottom: 5 }}>
|
||||||
|
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.4)" }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 11, color, fontFamily: S.mono }}>
|
||||||
|
{val != null ? fmt(val) : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 차트 컨테이너 ────────────────────────────────────────────── */
|
||||||
|
const ChartBox = ({ title, children }) => (
|
||||||
|
<div style={{
|
||||||
|
background: S.surface, border: `1px solid rgba(255,255,255,0.05)`,
|
||||||
|
borderRadius: 14, padding: 18,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 10, color: S.text3, letterSpacing: 1.2,
|
||||||
|
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 14,
|
||||||
|
}}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ── 탭 정의 ──────────────────────────────────────────────────── */
|
||||||
|
const TABS = [
|
||||||
|
{ id: "overview", label: "Overview", icon: "◆" },
|
||||||
|
{ id: "trades", label: "Trades", icon: "◈" },
|
||||||
|
{ id: "chart", label: "Chart", icon: "◇" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════ */
|
||||||
|
/* 메인 대시보드 */
|
||||||
|
/* ═══════════════════════════════════════════════════════════════ */
|
||||||
|
export default function App() {
|
||||||
|
const [tab, setTab] = useState("overview");
|
||||||
|
const [expanded, setExpanded] = useState(null);
|
||||||
|
const [isLive, setIsLive] = useState(false);
|
||||||
|
const [lastUpdate, setLastUpdate] = useState(null);
|
||||||
|
|
||||||
|
const [symbols, setSymbols] = useState([]);
|
||||||
|
const symbolsRef = useRef([]);
|
||||||
|
const [selectedSymbol, setSelectedSymbol] = useState(null); // null = ALL
|
||||||
|
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
total_trades: 0, wins: 0, losses: 0,
|
||||||
|
total_pnl: 0, total_fees: 0, avg_pnl: 0,
|
||||||
|
best_trade: 0, worst_trade: 0,
|
||||||
|
});
|
||||||
|
const [positions, setPositions] = useState([]);
|
||||||
|
const [botStatus, setBotStatus] = useState({});
|
||||||
|
const [trades, setTrades] = useState([]);
|
||||||
|
const [tradesTotal, setTradesTotal] = useState(0);
|
||||||
|
const [tradesPage, setTradesPage] = useState(0);
|
||||||
|
const [daily, setDaily] = useState([]);
|
||||||
|
const [candles, setCandles] = useState([]);
|
||||||
|
|
||||||
|
/* ── 데이터 폴링 ─────────────────────────────────────────── */
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
const sym = selectedSymbol ? `?symbol=${selectedSymbol}` : "";
|
||||||
|
const symRequired = selectedSymbol || symbolsRef.current[0] || "XRPUSDT";
|
||||||
|
|
||||||
|
const [symRes, sRes, pRes, tRes, dRes, cRes] = await Promise.all([
|
||||||
|
api("/symbols"),
|
||||||
|
api(`/stats${sym}`),
|
||||||
|
api(`/position${sym}`),
|
||||||
|
api(`/trades${sym}${sym ? "&" : "?"}limit=50&offset=${tradesPage * 50}`),
|
||||||
|
api(`/daily${sym}`),
|
||||||
|
api(`/candles?symbol=${symRequired}&limit=96`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (symRes?.symbols) {
|
||||||
|
symbolsRef.current = symRes.symbols;
|
||||||
|
setSymbols(symRes.symbols);
|
||||||
|
}
|
||||||
|
if (sRes && sRes.total_trades !== undefined) {
|
||||||
|
setStats(sRes);
|
||||||
|
setIsLive(true);
|
||||||
|
setLastUpdate(new Date());
|
||||||
|
}
|
||||||
|
if (pRes) {
|
||||||
|
setPositions(pRes.positions || []);
|
||||||
|
if (pRes.bot) setBotStatus(pRes.bot);
|
||||||
|
}
|
||||||
|
if (tRes?.trades) {
|
||||||
|
setTrades(tRes.trades);
|
||||||
|
setTradesTotal(tRes.total || tRes.trades.length);
|
||||||
|
}
|
||||||
|
if (dRes?.daily) setDaily(dRes.daily);
|
||||||
|
if (cRes?.candles) setCandles(cRes.candles);
|
||||||
|
}, [selectedSymbol, tradesPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll();
|
||||||
|
const iv = setInterval(fetchAll, 15000);
|
||||||
|
return () => clearInterval(iv);
|
||||||
|
}, [fetchAll]);
|
||||||
|
|
||||||
|
/* ── 파생 데이터 ─────────────────────────────────────────── */
|
||||||
|
const winRate = stats.total_trades > 0
|
||||||
|
? ((stats.wins / stats.total_trades) * 100).toFixed(0) : "0";
|
||||||
|
|
||||||
|
// 일별 → 날짜순 정렬 (오래된 순)
|
||||||
|
const dailyAsc = [...daily].reverse();
|
||||||
|
const dailyLabels = dailyAsc.map((d) => fmtDate(d.date));
|
||||||
|
const dailyPnls = dailyAsc.map((d) => d.net_pnl || 0);
|
||||||
|
|
||||||
|
// 누적 수익
|
||||||
|
const cumData = [];
|
||||||
|
let cum = 0;
|
||||||
|
dailyAsc.forEach((d) => {
|
||||||
|
cum += d.net_pnl || 0;
|
||||||
|
cumData.push({ date: fmtDate(d.date), cumPnl: +cum.toFixed(4) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 캔들 차트용
|
||||||
|
const candleLabels = candles.map((c) => fmtTime(c.ts));
|
||||||
|
|
||||||
|
/* ── 현재 가격 (봇 상태 또는 마지막 캔들) ──────────────────── */
|
||||||
|
const currentPrice = selectedSymbol
|
||||||
|
? (botStatus[`${selectedSymbol}:current_price`] || (candles.length ? candles[candles.length - 1].price : null))
|
||||||
|
: (candles.length ? candles[candles.length - 1].price : null);
|
||||||
|
|
||||||
|
/* ── 공통 차트 축 스타일 ─────────────────────────────────── */
|
||||||
|
const axisStyle = {
|
||||||
|
tick: { fill: "rgba(255,255,255,0.25)", fontSize: 10, fontFamily: "JetBrains Mono" },
|
||||||
|
axisLine: false, tickLine: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: "100vh", background: S.bg, color: "#fff",
|
||||||
|
fontFamily: S.sans, padding: "28px 20px",
|
||||||
|
position: "relative", overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
{/* BG glow */}
|
||||||
|
<div style={{
|
||||||
|
position: "fixed", inset: 0, pointerEvents: "none",
|
||||||
|
background: "radial-gradient(ellipse 50% 35% at 15% 5%,rgba(99,102,241,0.05) 0%,transparent 70%),radial-gradient(ellipse 40% 40% at 85% 90%,rgba(52,211,153,0.03) 0%,transparent 70%)",
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<div style={{ maxWidth: 960, margin: "0 auto", position: "relative" }}>
|
||||||
|
{/* ═══ 헤더 ═══════════════════════════════════════════ */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex", justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start", marginBottom: 28, flexWrap: "wrap", gap: 16,
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 10, marginBottom: 6,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 8, height: 8, borderRadius: "50%",
|
||||||
|
background: isLive ? S.green : S.amber,
|
||||||
|
boxShadow: isLive
|
||||||
|
? "0 0 10px rgba(52,211,153,0.5)"
|
||||||
|
: "0 0 10px rgba(245,158,11,0.5)",
|
||||||
|
animation: "pulse 2s infinite",
|
||||||
|
}} />
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, color: S.text3, letterSpacing: 2,
|
||||||
|
textTransform: "uppercase", fontFamily: S.mono,
|
||||||
|
}}>
|
||||||
|
{isLive ? "Live" : "Connecting…"}
|
||||||
|
{selectedSymbol
|
||||||
|
? ` · ${selectedSymbol.replace("USDT", "/USDT")}`
|
||||||
|
: ` · ${symbols.length} symbols`}
|
||||||
|
{currentPrice && (
|
||||||
|
<span style={{ color: "rgba(255,255,255,0.5)", marginLeft: 8 }}>
|
||||||
|
{fmt(currentPrice)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 style={{ fontSize: 28, fontWeight: 700, margin: 0, letterSpacing: -0.8 }}>
|
||||||
|
Trading Dashboard
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오픈 포지션 — 복수 표시 */}
|
||||||
|
{positions.length > 0 && (
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
{positions.map((pos) => {
|
||||||
|
const curP = parseFloat(botStatus[`${pos.symbol}:current_price`] || 0);
|
||||||
|
const entP = parseFloat(pos.entry_price || 0);
|
||||||
|
const isShort = pos.direction === "SHORT";
|
||||||
|
const uPnl = botStatus[`${pos.symbol}:unrealized_pnl`];
|
||||||
|
const uPnlPct = botStatus[`${pos.symbol}:unrealized_pnl_pct`];
|
||||||
|
const pnlPct = uPnlPct != null
|
||||||
|
? parseFloat(uPnlPct)
|
||||||
|
: (entP > 0 && curP > 0
|
||||||
|
? ((isShort ? entP - curP : curP - entP) / entP * 100 * (pos.leverage || 10))
|
||||||
|
: null);
|
||||||
|
const pnlUsdt = uPnl != null ? parseFloat(uPnl) : null;
|
||||||
|
const posPnlColor = pnlPct > 0 ? S.green : pnlPct < 0 ? S.red : S.text3;
|
||||||
|
return (
|
||||||
|
<div key={pos.id} style={{
|
||||||
|
background: "linear-gradient(135deg,rgba(99,102,241,0.08) 0%,rgba(99,102,241,0.02) 100%)",
|
||||||
|
border: "1px solid rgba(99,102,241,0.15)", borderRadius: 14,
|
||||||
|
padding: "12px 18px",
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 9, color: S.text3, letterSpacing: 1.2, fontFamily: S.mono, marginBottom: 4 }}>
|
||||||
|
{(pos.symbol || "").replace("USDT", "/USDT")}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||||
|
<Badge
|
||||||
|
bg={isShort ? "rgba(239,68,68,0.12)" : "rgba(52,211,153,0.12)"}
|
||||||
|
color={isShort ? S.red : S.green}
|
||||||
|
>
|
||||||
|
{pos.direction} {pos.leverage || 10}x
|
||||||
|
</Badge>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700, fontFamily: S.mono }}>
|
||||||
|
{fmt(pos.entry_price)}
|
||||||
|
</span>
|
||||||
|
{pnlPct !== null && (
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, fontFamily: S.mono, color: posPnlColor }}>
|
||||||
|
{pnlUsdt != null ? `${pnlUsdt > 0 ? "+" : ""}${pnlUsdt.toFixed(4)}` : ""}
|
||||||
|
{` (${pnlPct > 0 ? "+" : ""}${pnlPct.toFixed(2)}%)`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ 심볼 필터 ═══════════════════════════════════════ */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex", gap: 4, marginBottom: 12,
|
||||||
|
background: "rgba(255,255,255,0.02)", borderRadius: 12,
|
||||||
|
padding: 4, width: "fit-content",
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedSymbol(null); setTradesPage(0); }}
|
||||||
|
style={{
|
||||||
|
background: selectedSymbol === null ? "rgba(99,102,241,0.15)" : "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: selectedSymbol === null ? S.indigo : S.text3,
|
||||||
|
padding: "6px 14px", borderRadius: 8, cursor: "pointer",
|
||||||
|
fontSize: 11, fontWeight: 600, fontFamily: S.mono,
|
||||||
|
}}
|
||||||
|
>ALL</button>
|
||||||
|
{symbols.map((sym) => (
|
||||||
|
<button
|
||||||
|
key={sym}
|
||||||
|
onClick={() => { setSelectedSymbol(sym); setTradesPage(0); }}
|
||||||
|
style={{
|
||||||
|
background: selectedSymbol === sym ? "rgba(99,102,241,0.15)" : "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: selectedSymbol === sym ? S.indigo : S.text3,
|
||||||
|
padding: "6px 14px", borderRadius: 8, cursor: "pointer",
|
||||||
|
fontSize: 11, fontWeight: 600, fontFamily: S.mono,
|
||||||
|
}}
|
||||||
|
>{sym.replace("USDT", "")}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ 탭 ═════════════════════════════════════════════ */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex", gap: 4, marginBottom: 24,
|
||||||
|
background: "rgba(255,255,255,0.02)", borderRadius: 12,
|
||||||
|
padding: 4, width: "fit-content",
|
||||||
|
}}>
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
style={{
|
||||||
|
background: tab === t.id ? "rgba(255,255,255,0.08)" : "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: tab === t.id ? "#fff" : S.text3,
|
||||||
|
padding: "8px 18px", borderRadius: 9, cursor: "pointer",
|
||||||
|
fontSize: 12, fontWeight: 500, fontFamily: S.sans,
|
||||||
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ marginRight: 6, fontSize: 10 }}>{t.icon}</span>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ OVERVIEW ═══════════════════════════════════════ */}
|
||||||
|
{tab === "overview" && (
|
||||||
|
<div>
|
||||||
|
{/* Stats */}
|
||||||
|
<div style={{
|
||||||
|
display: "grid", gridTemplateColumns: "repeat(4,1fr)",
|
||||||
|
gap: 10, marginBottom: 24,
|
||||||
|
}}>
|
||||||
|
<StatCard icon="💰" label="총 수익" value={pnlSign(stats.total_pnl)} sub="USDT" accent="rgba(52,211,153,0.4)" />
|
||||||
|
<StatCard icon="📊" label="승률" value={`${winRate}%`} sub={`${stats.wins}W / ${stats.losses}L`} accent="rgba(129,140,248,0.4)" />
|
||||||
|
<StatCard icon="⚡" label="총 거래" value={stats.total_trades} sub={`평균 ${fmt(stats.avg_pnl)} USDT`} accent="rgba(251,191,36,0.3)" />
|
||||||
|
<StatCard icon="🎯" label="베스트" value={`+${fmt(stats.best_trade)}`} sub={`최저 ${fmt(stats.worst_trade)}`} accent="rgba(99,102,241,0.3)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 차트 */}
|
||||||
|
<div style={{
|
||||||
|
display: "grid", gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: 10, marginBottom: 24,
|
||||||
|
}}>
|
||||||
|
<ChartBox title="일별 손익">
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<BarChart data={dailyAsc.map((d) => ({ date: fmtDate(d.date), pnl: d.net_pnl || 0 }))}>
|
||||||
|
<XAxis dataKey="date" {...axisStyle} />
|
||||||
|
<YAxis {...axisStyle} />
|
||||||
|
<Tooltip content={<ChartTooltip />} />
|
||||||
|
<Bar dataKey="pnl" name="순수익" radius={[5, 5, 0, 0]}>
|
||||||
|
{dailyAsc.map((d, i) => (
|
||||||
|
<Cell key={i} fill={(d.net_pnl || 0) >= 0 ? S.green : S.red} fillOpacity={0.75} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartBox>
|
||||||
|
|
||||||
|
<ChartBox title="누적 수익 곡선">
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<AreaChart data={cumData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gCum" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={S.indigo} stopOpacity={0.25} />
|
||||||
|
<stop offset="100%" stopColor={S.indigo} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis dataKey="date" {...axisStyle} />
|
||||||
|
<YAxis {...axisStyle} />
|
||||||
|
<Tooltip content={<ChartTooltip />} />
|
||||||
|
<Area
|
||||||
|
type="monotone" dataKey="cumPnl" name="누적"
|
||||||
|
stroke={S.indigo} strokeWidth={2} fill="url(#gCum)"
|
||||||
|
dot={{ fill: S.indigo, r: 3.5, strokeWidth: 0 }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartBox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최근 거래 */}
|
||||||
|
<div style={{
|
||||||
|
fontSize: 10, color: S.text3, letterSpacing: 1.2,
|
||||||
|
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 10,
|
||||||
|
}}>
|
||||||
|
최근 거래
|
||||||
|
</div>
|
||||||
|
{trades.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: "center", color: S.text3, padding: 40,
|
||||||
|
fontFamily: S.mono, fontSize: 12,
|
||||||
|
}}>
|
||||||
|
거래 내역 없음 — 로그 파싱 대기 중
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{trades.slice(0, 3).map((t) => (
|
||||||
|
<TradeRow
|
||||||
|
key={t.id}
|
||||||
|
trade={t}
|
||||||
|
isExpanded={expanded === t.id}
|
||||||
|
onToggle={() => setExpanded(expanded === t.id ? null : t.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{trades.length > 3 && (
|
||||||
|
<div
|
||||||
|
onClick={() => setTab("trades")}
|
||||||
|
style={{
|
||||||
|
textAlign: "center", padding: 12, color: S.indigo,
|
||||||
|
fontSize: 12, cursor: "pointer", fontFamily: S.mono,
|
||||||
|
background: "rgba(99,102,241,0.04)", borderRadius: 10,
|
||||||
|
marginTop: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
전체 {tradesTotal}건 보기 →
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══ TRADES ═════════════════════════════════════════ */}
|
||||||
|
{tab === "trades" && (
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 10, color: S.text3, letterSpacing: 1.2,
|
||||||
|
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 12,
|
||||||
|
}}>
|
||||||
|
전체 거래 내역 ({tradesTotal}건)
|
||||||
|
</div>
|
||||||
|
{trades.map((t) => (
|
||||||
|
<TradeRow
|
||||||
|
key={t.id}
|
||||||
|
trade={t}
|
||||||
|
isExpanded={expanded === t.id}
|
||||||
|
onToggle={() => setExpanded(expanded === t.id ? null : t.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{tradesTotal > 50 && (
|
||||||
|
<div style={{
|
||||||
|
display: "flex", justifyContent: "center", alignItems: "center",
|
||||||
|
gap: 12, marginTop: 14,
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
disabled={tradesPage === 0}
|
||||||
|
onClick={() => setTradesPage((p) => Math.max(0, p - 1))}
|
||||||
|
style={{
|
||||||
|
fontSize: 11, fontFamily: S.mono, padding: "6px 14px",
|
||||||
|
background: tradesPage === 0 ? "transparent" : "rgba(99,102,241,0.1)",
|
||||||
|
color: tradesPage === 0 ? S.text4 : S.indigo,
|
||||||
|
border: `1px solid ${tradesPage === 0 ? S.border : "rgba(99,102,241,0.2)"}`,
|
||||||
|
borderRadius: 8, cursor: tradesPage === 0 ? "default" : "pointer",
|
||||||
|
}}
|
||||||
|
>← 이전</button>
|
||||||
|
<span style={{ fontSize: 11, color: S.text3, fontFamily: S.mono }}>
|
||||||
|
{tradesPage * 50 + 1}–{Math.min((tradesPage + 1) * 50, tradesTotal)} / {tradesTotal}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
disabled={(tradesPage + 1) * 50 >= tradesTotal}
|
||||||
|
onClick={() => setTradesPage((p) => p + 1)}
|
||||||
|
style={{
|
||||||
|
fontSize: 11, fontFamily: S.mono, padding: "6px 14px",
|
||||||
|
background: (tradesPage + 1) * 50 >= tradesTotal ? "transparent" : "rgba(99,102,241,0.1)",
|
||||||
|
color: (tradesPage + 1) * 50 >= tradesTotal ? S.text4 : S.indigo,
|
||||||
|
border: `1px solid ${(tradesPage + 1) * 50 >= tradesTotal ? S.border : "rgba(99,102,241,0.2)"}`,
|
||||||
|
borderRadius: 8, cursor: (tradesPage + 1) * 50 >= tradesTotal ? "default" : "pointer",
|
||||||
|
}}
|
||||||
|
>다음 →</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══ CHART ══════════════════════════════════════════ */}
|
||||||
|
{tab === "chart" && (
|
||||||
|
<div>
|
||||||
|
<ChartBox title={`${(selectedSymbol || symbols[0] || "XRP").replace("USDT", "")}/USDT 15m 가격`}>
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<AreaChart data={candles.map((c) => ({ ts: fmtTime(c.ts), price: c.price || c.close }))}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gP" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#6366f1" stopOpacity={0.15} />
|
||||||
|
<stop offset="100%" stopColor="#6366f1" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
|
||||||
|
<XAxis dataKey="ts" {...axisStyle} interval="preserveStartEnd" />
|
||||||
|
<YAxis domain={["auto", "auto"]} {...axisStyle} />
|
||||||
|
<Tooltip content={<ChartTooltip />} />
|
||||||
|
<Area
|
||||||
|
type="monotone" dataKey="price" name="가격"
|
||||||
|
stroke="#6366f1" strokeWidth={1.5} fill="url(#gP)" dot={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartBox>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: "grid", gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: 10, marginTop: 12,
|
||||||
|
}}>
|
||||||
|
<ChartBox title="OI 변화율">
|
||||||
|
<ResponsiveContainer width="100%" height={150}>
|
||||||
|
<AreaChart data={candles.map((c) => ({ ts: fmtTime(c.ts), oi_change: c.oi_change }))}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gOI" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={S.amber} stopOpacity={0.15} />
|
||||||
|
<stop offset="100%" stopColor={S.amber} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
|
||||||
|
<XAxis dataKey="ts" {...axisStyle} interval="preserveStartEnd" />
|
||||||
|
<YAxis {...axisStyle} />
|
||||||
|
<Tooltip content={<ChartTooltip />} />
|
||||||
|
<Line type="monotone" dataKey={() => 0} stroke="rgba(255,255,255,0.1)" strokeDasharray="4 4" dot={false} name="기준선" />
|
||||||
|
<Area type="monotone" dataKey="oi_change" name="OI변화율" stroke={S.amber} strokeWidth={1.5} fill="url(#gOI)" dot={false} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartBox>
|
||||||
|
|
||||||
|
<ChartBox title="ADX">
|
||||||
|
<ResponsiveContainer width="100%" height={150}>
|
||||||
|
<AreaChart data={candles.map((c) => ({ ts: fmtTime(c.ts), adx: c.adx }))}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gA" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={S.green} stopOpacity={0.15} />
|
||||||
|
<stop offset="100%" stopColor={S.green} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
|
||||||
|
<XAxis dataKey="ts" {...axisStyle} interval="preserveStartEnd" />
|
||||||
|
<YAxis {...axisStyle} />
|
||||||
|
<Tooltip content={<ChartTooltip />} />
|
||||||
|
<Line type="monotone" dataKey={() => 25} stroke="rgba(52,211,153,0.3)" strokeDasharray="4 4" dot={false} name="임계값" />
|
||||||
|
<Area type="monotone" dataKey="adx" name="ADX" stroke={S.green} strokeWidth={1.5} fill="url(#gA)" dot={false} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartBox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══ 푸터 ═══════════════════════════════════════════ */}
|
||||||
|
<div style={{
|
||||||
|
textAlign: "center", padding: "24px 0 8px", marginTop: 24,
|
||||||
|
borderTop: "1px solid rgba(255,255,255,0.03)",
|
||||||
|
display: "flex", justifyContent: "center", alignItems: "center", gap: 16,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.12)", fontFamily: S.mono }}>
|
||||||
|
{lastUpdate
|
||||||
|
? `Synced: ${lastUpdate.toLocaleTimeString("ko-KR")} · 15s polling`
|
||||||
|
: "API 연결 대기 중…"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const key = prompt("Reset API Key를 입력하세요:");
|
||||||
|
if (!key) return;
|
||||||
|
if (!confirm("DB를 초기화하고 로그를 처음부터 다시 파싱합니다. 계속할까요?")) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/reset", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "X-API-Key": key },
|
||||||
|
});
|
||||||
|
if (r.ok) { alert("초기화 완료. 잠시 후 데이터가 다시 채워집니다."); location.reload(); }
|
||||||
|
else if (r.status === 403) alert("API Key가 올바르지 않습니다.");
|
||||||
|
else alert("초기화 실패: " + r.statusText);
|
||||||
|
} catch (e) { alert("초기화 실패: " + e.message); }
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontSize: 10, fontFamily: S.mono, padding: "3px 10px",
|
||||||
|
background: "rgba(255,255,255,0.04)", color: "rgba(255,255,255,0.2)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.06)", borderRadius: 6, cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>Reset DB</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
|
||||||
|
button:hover { filter: brightness(1.1); }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
dashboard/ui/src/main.jsx
Normal file
9
dashboard/ui/src/main.jsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
11
dashboard/ui/vite.config.js
Normal file
11
dashboard/ui/vite.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8080'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
BIN
data/avaxusdt/combined_15m.parquet
Normal file
BIN
data/avaxusdt/combined_15m.parquet
Normal file
Binary file not shown.
0
data/dogeusdt/.gitkeep
Normal file
0
data/dogeusdt/.gitkeep
Normal file
BIN
data/dogeusdt/combined_15m.parquet
Normal file
BIN
data/dogeusdt/combined_15m.parquet
Normal file
Binary file not shown.
BIN
data/linkusdt/combined_15m.parquet
Normal file
BIN
data/linkusdt/combined_15m.parquet
Normal file
Binary file not shown.
BIN
data/solusdt/combined_15m.parquet
Normal file
BIN
data/solusdt/combined_15m.parquet
Normal file
Binary file not shown.
0
data/trxusdt/.gitkeep
Normal file
0
data/trxusdt/.gitkeep
Normal file
BIN
data/trxusdt/combined_15m.parquet
Normal file
BIN
data/trxusdt/combined_15m.parquet
Normal file
Binary file not shown.
0
data/xrpusdt/.gitkeep
Normal file
0
data/xrpusdt/.gitkeep
Normal file
BIN
data/xrpusdt/combined_15m.parquet
Normal file
BIN
data/xrpusdt/combined_15m.parquet
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
cointrader:
|
cointrader:
|
||||||
image: 10.1.10.28:3000/gihyeon/cointrader:latest
|
image: git.gihyeon.com/gihyeon/cointrader:latest
|
||||||
container_name: cointrader
|
container_name: cointrader
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
@@ -16,3 +16,75 @@ services:
|
|||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "5"
|
max-file: "5"
|
||||||
|
|
||||||
|
dashboard-api:
|
||||||
|
image: git.gihyeon.com/gihyeon/cointrader-dashboard-api:latest
|
||||||
|
container_name: dashboard-api
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Seoul
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- LOG_DIR=/app/logs
|
||||||
|
- DB_PATH=/app/data/dashboard.db
|
||||||
|
- POLL_INTERVAL=5
|
||||||
|
volumes:
|
||||||
|
- ./logs:/app/logs:ro
|
||||||
|
- dashboard-data:/app/data
|
||||||
|
depends_on:
|
||||||
|
- cointrader
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
dashboard-ui:
|
||||||
|
image: git.gihyeon.com/gihyeon/cointrader-dashboard-ui:latest
|
||||||
|
container_name: dashboard-ui
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:3000"
|
||||||
|
depends_on:
|
||||||
|
- dashboard-api
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
mtf-bot:
|
||||||
|
image: git.gihyeon.com/gihyeon/cointrader:latest
|
||||||
|
container_name: mtf-bot
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Seoul
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
volumes:
|
||||||
|
- ./logs:/app/logs
|
||||||
|
entrypoint: ["python", "main_mtf.py"]
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
|
ls-ratio-collector:
|
||||||
|
image: git.gihyeon.com/gihyeon/cointrader:latest
|
||||||
|
container_name: ls-ratio-collector
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Seoul
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
entrypoint: ["sh", "scripts/collect_ls_ratio_loop.sh"]
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "5m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
dashboard-data:
|
||||||
|
|||||||
129
docs/decisions/2026-03-21-ml-off-xrp-only.md
Normal file
129
docs/decisions/2026-03-21-ml-off-xrp-only.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# 의사결정 로그: ML 필터 비활성화 & XRP 단독 운영
|
||||||
|
|
||||||
|
**일자**: 2026-03-21
|
||||||
|
**결정자**: gihyeon
|
||||||
|
**상태**: 확정, 운영 반영 완료
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ML 필터를 왜 껐는가
|
||||||
|
|
||||||
|
### 결론: ML OFF > ML ON (전 심볼)
|
||||||
|
|
||||||
|
Walk-Forward 검증 결과, ML 필터를 끈 상태가 모든 심볼에서 더 나은 성과를 보였다.
|
||||||
|
|
||||||
|
| 심볼 | ML OFF PF | ML ON PF | 차이 | ML OFF Return | ML ON Return |
|
||||||
|
|------|-----------|----------|------|---------------|--------------|
|
||||||
|
| **XRPUSDT** | **1.16** | 0.71 | -0.45 (61%↓) | +12.17% | -25.62% |
|
||||||
|
| DOGEUSDT | 1.18 | 0.78 | -0.40 (34%↓) | +16.11% | -28.50% |
|
||||||
|
| SOLUSDT | 0.09 | 0.25 | — | -321.85% | -48.83% |
|
||||||
|
|
||||||
|
### 원인 분석
|
||||||
|
|
||||||
|
**1) Ablation 실험 — 모델이 독립적 알파를 제공하지 못함**
|
||||||
|
- 실험 A: 전체 26개 피처 (baseline AUC)
|
||||||
|
- 실험 B: signal_strength 제거
|
||||||
|
- 실험 C: signal_strength + side 제거
|
||||||
|
- **A→C AUC 하락: 0.08~0.09** (판정 기준: ≤0.05 유용, 0.05~0.10 조건부, ≥0.10 재설계)
|
||||||
|
- 해석: 모델이 기존 기술적 신호(RSI, MACD, ADX)를 단순 재확인하는 수준. 독립적 예측력 부재.
|
||||||
|
|
||||||
|
**2) 학습 데이터 부족**
|
||||||
|
- Walk-Forward 각 폴드 학습 세트에 유효 신호 ~27건
|
||||||
|
- 1:1 언더샘플링 후 양성 샘플 ~13건/폴드 → LightGBM 학습에 극히 부족
|
||||||
|
- 과적합 → 일반화 실패
|
||||||
|
|
||||||
|
**3) Purged Gap 적용 후 성능 추가 하락**
|
||||||
|
- 라벨 생성에 24캔들(6h) lookahead 사용 → 학습/검증 사이에 24캔들 embargo 추가
|
||||||
|
- 이전에 label leakage로 부풀려진 성능이 정정됨
|
||||||
|
|
||||||
|
### 운영 설정
|
||||||
|
```
|
||||||
|
NO_ML_FILTER=true # .env
|
||||||
|
```
|
||||||
|
모델 파일은 유지 (향후 재검증용). `ml_filter.py`의 hot-reload 로직도 그대로 남겨둠.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. SOL/DOGE/TRX를 왜 뺐는가
|
||||||
|
|
||||||
|
### 결론: XRP만 PF > 1.0 달성
|
||||||
|
|
||||||
|
| 심볼 | Strategy Sweep 최고 PF | Walk-Forward PF (ML OFF) | 판정 |
|
||||||
|
|------|----------------------|--------------------------|------|
|
||||||
|
| **XRPUSDT** | 1.68 | **1.16** | ✅ 운영 유지 |
|
||||||
|
| DOGEUSDT | 1.80 | 1.18* | ❌ 제외 |
|
||||||
|
| TRXUSDT | 3.87 (16건) | — | ❌ 제외 |
|
||||||
|
| SOLUSDT | 2.83 | **0.09** | ❌ 제외 |
|
||||||
|
|
||||||
|
*DOGE PF 1.18은 WR 25%로 소수 대형 승리에 의존 → 안정성 부족
|
||||||
|
|
||||||
|
### 핵심 교훈: 과적합 탐지
|
||||||
|
|
||||||
|
**SOLUSDT 사례가 가장 극적:**
|
||||||
|
- Strategy Sweep (1년 전체 백테스트): PF 2.83, Return +90.93%
|
||||||
|
- Walk-Forward (시계열 CV): PF 0.09, Return -321.85%
|
||||||
|
- **과적합 정도: PF 2.83 → 0.09 (97% 하락)**
|
||||||
|
|
||||||
|
→ 전체 기간 백테스트 결과만으로 심볼을 선택하면 안 됨. 반드시 Walk-Forward로 검증해야 함.
|
||||||
|
|
||||||
|
### 운영 설정
|
||||||
|
```
|
||||||
|
SYMBOLS=XRPUSDT # .env (이전: XRPUSDT,SOLUSDT,DOGEUSDT,TRXUSDT)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ML을 다시 켜려면 어떤 조건이 필요한가
|
||||||
|
|
||||||
|
### 필수 조건 (AND)
|
||||||
|
|
||||||
|
1. **데이터 양**: Walk-Forward 폴드당 유효 신호 100건 이상
|
||||||
|
- 현재 ~27건 → 약 4배 필요
|
||||||
|
- 방법: (a) 더 긴 수집 기간 (1년→3년), (b) 15m→5m 타임프레임 (데이터 3배), (c) 새 피처로 유효 신호 비율 증가
|
||||||
|
|
||||||
|
2. **독립적 알파**: Ablation A→C AUC 하락 ≤ 0.05
|
||||||
|
- signal_strength와 side를 제거해도 모델이 독립적으로 예측할 수 있어야 함
|
||||||
|
- 현재 0.08~0.09 → 새 피처(L/S ratio, OI 파생 등)가 이 갭을 메워야 함
|
||||||
|
|
||||||
|
3. **Walk-Forward 검증**: ML ON PF > ML OFF PF (최소 0.1 이상 차이)
|
||||||
|
- 단순히 PF > 1.0이 아니라, ML OFF 대비 개선이 있어야 함
|
||||||
|
- 검증 거래 수 50건 이상
|
||||||
|
|
||||||
|
4. **과적합 지표**: Strategy Sweep PF vs Walk-Forward PF 비율 < 2.0
|
||||||
|
- SOL처럼 Sweep 2.83 / WF 0.09 = 31배 차이 → 극심한 과적합
|
||||||
|
- 비율 2.0 이하면 합리적 범위
|
||||||
|
|
||||||
|
### 유망한 다음 시도
|
||||||
|
|
||||||
|
| 개선 방향 | 기대 효과 | 현재 상태 |
|
||||||
|
|-----------|-----------|-----------|
|
||||||
|
| **L/S Ratio 피처 추가** | 독립적 알파 (상관 0.12~0.14) | 수집 시작 (2026-03-22), 1개월 뒤 검증 가능 |
|
||||||
|
| **학습 데이터 3년 확보** | 폴드당 샘플 3배 증가 | 미착수 |
|
||||||
|
| **Cross-symbol 피처** | BTC/ETH 탑 트레이더 동향 → XRP 예측 | L/S ratio 수집 후 가능 |
|
||||||
|
| **다른 모델 (XGBoost, CatBoost)** | 소규모 데이터에 더 적합할 수 있음 | 미착수 |
|
||||||
|
|
||||||
|
### 재검증 타임라인
|
||||||
|
|
||||||
|
```
|
||||||
|
2026-03-22: L/S ratio 수집 시작 (top_acct + global, 3심볼)
|
||||||
|
2026-04-22: 1개월 데이터 축적 (~17,000건)
|
||||||
|
→ 상관분석 재실행 (5일 → 30일 데이터로 신뢰도 확인)
|
||||||
|
→ L/S ratio 피처를 ML에 추가하여 Ablation 재실험
|
||||||
|
→ Walk-Forward ML ON vs OFF 재비교
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 관련 문서 & 코드
|
||||||
|
|
||||||
|
| 참조 | 위치 |
|
||||||
|
|------|------|
|
||||||
|
| ML 비활성화 커밋 | `dacefaa` (docs: update for XRP-only operation) |
|
||||||
|
| ML 비교 결과 (XRP) | `results/xrpusdt/ml_comparison_20260321_200332.json` |
|
||||||
|
| ML 비교 결과 (DOGE) | `results/dogeusdt/ml_comparison_20260321_200334.json` |
|
||||||
|
| Strategy Sweep 결과 | `results/{symbol}/strategy_sweep_*.json` |
|
||||||
|
| Purged Gap 계획 | `docs/plans/2026-03-21-purged-gap-and-ablation.md` |
|
||||||
|
| ML 검증 파이프라인 | `docs/plans/2026-03-21-ml-validation-pipeline.md` |
|
||||||
|
| ML 검증 결과 | `docs/plans/2026-03-21-ml-validation-result.md` |
|
||||||
|
| L/S Ratio 수집 스크립트 | `scripts/collect_ls_ratio.py` |
|
||||||
|
| 운영 설정 | `.env` → `NO_ML_FILTER=true`, `SYMBOLS=XRPUSDT` |
|
||||||
150
docs/plans/2026-03-02-adx-filter-design.md
Normal file
150
docs/plans/2026-03-02-adx-filter-design.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# ADX 횡보장 필터 구현 계획
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** ADX < 25일 때 get_signal()에서 즉시 HOLD를 반환하여 횡보장 진입을 차단한다.
|
||||||
|
|
||||||
|
**Architecture:** `calculate_all()`에서 `pandas_ta.adx()`로 ADX 컬럼을 추가하고, `get_signal()`에서 가중치 계산 전 ADX < 25이면 early-return HOLD. NaN(초기 캔들)은 기존 로직으로 폴백.
|
||||||
|
|
||||||
|
**Tech Stack:** pandas-ta (이미 사용 중), pytest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: ADX 계산 테스트 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `tests/test_indicators.py`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_adx_column_exists(sample_df):
|
||||||
|
"""calculate_all()이 adx 컬럼을 생성하는지 확인."""
|
||||||
|
ind = Indicators(sample_df)
|
||||||
|
df = ind.calculate_all()
|
||||||
|
assert "adx" in df.columns
|
||||||
|
valid = df["adx"].dropna()
|
||||||
|
assert (valid >= 0).all()
|
||||||
|
```
|
||||||
|
|
||||||
|
`tests/test_indicators.py`에 위 테스트 함수를 추가한다.
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_indicators.py::test_adx_column_exists -v`
|
||||||
|
Expected: FAIL — `"adx" not in df.columns`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: calculate_all()에 ADX 계산 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/indicators.py:46-48` (vol_ma20 계산 바로 앞에 추가)
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
`calculate_all()`의 Stochastic RSI 계산 뒤, `vol_ma20` 계산 앞에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ADX (14) — 횡보장 필터
|
||||||
|
adx_df = ta.adx(df["high"], df["low"], df["close"], length=14)
|
||||||
|
df["adx"] = adx_df["ADX_14"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_indicators.py::test_adx_column_exists -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/indicators.py tests/test_indicators.py
|
||||||
|
git commit -m "feat: add ADX calculation to indicators"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: ADX 필터 테스트 추가 (차단 케이스)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `tests/test_indicators.py`
|
||||||
|
|
||||||
|
**Step 6: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_adx_filter_blocks_low_adx(sample_df):
|
||||||
|
"""ADX < 25일 때 가중치와 무관하게 HOLD를 반환해야 한다."""
|
||||||
|
ind = Indicators(sample_df)
|
||||||
|
df = ind.calculate_all()
|
||||||
|
# ADX를 강제로 낮은 값으로 설정
|
||||||
|
df["adx"] = 15.0
|
||||||
|
signal = ind.get_signal(df)
|
||||||
|
assert signal == "HOLD"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 7: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_indicators.py::test_adx_filter_blocks_low_adx -v`
|
||||||
|
Expected: FAIL — signal이 LONG 또는 SHORT 반환 (ADX 필터 미구현)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: ADX 필터 테스트 추가 (NaN 폴백 케이스)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `tests/test_indicators.py`
|
||||||
|
|
||||||
|
**Step 8: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_adx_nan_falls_through(sample_df):
|
||||||
|
"""ADX가 NaN(초기 캔들)이면 기존 가중치 로직으로 폴백해야 한다."""
|
||||||
|
ind = Indicators(sample_df)
|
||||||
|
df = ind.calculate_all()
|
||||||
|
df["adx"] = float("nan")
|
||||||
|
signal = ind.get_signal(df)
|
||||||
|
# NaN이면 차단하지 않고 기존 로직 실행 → LONG/SHORT/HOLD 중 하나
|
||||||
|
assert signal in ("LONG", "SHORT", "HOLD")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 9: Run test to verify it passes (이 테스트는 현재도 통과)**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_indicators.py::test_adx_nan_falls_through -v`
|
||||||
|
Expected: PASS (ADX 컬럼이 무시되므로 기존 로직 그대로)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: get_signal()에 ADX early-return 구현
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/indicators.py:51-56` (get_signal 메서드 시작부)
|
||||||
|
|
||||||
|
**Step 10: Write minimal implementation**
|
||||||
|
|
||||||
|
`get_signal()` 메서드의 `last = df.iloc[-1]` 바로 다음에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ADX 횡보장 필터: ADX < 25이면 추세 부재로 판단하여 진입 차단
|
||||||
|
adx = last.get("adx", None)
|
||||||
|
if adx is not None and not pd.isna(adx) and adx < 25:
|
||||||
|
logger.debug(f"ADX 필터: {adx:.1f} < 25 — HOLD")
|
||||||
|
return "HOLD"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 11: Run all ADX-related tests**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_indicators.py -k "adx" -v`
|
||||||
|
Expected: 3 tests PASS
|
||||||
|
|
||||||
|
**Step 12: Run full test suite to check for regressions**
|
||||||
|
|
||||||
|
Run: `pytest tests/ -v --tb=short`
|
||||||
|
Expected: All tests PASS
|
||||||
|
|
||||||
|
**Step 13: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/indicators.py tests/test_indicators.py
|
||||||
|
git commit -m "feat: add ADX filter to block sideways market entries"
|
||||||
|
```
|
||||||
91
docs/plans/2026-03-02-hold-negative-sampling-design.md
Normal file
91
docs/plans/2026-03-02-hold-negative-sampling-design.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# HOLD Negative Sampling + Stratified Undersampling Design
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
현재 ML 파이프라인의 학습 데이터가 535개로 매우 적음.
|
||||||
|
`dataset_builder.py`에서 시그널(LONG/SHORT) 발생 캔들만 라벨링하기 때문.
|
||||||
|
전체 ~35,000개 캔들 중 98.5%가 HOLD로 버려짐.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
- HOLD 캔들을 negative sample로 활용하여 학습 데이터 증가
|
||||||
|
- Train-Serve Skew 방지 (학습/추론 데이터 분포 일치)
|
||||||
|
- 기존 signal 샘플은 하나도 버리지 않는 계층적 샘플링
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. dataset_builder.py — HOLD Negative Sampling
|
||||||
|
|
||||||
|
**변경 위치**: `generate_dataset_vectorized()` (line 360-421)
|
||||||
|
|
||||||
|
**현재 로직**:
|
||||||
|
```python
|
||||||
|
valid_rows = (
|
||||||
|
(signal_arr != "HOLD") & # ← 시그널 캔들만 선택
|
||||||
|
...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 로직**:
|
||||||
|
1. 기존 시그널 캔들(LONG/SHORT) 라벨링은 그대로 유지
|
||||||
|
2. HOLD 캔들 중 랜덤 샘플링 (시그널 수의 NEGATIVE_RATIO배)
|
||||||
|
3. HOLD 캔들: label=0, side=랜덤(50% LONG / 50% SHORT), signal_strength=0
|
||||||
|
4. `source` 컬럼 추가: "signal" | "hold_negative" (계층적 샘플링에 사용)
|
||||||
|
|
||||||
|
**파라미터**:
|
||||||
|
```python
|
||||||
|
NEGATIVE_RATIO = 5 # 시그널 대비 HOLD 샘플 비율
|
||||||
|
RANDOM_SEED = 42 # 재현성
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 데이터량**:
|
||||||
|
- 시그널: ~535개 (Win ~200, Loss ~335)
|
||||||
|
- HOLD negative: ~2,675개
|
||||||
|
- 총 학습 데이터: ~3,210개
|
||||||
|
|
||||||
|
### 2. train_model.py — Stratified Undersampling
|
||||||
|
|
||||||
|
**변경 위치**: `train()` 함수 내 언더샘플링 블록 (line 241-257)
|
||||||
|
|
||||||
|
**현재 로직**: 양성:음성 = 1:1 블라인드 언더샘플링
|
||||||
|
```python
|
||||||
|
if len(neg_idx) > len(pos_idx):
|
||||||
|
neg_idx = np.random.choice(neg_idx, size=len(pos_idx), replace=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 로직**: 계층적 3-class 샘플링
|
||||||
|
```python
|
||||||
|
# 1. Signal 샘플(source="signal") 전수 유지 (Win + Loss 모두)
|
||||||
|
# 2. HOLD negative(source="hold_negative")에서만 샘플링
|
||||||
|
# → 양성(Win) 수와 동일한 수만큼 샘플링
|
||||||
|
# 최종: Win ~200 + Signal Loss ~335 + HOLD ~200 = ~735개
|
||||||
|
```
|
||||||
|
|
||||||
|
**효과**:
|
||||||
|
- Signal 샘플 보존율: 100% (Win/Loss 모두)
|
||||||
|
- HOLD negative: 적절한 양만 추가
|
||||||
|
- Train-Serve Skew 없음 (추론 시 signal_strength ≥ 3에서만 호출)
|
||||||
|
|
||||||
|
### 3. 런타임 (변경 없음)
|
||||||
|
|
||||||
|
- `bot.py`: 시그널 발생 시에만 ML 필터 호출 (기존 동일)
|
||||||
|
- `ml_filter.py`: `should_enter()` 그대로
|
||||||
|
- `ml_features.py`: `FEATURE_COLS` 그대로
|
||||||
|
- `label_builder.py`: 기존 SL/TP 룩어헤드 로직 그대로
|
||||||
|
|
||||||
|
## Test Cases
|
||||||
|
|
||||||
|
### 필수 테스트
|
||||||
|
1. **HOLD negative label 검증**: HOLD negative 샘플의 label이 전부 0인지 확인
|
||||||
|
2. **Signal 보존 검증**: 계층적 샘플링 후 source="signal" 샘플이 하나도 버려지지 않았는지 확인
|
||||||
|
|
||||||
|
### 기존 테스트 호환성
|
||||||
|
- 기존 dataset_builder 관련 테스트가 깨지지 않도록 보장
|
||||||
|
|
||||||
|
## File Changes
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `src/dataset_builder.py` | HOLD negative sampling, source 컬럼 추가 |
|
||||||
|
| `scripts/train_model.py` | 계층적 샘플링으로 교체 |
|
||||||
|
| `tests/test_dataset_builder.py` (or equivalent) | 2개 테스트 케이스 추가 |
|
||||||
432
docs/plans/2026-03-02-hold-negative-sampling-plan.md
Normal file
432
docs/plans/2026-03-02-hold-negative-sampling-plan.md
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
# HOLD Negative Sampling Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** HOLD 캔들을 negative sample로 추가하고 계층적 언더샘플링을 도입하여 ML 학습 데이터를 535 → ~3,200개로 증가시킨다.
|
||||||
|
|
||||||
|
**Architecture:** `dataset_builder.py`에서 시그널 캔들 외에 HOLD 캔들을 label=0으로 추가 샘플링하고, `source` 컬럼("signal"/"hold_negative")으로 구분한다. 학습 시 signal 샘플은 전수 유지, HOLD negative에서만 양성 수 만큼 샘플링하는 계층적 언더샘플링을 적용한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, NumPy, pandas, LightGBM, pytest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: dataset_builder.py — HOLD Negative Sampling 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/dataset_builder.py:360-421` (generate_dataset_vectorized 함수)
|
||||||
|
- Test: `tests/test_dataset_builder.py`
|
||||||
|
|
||||||
|
**Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
`tests/test_dataset_builder.py` 끝에 2개 테스트 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_hold_negative_labels_are_all_zero(sample_df):
|
||||||
|
"""HOLD negative 샘플의 label은 전부 0이어야 한다."""
|
||||||
|
result = generate_dataset_vectorized(sample_df, negative_ratio=3)
|
||||||
|
if len(result) > 0 and "source" in result.columns:
|
||||||
|
hold_neg = result[result["source"] == "hold_negative"]
|
||||||
|
if len(hold_neg) > 0:
|
||||||
|
assert (hold_neg["label"] == 0).all(), \
|
||||||
|
f"HOLD negative 중 label != 0인 샘플 존재: {hold_neg['label'].value_counts().to_dict()}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_samples_preserved_after_sampling(sample_df):
|
||||||
|
"""계층적 샘플링 후 source='signal' 샘플이 하나도 버려지지 않아야 한다."""
|
||||||
|
# negative_ratio=0이면 기존 동작 (signal만), >0이면 HOLD 추가
|
||||||
|
result_signal_only = generate_dataset_vectorized(sample_df, negative_ratio=0)
|
||||||
|
result_with_hold = generate_dataset_vectorized(sample_df, negative_ratio=3)
|
||||||
|
|
||||||
|
if len(result_with_hold) > 0 and "source" in result_with_hold.columns:
|
||||||
|
signal_count = (result_with_hold["source"] == "signal").sum()
|
||||||
|
assert signal_count == len(result_signal_only), \
|
||||||
|
f"Signal 샘플 손실: 원본={len(result_signal_only)}, 유지={signal_count}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_dataset_builder.py::test_hold_negative_labels_are_all_zero tests/test_dataset_builder.py::test_signal_samples_preserved_after_sampling -v`
|
||||||
|
Expected: FAIL — `generate_dataset_vectorized()` does not accept `negative_ratio` parameter
|
||||||
|
|
||||||
|
**Step 3: Implement HOLD negative sampling in generate_dataset_vectorized**
|
||||||
|
|
||||||
|
`src/dataset_builder.py`의 `generate_dataset_vectorized()` 함수를 수정한다.
|
||||||
|
시그니처에 `negative_ratio: int = 0` 파라미터를 추가하고, HOLD 캔들 샘플링 로직을 삽입한다.
|
||||||
|
|
||||||
|
수정 대상: `generate_dataset_vectorized` 함수 전체.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def generate_dataset_vectorized(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
btc_df: pd.DataFrame | None = None,
|
||||||
|
eth_df: pd.DataFrame | None = None,
|
||||||
|
time_weight_decay: float = 0.0,
|
||||||
|
negative_ratio: int = 0,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
전체 시계열을 1회 계산해 학습 데이터셋을 생성한다.
|
||||||
|
|
||||||
|
negative_ratio: 시그널 샘플 대비 HOLD negative 샘플 비율.
|
||||||
|
0이면 기존 동작 (시그널만). 5면 시그널의 5배만큼 HOLD 샘플 추가.
|
||||||
|
"""
|
||||||
|
print(" [1/3] 전체 시계열 지표 계산 (1회)...")
|
||||||
|
d = _calc_indicators(df)
|
||||||
|
|
||||||
|
print(" [2/3] 신호 마스킹 및 피처 추출...")
|
||||||
|
signal_arr = _calc_signals(d)
|
||||||
|
feat_all = _calc_features_vectorized(d, signal_arr, btc_df=btc_df, eth_df=eth_df)
|
||||||
|
|
||||||
|
# 신호 발생 + NaN 없음 + 미래 데이터 충분한 인덱스만
|
||||||
|
OPTIONAL_COLS = {"oi_change", "funding_rate"}
|
||||||
|
available_cols_for_nan_check = [
|
||||||
|
c for c in FEATURE_COLS
|
||||||
|
if c in feat_all.columns and c not in OPTIONAL_COLS
|
||||||
|
]
|
||||||
|
base_valid = (
|
||||||
|
(~feat_all[available_cols_for_nan_check].isna().any(axis=1).values) &
|
||||||
|
(np.arange(len(d)) >= WARMUP) &
|
||||||
|
(np.arange(len(d)) < len(d) - LOOKAHEAD)
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 시그널 캔들 (기존 로직) ---
|
||||||
|
sig_valid = base_valid & (signal_arr != "HOLD")
|
||||||
|
sig_idx = np.where(sig_valid)[0]
|
||||||
|
print(f" 신호 발생 인덱스: {len(sig_idx):,}개")
|
||||||
|
|
||||||
|
print(" [3/3] 레이블 계산...")
|
||||||
|
labels, valid_mask = _calc_labels_vectorized(d, feat_all, sig_idx)
|
||||||
|
|
||||||
|
final_sig_idx = sig_idx[valid_mask]
|
||||||
|
available_feature_cols = [c for c in FEATURE_COLS if c in feat_all.columns]
|
||||||
|
feat_signal = feat_all.iloc[final_sig_idx][available_feature_cols].copy()
|
||||||
|
feat_signal["label"] = labels
|
||||||
|
feat_signal["source"] = "signal"
|
||||||
|
|
||||||
|
# --- HOLD negative 캔들 ---
|
||||||
|
if negative_ratio > 0 and len(final_sig_idx) > 0:
|
||||||
|
hold_valid = base_valid & (signal_arr == "HOLD")
|
||||||
|
hold_candidates = np.where(hold_valid)[0]
|
||||||
|
n_neg = min(len(hold_candidates), len(final_sig_idx) * negative_ratio)
|
||||||
|
|
||||||
|
if n_neg > 0:
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
hold_idx = rng.choice(hold_candidates, size=n_neg, replace=False)
|
||||||
|
hold_idx = np.sort(hold_idx)
|
||||||
|
|
||||||
|
feat_hold = feat_all.iloc[hold_idx][available_feature_cols].copy()
|
||||||
|
feat_hold["label"] = 0
|
||||||
|
feat_hold["source"] = "hold_negative"
|
||||||
|
|
||||||
|
# HOLD 캔들은 시그널이 없으므로 side를 랜덤 할당 (50:50)
|
||||||
|
sides = rng.integers(0, 2, size=len(feat_hold)).astype(np.float32)
|
||||||
|
feat_hold["side"] = sides
|
||||||
|
# signal_strength는 이미 0 (시그널 미발생이므로)
|
||||||
|
|
||||||
|
print(f" HOLD negative 추가: {len(feat_hold):,}개 "
|
||||||
|
f"(비율 1:{negative_ratio})")
|
||||||
|
|
||||||
|
feat_final = pd.concat([feat_signal, feat_hold], ignore_index=True)
|
||||||
|
# 시간 순서 복원 (원본 인덱스 기반 정렬)
|
||||||
|
original_order = np.concatenate([final_sig_idx, hold_idx])
|
||||||
|
sort_order = np.argsort(original_order)
|
||||||
|
feat_final = feat_final.iloc[sort_order].reset_index(drop=True)
|
||||||
|
else:
|
||||||
|
feat_final = feat_signal.reset_index(drop=True)
|
||||||
|
else:
|
||||||
|
feat_final = feat_signal.reset_index(drop=True)
|
||||||
|
|
||||||
|
# 시간 가중치
|
||||||
|
n = len(feat_final)
|
||||||
|
if time_weight_decay > 0 and n > 1:
|
||||||
|
weights = np.exp(time_weight_decay * np.linspace(0.0, 1.0, n)).astype(np.float32)
|
||||||
|
weights /= weights.mean()
|
||||||
|
print(f" 시간 가중치 적용 (decay={time_weight_decay}): "
|
||||||
|
f"min={weights.min():.3f}, max={weights.max():.3f}")
|
||||||
|
else:
|
||||||
|
weights = np.ones(n, dtype=np.float32)
|
||||||
|
|
||||||
|
feat_final["sample_weight"] = weights
|
||||||
|
|
||||||
|
total_sig = (feat_final["source"] == "signal").sum() if "source" in feat_final.columns else len(feat_final)
|
||||||
|
total_hold = (feat_final["source"] == "hold_negative").sum() if "source" in feat_final.columns else 0
|
||||||
|
print(f" 최종 데이터셋: {n:,}개 (시그널={total_sig:,}, HOLD={total_hold:,})")
|
||||||
|
|
||||||
|
return feat_final
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run the new tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_dataset_builder.py::test_hold_negative_labels_are_all_zero tests/test_dataset_builder.py::test_signal_samples_preserved_after_sampling -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Run all existing dataset_builder tests to verify no regressions**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_dataset_builder.py -v`
|
||||||
|
Expected: All existing tests PASS (기존 동작은 negative_ratio=0 기본값으로 유지)
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/dataset_builder.py tests/test_dataset_builder.py
|
||||||
|
git commit -m "feat: add HOLD negative sampling to dataset builder"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 계층적 언더샘플링 헬퍼 함수
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/dataset_builder.py` (파일 끝에 헬퍼 추가)
|
||||||
|
- Test: `tests/test_dataset_builder.py`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_stratified_undersample_preserves_signal():
|
||||||
|
"""stratified_undersample은 signal 샘플을 전수 유지해야 한다."""
|
||||||
|
from src.dataset_builder import stratified_undersample
|
||||||
|
|
||||||
|
y = np.array([1, 0, 0, 0, 0, 0, 0, 0, 1, 0])
|
||||||
|
source = np.array(["signal", "signal", "signal", "hold_negative",
|
||||||
|
"hold_negative", "hold_negative", "hold_negative",
|
||||||
|
"hold_negative", "signal", "signal"])
|
||||||
|
|
||||||
|
idx = stratified_undersample(y, source, seed=42)
|
||||||
|
|
||||||
|
# signal 인덱스: 0, 1, 2, 8, 9 → 전부 포함
|
||||||
|
signal_indices = np.where(source == "signal")[0]
|
||||||
|
for si in signal_indices:
|
||||||
|
assert si in idx, f"signal 인덱스 {si}가 누락됨"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_dataset_builder.py::test_stratified_undersample_preserves_signal -v`
|
||||||
|
Expected: FAIL — `stratified_undersample` 함수 미존재
|
||||||
|
|
||||||
|
**Step 3: Implement stratified_undersample**
|
||||||
|
|
||||||
|
`src/dataset_builder.py` 끝에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def stratified_undersample(
|
||||||
|
y: np.ndarray,
|
||||||
|
source: np.ndarray,
|
||||||
|
seed: int = 42,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Signal 샘플 전수 유지 + HOLD negative만 양성 수 만큼 샘플링.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
y: 라벨 배열 (0 or 1)
|
||||||
|
source: 소스 배열 ("signal" or "hold_negative")
|
||||||
|
seed: 랜덤 시드
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
정렬된 인덱스 배열 (학습에 사용할 행 인덱스)
|
||||||
|
"""
|
||||||
|
pos_idx = np.where(y == 1)[0] # Signal Win
|
||||||
|
sig_neg_idx = np.where((y == 0) & (source == "signal"))[0] # Signal Loss
|
||||||
|
hold_neg_idx = np.where(source == "hold_negative")[0] # HOLD negative
|
||||||
|
|
||||||
|
# HOLD negative에서 양성 수 만큼만 샘플링
|
||||||
|
n_hold = min(len(hold_neg_idx), len(pos_idx))
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
if n_hold > 0:
|
||||||
|
hold_sampled = rng.choice(hold_neg_idx, size=n_hold, replace=False)
|
||||||
|
else:
|
||||||
|
hold_sampled = np.array([], dtype=np.intp)
|
||||||
|
|
||||||
|
return np.sort(np.concatenate([pos_idx, sig_neg_idx, hold_sampled]))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_dataset_builder.py::test_stratified_undersample_preserves_signal -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/dataset_builder.py tests/test_dataset_builder.py
|
||||||
|
git commit -m "feat: add stratified_undersample helper function"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: train_model.py — 계층적 언더샘플링 적용
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/train_model.py:229-257` (train 함수)
|
||||||
|
- Modify: `scripts/train_model.py:356-391` (walk_forward_auc 함수)
|
||||||
|
|
||||||
|
**Step 1: Update train() function**
|
||||||
|
|
||||||
|
`scripts/train_model.py`에서 `dataset_builder`에서 `stratified_undersample`을 import하고,
|
||||||
|
`train()` 함수의 언더샘플링 블록을 교체한다.
|
||||||
|
|
||||||
|
import 수정 (line 25):
|
||||||
|
```python
|
||||||
|
from src.dataset_builder import generate_dataset_vectorized, stratified_undersample
|
||||||
|
```
|
||||||
|
|
||||||
|
`train()` 함수에서 데이터셋 생성 호출에 `negative_ratio=5` 추가 (line 217):
|
||||||
|
```python
|
||||||
|
dataset = generate_dataset_vectorized(
|
||||||
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
|
time_weight_decay=time_weight_decay,
|
||||||
|
negative_ratio=5,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
source 배열 추출 추가 (line 231 부근, w 다음):
|
||||||
|
```python
|
||||||
|
source = dataset["source"].values if "source" in dataset.columns else np.full(len(X), "signal")
|
||||||
|
```
|
||||||
|
|
||||||
|
언더샘플링 블록 교체 (line 241-257):
|
||||||
|
```python
|
||||||
|
# --- 계층적 샘플링: signal 전수 유지, HOLD negative만 양성 수 만큼 ---
|
||||||
|
source_train = source[:split]
|
||||||
|
balanced_idx = stratified_undersample(y_train.values, source_train, seed=42)
|
||||||
|
|
||||||
|
X_train = X_train.iloc[balanced_idx]
|
||||||
|
y_train = y_train.iloc[balanced_idx]
|
||||||
|
w_train = w_train[balanced_idx]
|
||||||
|
|
||||||
|
sig_count = (source_train[balanced_idx] == "signal").sum()
|
||||||
|
hold_count = (source_train[balanced_idx] == "hold_negative").sum()
|
||||||
|
print(f"\n계층적 샘플링 후 학습 데이터: {len(X_train)}개 "
|
||||||
|
f"(Signal={sig_count}, HOLD={hold_count}, "
|
||||||
|
f"양성={int(y_train.sum())}, 음성={int((y_train==0).sum())})")
|
||||||
|
print(f"검증 데이터: {len(X_val)}개 (양성={int(y_val.sum())}, 음성={int((y_val==0).sum())})")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update walk_forward_auc() function**
|
||||||
|
|
||||||
|
`walk_forward_auc()` 함수에서도 동일하게 적용.
|
||||||
|
|
||||||
|
dataset 생성 (line 356-358)에 `negative_ratio=5` 추가:
|
||||||
|
```python
|
||||||
|
dataset = generate_dataset_vectorized(
|
||||||
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
|
time_weight_decay=time_weight_decay,
|
||||||
|
negative_ratio=5,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
source 배열 추출 (line 362 부근):
|
||||||
|
```python
|
||||||
|
source = dataset["source"].values if "source" in dataset.columns else np.full(n, "signal")
|
||||||
|
```
|
||||||
|
|
||||||
|
폴드 내 언더샘플링 교체 (line 381-386):
|
||||||
|
```python
|
||||||
|
source_tr = source[:tr_end]
|
||||||
|
bal_idx = stratified_undersample(y_tr, source_tr, seed=42)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Run training to verify**
|
||||||
|
|
||||||
|
Run: `python scripts/train_model.py --data data/combined_15m.parquet --decay 2.0`
|
||||||
|
Expected: 학습 샘플 수 대폭 증가 확인 (기존 ~535 → ~3,200)
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/train_model.py
|
||||||
|
git commit -m "feat: apply stratified undersampling to training pipeline"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: tune_hyperparams.py — 계층적 언더샘플링 적용
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/tune_hyperparams.py:41-81` (load_dataset)
|
||||||
|
- Modify: `scripts/tune_hyperparams.py:88-144` (_walk_forward_cv)
|
||||||
|
- Modify: `scripts/tune_hyperparams.py:151-206` (make_objective)
|
||||||
|
- Modify: `scripts/tune_hyperparams.py:213-244` (measure_baseline)
|
||||||
|
- Modify: `scripts/tune_hyperparams.py:370-449` (main)
|
||||||
|
|
||||||
|
**Step 1: Update load_dataset to return source**
|
||||||
|
|
||||||
|
import 수정 (line 34):
|
||||||
|
```python
|
||||||
|
from src.dataset_builder import generate_dataset_vectorized, stratified_undersample
|
||||||
|
```
|
||||||
|
|
||||||
|
`load_dataset()` 시그니처와 반환값 수정:
|
||||||
|
```python
|
||||||
|
def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||||
|
```
|
||||||
|
|
||||||
|
dataset 생성에 `negative_ratio=5` 추가 (line 66):
|
||||||
|
```python
|
||||||
|
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=0.0, negative_ratio=5)
|
||||||
|
```
|
||||||
|
|
||||||
|
source 추출 추가 (line 74 부근, w 다음):
|
||||||
|
```python
|
||||||
|
source = dataset["source"].values if "source" in dataset.columns else np.full(len(dataset), "signal")
|
||||||
|
```
|
||||||
|
|
||||||
|
return 수정:
|
||||||
|
```python
|
||||||
|
return X, y, w, source
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update _walk_forward_cv to accept and use source**
|
||||||
|
|
||||||
|
시그니처에 source 추가:
|
||||||
|
```python
|
||||||
|
def _walk_forward_cv(
|
||||||
|
X: np.ndarray,
|
||||||
|
y: np.ndarray,
|
||||||
|
w: np.ndarray,
|
||||||
|
source: np.ndarray,
|
||||||
|
params: dict,
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
폴드 내 언더샘플링 교체 (line 117-122):
|
||||||
|
```python
|
||||||
|
source_tr = source[:tr_end]
|
||||||
|
bal_idx = stratified_undersample(y_tr, source_tr, seed=42)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Update make_objective, measure_baseline, main**
|
||||||
|
|
||||||
|
`make_objective()`: 클로저에 source 캡처, `_walk_forward_cv` 호출에 source 전달
|
||||||
|
`measure_baseline()`: source 파라미터 추가, `_walk_forward_cv` 호출에 전달
|
||||||
|
`main()`: `load_dataset` 반환값 4개로 변경, 하위 함수에 source 전달
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/tune_hyperparams.py
|
||||||
|
git commit -m "feat: apply stratified undersampling to hyperparameter tuning"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: 전체 테스트 실행 및 검증
|
||||||
|
|
||||||
|
**Step 1: Run full test suite**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: All tests PASS
|
||||||
|
|
||||||
|
**Step 2: Run training pipeline end-to-end**
|
||||||
|
|
||||||
|
Run: `python scripts/train_model.py --data data/combined_15m.parquet --decay 2.0`
|
||||||
|
Expected:
|
||||||
|
- 학습 샘플 ~3,200개 (기존 535)
|
||||||
|
- "계층적 샘플링 후" 로그에 Signal/HOLD 카운트 표시
|
||||||
|
- AUC 출력 (값 자체보다 실행 완료가 중요)
|
||||||
|
|
||||||
|
**Step 3: Commit final state**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: verify HOLD negative sampling pipeline end-to-end"
|
||||||
|
```
|
||||||
394
docs/plans/2026-03-02-oi-funding-accumulation.md
Normal file
394
docs/plans/2026-03-02-oi-funding-accumulation.md
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
# OI/펀딩비 누적 저장 (접근법 B) 구현 계획
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** `fetch_history.py`의 데이터 수집 방식을 덮어쓰기(Overwrite)에서 Upsert(병합)로 변경해, 매일 실행할 때마다 기존 parquet의 OI/펀딩비 0.0 구간이 실제 값으로 채워지며 고품질 데이터가 무한히 누적되도록 한다.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- `fetch_history.py`에 `--upsert` 플래그 추가 (기본값 True). 기존 parquet이 있으면 로드 후 신규 데이터와 timestamp 기준 병합(Upsert). 없으면 기존처럼 새로 생성.
|
||||||
|
- Upsert 규칙: 기존 행의 `oi_change` / `funding_rate`가 0.0이면 신규 값으로 덮어씀. 신규 행은 그냥 추가. 중복 제거 후 시간순 정렬.
|
||||||
|
- `train_and_deploy.sh`의 `--days` 인자를 35일로 조정 (30일 API 한도 + 5일 버퍼).
|
||||||
|
- LXC 운영서버는 모델 파일만 받으므로 변경 없음. 맥미니의 `data/` 폴더에만 누적.
|
||||||
|
|
||||||
|
**Tech Stack:** pandas, parquet (pyarrow), pytest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: fetch_history.py — upsert_parquet() 함수 추가 및 --upsert 플래그
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/fetch_history.py`
|
||||||
|
- Test: `tests/test_fetch_history.py` (신규 생성)
|
||||||
|
|
||||||
|
### Step 1: 실패 테스트 작성
|
||||||
|
|
||||||
|
`tests/test_fetch_history.py` 파일을 새로 만든다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""fetch_history.py의 upsert_parquet() 함수 테스트."""
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _make_parquet(tmp_path: Path, rows: dict) -> Path:
|
||||||
|
"""테스트용 parquet 파일 생성 헬퍼."""
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
|
||||||
|
df = df.set_index("timestamp")
|
||||||
|
path = tmp_path / "test.parquet"
|
||||||
|
df.to_parquet(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_fills_zero_oi_with_real_value(tmp_path):
|
||||||
|
"""기존 행의 oi_change=0.0이 신규 데이터의 실제 값으로 덮어써진다."""
|
||||||
|
from scripts.fetch_history import upsert_parquet
|
||||||
|
|
||||||
|
existing_path = _make_parquet(tmp_path, {
|
||||||
|
"timestamp": ["2026-01-01 00:00", "2026-01-01 00:15"],
|
||||||
|
"close": [1.0, 1.1],
|
||||||
|
"oi_change": [0.0, 0.0],
|
||||||
|
"funding_rate": [0.0, 0.0],
|
||||||
|
})
|
||||||
|
|
||||||
|
new_df = pd.DataFrame({
|
||||||
|
"close": [1.0, 1.1],
|
||||||
|
"oi_change": [0.05, 0.03],
|
||||||
|
"funding_rate": [0.0001, 0.0001],
|
||||||
|
}, index=pd.to_datetime(["2026-01-01 00:00", "2026-01-01 00:15"], utc=True))
|
||||||
|
new_df.index.name = "timestamp"
|
||||||
|
|
||||||
|
result = upsert_parquet(existing_path, new_df)
|
||||||
|
|
||||||
|
assert result.loc["2026-01-01 00:00+00:00", "oi_change"] == pytest.approx(0.05)
|
||||||
|
assert result.loc["2026-01-01 00:15+00:00", "oi_change"] == pytest.approx(0.03)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_appends_new_rows(tmp_path):
|
||||||
|
"""신규 타임스탬프 행이 기존 데이터 아래에 추가된다."""
|
||||||
|
from scripts.fetch_history import upsert_parquet
|
||||||
|
|
||||||
|
existing_path = _make_parquet(tmp_path, {
|
||||||
|
"timestamp": ["2026-01-01 00:00"],
|
||||||
|
"close": [1.0],
|
||||||
|
"oi_change": [0.05],
|
||||||
|
"funding_rate": [0.0001],
|
||||||
|
})
|
||||||
|
|
||||||
|
new_df = pd.DataFrame({
|
||||||
|
"close": [1.1],
|
||||||
|
"oi_change": [0.03],
|
||||||
|
"funding_rate": [0.0002],
|
||||||
|
}, index=pd.to_datetime(["2026-01-01 00:15"], utc=True))
|
||||||
|
new_df.index.name = "timestamp"
|
||||||
|
|
||||||
|
result = upsert_parquet(existing_path, new_df)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
assert "2026-01-01 00:15+00:00" in result.index.astype(str).tolist() or \
|
||||||
|
pd.Timestamp("2026-01-01 00:15", tz="UTC") in result.index
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_keeps_nonzero_existing_oi(tmp_path):
|
||||||
|
"""기존 행의 oi_change가 이미 0이 아니면 덮어쓰지 않는다."""
|
||||||
|
from scripts.fetch_history import upsert_parquet
|
||||||
|
|
||||||
|
existing_path = _make_parquet(tmp_path, {
|
||||||
|
"timestamp": ["2026-01-01 00:00"],
|
||||||
|
"close": [1.0],
|
||||||
|
"oi_change": [0.07], # 이미 실제 값 존재
|
||||||
|
"funding_rate": [0.0003],
|
||||||
|
})
|
||||||
|
|
||||||
|
new_df = pd.DataFrame({
|
||||||
|
"close": [1.0],
|
||||||
|
"oi_change": [0.05], # 다른 값으로 덮어쓰려 해도
|
||||||
|
"funding_rate": [0.0001],
|
||||||
|
}, index=pd.to_datetime(["2026-01-01 00:00"], utc=True))
|
||||||
|
new_df.index.name = "timestamp"
|
||||||
|
|
||||||
|
result = upsert_parquet(existing_path, new_df)
|
||||||
|
|
||||||
|
# 기존 값(0.07)이 유지되어야 한다
|
||||||
|
assert result.iloc[0]["oi_change"] == pytest.approx(0.07)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_no_existing_file_returns_new_df(tmp_path):
|
||||||
|
"""기존 parquet 파일이 없으면 신규 데이터를 그대로 반환한다."""
|
||||||
|
from scripts.fetch_history import upsert_parquet
|
||||||
|
|
||||||
|
nonexistent_path = tmp_path / "nonexistent.parquet"
|
||||||
|
new_df = pd.DataFrame({
|
||||||
|
"close": [1.0, 1.1],
|
||||||
|
"oi_change": [0.05, 0.03],
|
||||||
|
"funding_rate": [0.0001, 0.0001],
|
||||||
|
}, index=pd.to_datetime(["2026-01-01 00:00", "2026-01-01 00:15"], utc=True))
|
||||||
|
new_df.index.name = "timestamp"
|
||||||
|
|
||||||
|
result = upsert_parquet(nonexistent_path, new_df)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result.iloc[0]["oi_change"] == pytest.approx(0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_result_is_sorted_by_timestamp(tmp_path):
|
||||||
|
"""결과 DataFrame이 timestamp 기준 오름차순 정렬되어 있다."""
|
||||||
|
from scripts.fetch_history import upsert_parquet
|
||||||
|
|
||||||
|
existing_path = _make_parquet(tmp_path, {
|
||||||
|
"timestamp": ["2026-01-01 00:15"],
|
||||||
|
"close": [1.1],
|
||||||
|
"oi_change": [0.0],
|
||||||
|
"funding_rate": [0.0],
|
||||||
|
})
|
||||||
|
|
||||||
|
new_df = pd.DataFrame({
|
||||||
|
"close": [1.0, 1.1, 1.2],
|
||||||
|
"oi_change": [0.05, 0.03, 0.02],
|
||||||
|
"funding_rate": [0.0001, 0.0001, 0.0002],
|
||||||
|
}, index=pd.to_datetime(
|
||||||
|
["2026-01-01 00:00", "2026-01-01 00:15", "2026-01-01 00:30"], utc=True
|
||||||
|
))
|
||||||
|
new_df.index.name = "timestamp"
|
||||||
|
|
||||||
|
result = upsert_parquet(existing_path, new_df)
|
||||||
|
|
||||||
|
assert result.index.is_monotonic_increasing
|
||||||
|
assert len(result) == 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: 테스트 실패 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/pytest tests/test_fetch_history.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `FAILED` — `ImportError: cannot import name 'upsert_parquet' from 'scripts.fetch_history'`
|
||||||
|
|
||||||
|
### Step 3: fetch_history.py에 upsert_parquet() 함수 구현
|
||||||
|
|
||||||
|
`scripts/fetch_history.py`의 `main()` 함수 바로 위에 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def upsert_parquet(path: Path | str, new_df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
기존 parquet 파일에 신규 데이터를 Upsert(병합)한다.
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
- 기존 행의 oi_change / funding_rate가 0.0이면 신규 값으로 덮어씀
|
||||||
|
- 기존 행의 oi_change / funding_rate가 이미 0이 아니면 유지
|
||||||
|
- 신규 타임스탬프 행은 그냥 추가
|
||||||
|
- 결과는 timestamp 기준 오름차순 정렬, 중복 제거
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: 기존 parquet 경로 (없으면 new_df 그대로 반환)
|
||||||
|
new_df: 새로 수집한 DataFrame (timestamp index)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
병합된 DataFrame
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
if not path.exists():
|
||||||
|
return new_df.sort_index()
|
||||||
|
|
||||||
|
existing = pd.read_parquet(path)
|
||||||
|
|
||||||
|
# timestamp index 통일 (tz-aware UTC)
|
||||||
|
if existing.index.tz is None:
|
||||||
|
existing.index = existing.index.tz_localize("UTC")
|
||||||
|
if new_df.index.tz is None:
|
||||||
|
new_df.index = new_df.index.tz_localize("UTC")
|
||||||
|
|
||||||
|
# 기존 데이터에서 oi_change / funding_rate가 0.0인 행만 신규 값으로 업데이트
|
||||||
|
UPSERT_COLS = ["oi_change", "funding_rate"]
|
||||||
|
overlap_idx = existing.index.intersection(new_df.index)
|
||||||
|
|
||||||
|
for col in UPSERT_COLS:
|
||||||
|
if col not in existing.columns or col not in new_df.columns:
|
||||||
|
continue
|
||||||
|
# 겹치는 행 중 기존 값이 0.0인 경우에만 신규 값으로 교체
|
||||||
|
zero_mask = existing.loc[overlap_idx, col] == 0.0
|
||||||
|
update_idx = overlap_idx[zero_mask]
|
||||||
|
if len(update_idx) > 0:
|
||||||
|
existing.loc[update_idx, col] = new_df.loc[update_idx, col]
|
||||||
|
|
||||||
|
# 신규 타임스탬프 행 추가 (기존에 없는 것만)
|
||||||
|
new_only_idx = new_df.index.difference(existing.index)
|
||||||
|
if len(new_only_idx) > 0:
|
||||||
|
existing = pd.concat([existing, new_df.loc[new_only_idx]])
|
||||||
|
|
||||||
|
return existing.sort_index()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: main()에 --upsert 플래그 추가 및 저장 로직 수정
|
||||||
|
|
||||||
|
`main()` 함수의 `parser` 정의 부분에 인자 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-upsert", action="store_true",
|
||||||
|
help="기존 parquet을 Upsert하지 않고 새로 덮어씀 (기본: Upsert 활성화)",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
그리고 단일 심볼 저장 부분:
|
||||||
|
```python
|
||||||
|
# 기존:
|
||||||
|
df.to_parquet(args.output)
|
||||||
|
|
||||||
|
# 변경:
|
||||||
|
if not args.no_upsert:
|
||||||
|
df = upsert_parquet(args.output, df)
|
||||||
|
df.to_parquet(args.output)
|
||||||
|
```
|
||||||
|
|
||||||
|
멀티 심볼 저장 부분도 동일하게:
|
||||||
|
```python
|
||||||
|
# 기존:
|
||||||
|
merged.to_parquet(output)
|
||||||
|
|
||||||
|
# 변경:
|
||||||
|
if not args.no_upsert:
|
||||||
|
merged = upsert_parquet(output, merged)
|
||||||
|
merged.to_parquet(output)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: 테스트 통과 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/pytest tests/test_fetch_history.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 전체 PASS
|
||||||
|
|
||||||
|
### Step 6: 커밋
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/fetch_history.py tests/test_fetch_history.py
|
||||||
|
git commit -m "feat: add upsert_parquet to accumulate OI/funding data incrementally"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: train_and_deploy.sh — 데이터 수집 일수 35일로 조정
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/train_and_deploy.sh`
|
||||||
|
|
||||||
|
### Step 1: 현재 상태 확인
|
||||||
|
|
||||||
|
`scripts/train_and_deploy.sh`에서 `--days 365` 부분을 찾는다.
|
||||||
|
|
||||||
|
### Step 2: 수정
|
||||||
|
|
||||||
|
`train_and_deploy.sh`에서 `fetch_history.py` 호출 부분을 수정한다.
|
||||||
|
|
||||||
|
기존:
|
||||||
|
```bash
|
||||||
|
python scripts/fetch_history.py \
|
||||||
|
--symbols XRPUSDT BTCUSDT ETHUSDT \
|
||||||
|
--interval 15m \
|
||||||
|
--days 365 \
|
||||||
|
--output data/combined_15m.parquet
|
||||||
|
```
|
||||||
|
|
||||||
|
변경:
|
||||||
|
```bash
|
||||||
|
# OI/펀딩비 API 제한(30일) + 버퍼 5일 = 35일치 신규 수집 후 기존 parquet에 Upsert
|
||||||
|
python scripts/fetch_history.py \
|
||||||
|
--symbols XRPUSDT BTCUSDT ETHUSDT \
|
||||||
|
--interval 15m \
|
||||||
|
--days 35 \
|
||||||
|
--output data/combined_15m.parquet
|
||||||
|
```
|
||||||
|
|
||||||
|
**이유**: 매일 실행 시 35일치만 새로 가져와 기존 누적 parquet에 Upsert한다.
|
||||||
|
- 최초 실행 시(`data/combined_15m.parquet` 없음): 35일치로 시작
|
||||||
|
- 이후 매일: 35일치 신규 데이터로 기존 파일의 0.0 구간을 채우고 최신 행 추가
|
||||||
|
- 시간이 지날수록 OI/펀딩비 실제 값이 있는 구간이 1달 → 2달 → ... 로 늘어남
|
||||||
|
|
||||||
|
**주의**: 최초 실행 시 캔들 데이터도 35일치만 있으므로, 첫 실행은 수동으로
|
||||||
|
`--days 365 --no-upsert`로 전체 캔들을 먼저 수집하는 것을 권장한다.
|
||||||
|
README에 이 내용을 추가한다.
|
||||||
|
|
||||||
|
### Step 3: 커밋
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/train_and_deploy.sh
|
||||||
|
git commit -m "feat: fetch 35 days for daily upsert instead of overwriting 365 days"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: 전체 테스트 통과 확인 및 README 업데이트
|
||||||
|
|
||||||
|
### Step 1: 전체 테스트 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/pytest tests/ --ignore=tests/test_mlx_filter.py --ignore=tests/test_database.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 전체 PASS
|
||||||
|
|
||||||
|
### Step 2: README.md 업데이트
|
||||||
|
|
||||||
|
**"ML 모델 학습" 섹션의 "전체 파이프라인 (권장)" 부분 아래에 아래 내용을 추가한다:**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### 최초 실행 (캔들 전체 수집)
|
||||||
|
|
||||||
|
처음 실행하거나 `data/combined_15m.parquet`가 없을 때는 전체 캔들을 먼저 수집한다.
|
||||||
|
이후 매일 크론탭이 `train_and_deploy.sh`를 실행하면 35일치 신규 데이터가 자동으로 Upsert된다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 최초 1회: 1년치 캔들 전체 수집 (OI/펀딩비는 최근 30일만 실제 값, 나머지 0.0)
|
||||||
|
python scripts/fetch_history.py \
|
||||||
|
--symbols XRPUSDT BTCUSDT ETHUSDT \
|
||||||
|
--interval 15m \
|
||||||
|
--days 365 \
|
||||||
|
--no-upsert \
|
||||||
|
--output data/combined_15m.parquet
|
||||||
|
|
||||||
|
# 이후 매일 자동 실행 (크론탭 또는 train_and_deploy.sh):
|
||||||
|
# 35일치 신규 데이터를 기존 파일에 Upsert → OI/펀딩비 0.0 구간이 야금야금 채워짐
|
||||||
|
bash scripts/train_and_deploy.sh
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
**"주요 기능" 섹션에 아래 항목 추가:**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **OI/펀딩비 누적 학습**: 매일 35일치 신규 데이터를 기존 parquet에 Upsert. 시간이 지날수록 실제 OI/펀딩비 값이 있는 학습 구간이 1달 → 2달 → 반년으로 늘어남
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: 최종 커밋
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md
|
||||||
|
git commit -m "docs: document OI/funding incremental accumulation strategy"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 후 검증 포인트
|
||||||
|
|
||||||
|
1. `data/combined_15m.parquet`에서 날짜별 `oi_change` 값 분포 확인:
|
||||||
|
```python
|
||||||
|
import pandas as pd
|
||||||
|
df = pd.read_parquet("data/combined_15m.parquet")
|
||||||
|
print(df["oi_change"].describe())
|
||||||
|
print((df["oi_change"] == 0.0).sum(), "개 행이 아직 0.0")
|
||||||
|
```
|
||||||
|
2. 매일 실행 후 0.0 행 수가 줄어드는지 확인
|
||||||
|
3. 모델 학습 시 `oi_change` / `funding_rate` 피처의 non-zero 비율이 증가하는지 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 아키텍처 메모 (LXC 운영서버 관련)
|
||||||
|
|
||||||
|
- **LXC 운영서버(10.1.10.24)**: 변경 없음. 모델 파일(`*.pkl` / `*.onnx`)만 받음
|
||||||
|
- **맥미니**: `data/combined_15m.parquet`를 누적 보관. 매일 35일치 Upsert 후 학습
|
||||||
|
- **데이터 흐름**: 맥미니 parquet 누적 → 학습 → 모델 → LXC 배포
|
||||||
|
- **봇 실시간 OI/펀딩비**: 접근법 A(Task 1~4)에서 이미 구현됨. LXC 봇이 캔들마다 REST API로 실시간 수집
|
||||||
184
docs/plans/2026-03-02-optuna-hyperparam-tuning-design.md
Normal file
184
docs/plans/2026-03-02-optuna-hyperparam-tuning-design.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# Optuna 하이퍼파라미터 자동 튜닝 설계 문서
|
||||||
|
|
||||||
|
**작성일:** 2026-03-02
|
||||||
|
**목표:** 봇 운영 로그/학습 결과를 바탕으로 LightGBM 하이퍼파라미터를 Optuna로 자동 탐색하고, 사람이 결과를 확인·승인한 후 재학습에 반영하는 수동 트리거 파이프라인 구축
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 배경 및 동기
|
||||||
|
|
||||||
|
현재 `train_model.py`의 LightGBM 파라미터는 하드코딩되어 있다. 봇 성능이 저하되거나 데이터가 축적될 때마다 사람이 직접 파라미터를 조정해야 한다. 이를 Optuna로 자동화하되, 과적합 위험을 방지하기 위해 **사람이 결과를 먼저 확인하고 승인하는 구조**를 유지한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 범위 (2단계)
|
||||||
|
|
||||||
|
### 1단계 (현재): LightGBM 하이퍼파라미터 튜닝
|
||||||
|
- `scripts/tune_hyperparams.py` 신규 생성
|
||||||
|
- Optuna + Walk-Forward AUC 목적 함수
|
||||||
|
- 결과를 JSON + 콘솔 리포트로 출력
|
||||||
|
|
||||||
|
### 2단계 (추후): 기술 지표 파라미터 확장
|
||||||
|
- RSI 임계값, MACD 가중치, Stochastic RSI 임계값, 거래량 배수, 진입 점수 임계값 등을 탐색 공간에 추가
|
||||||
|
- `dataset_builder.py`의 `_calc_signals()` 파라미터화 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/tune_hyperparams.py
|
||||||
|
├── load_dataset() ← 데이터 로드 + 벡터화 데이터셋 1회 생성 (캐싱)
|
||||||
|
├── objective(trial, dataset) ← Optuna trial 함수
|
||||||
|
│ ├── trial.suggest_*() ← 하이퍼파라미터 샘플링
|
||||||
|
│ ├── num_leaves 상한 강제 ← 2^max_depth - 1 제약
|
||||||
|
│ └── _walk_forward_cv() ← Walk-Forward 교차검증 → 평균 AUC 반환
|
||||||
|
├── run_study() ← Optuna study 실행 (TPESampler + MedianPruner)
|
||||||
|
├── print_report() ← 콘솔 리포트 출력
|
||||||
|
└── save_results() ← JSON 저장 (models/tune_results_YYYYMMDD_HHMMSS.json)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 탐색 공간 (소규모 데이터셋 보수적 설계)
|
||||||
|
|
||||||
|
| 파라미터 | 범위 | 타입 | 근거 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `n_estimators` | 100 ~ 600 | int | 데이터 적을 때 500+ 트리는 과적합 |
|
||||||
|
| `learning_rate` | 0.01 ~ 0.2 | float (log) | 낮을수록 일반화 유리 |
|
||||||
|
| `max_depth` | 2 ~ 7 | int | 트리 깊이 상한 강제 |
|
||||||
|
| `num_leaves` | 7 ~ min(31, 2^max_depth-1) | int | **핵심**: leaf-wise 과적합 방지 |
|
||||||
|
| `min_child_samples` | 10 ~ 50 | int | 리프당 최소 샘플 수 |
|
||||||
|
| `subsample` | 0.5 ~ 1.0 | float | 행 샘플링 |
|
||||||
|
| `colsample_bytree` | 0.5 ~ 1.0 | float | 열 샘플링 |
|
||||||
|
| `reg_alpha` | 1e-4 ~ 1.0 | float (log) | L1 정규화 |
|
||||||
|
| `reg_lambda` | 1e-4 ~ 1.0 | float (log) | L2 정규화 |
|
||||||
|
| `time_weight_decay` | 0.5 ~ 4.0 | float | 시간 가중치 강도 |
|
||||||
|
|
||||||
|
### 핵심 제약: `num_leaves <= 2^max_depth - 1`
|
||||||
|
|
||||||
|
LightGBM은 leaf-wise 성장 전략을 사용하므로, `num_leaves`가 `2^max_depth - 1`을 초과하면 `max_depth` 제약이 무의미해진다. trial 내에서 `max_depth`를 먼저 샘플링한 후 `num_leaves` 상한을 동적으로 계산하여 강제한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
max_depth = trial.suggest_int("max_depth", 2, 7)
|
||||||
|
max_leaves = min(31, 2 ** max_depth - 1)
|
||||||
|
num_leaves = trial.suggest_int("num_leaves", 7, max_leaves)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목적 함수: Walk-Forward AUC
|
||||||
|
|
||||||
|
기존 `train_model.py`의 `walk_forward_auc()` 로직을 재활용한다. 데이터셋은 study 시작 전 1회만 생성하여 모든 trial이 공유한다 (속도 최적화).
|
||||||
|
|
||||||
|
```
|
||||||
|
전체 데이터셋 (N개 샘플)
|
||||||
|
├── 폴드 1: 학습[0:60%] → 검증[60%:68%]
|
||||||
|
├── 폴드 2: 학습[0:68%] → 검증[68%:76%]
|
||||||
|
├── 폴드 3: 학습[0:76%] → 검증[76%:84%]
|
||||||
|
├── 폴드 4: 학습[0:84%] → 검증[84%:92%]
|
||||||
|
└── 폴드 5: 학습[0:92%] → 검증[92%:100%]
|
||||||
|
목적 함수 = 5폴드 평균 AUC (최대화)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pruning (조기 종료)
|
||||||
|
|
||||||
|
`MedianPruner` 적용: 각 폴드 완료 후 중간 AUC를 Optuna에 보고. 이전 trial들의 중앙값보다 낮으면 나머지 폴드를 건너뛰고 trial 종료. 전체 탐색 시간 ~40% 단축 효과.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 출력 형식
|
||||||
|
|
||||||
|
### 콘솔 리포트
|
||||||
|
|
||||||
|
```
|
||||||
|
============================================================
|
||||||
|
Optuna 튜닝 완료 | 50 trials | 소요: 28분 42초
|
||||||
|
============================================================
|
||||||
|
Best AUC : 0.6234 (Trial #31)
|
||||||
|
Baseline : 0.5891 (현재 train_model.py 고정값)
|
||||||
|
개선폭 : +0.0343 (+5.8%)
|
||||||
|
------------------------------------------------------------
|
||||||
|
Best Parameters:
|
||||||
|
n_estimators : 320
|
||||||
|
learning_rate : 0.0412
|
||||||
|
max_depth : 4
|
||||||
|
num_leaves : 15
|
||||||
|
min_child_samples : 28
|
||||||
|
subsample : 0.72
|
||||||
|
colsample_bytree : 0.81
|
||||||
|
reg_alpha : 0.0023
|
||||||
|
reg_lambda : 0.0891
|
||||||
|
time_weight_decay : 2.31
|
||||||
|
------------------------------------------------------------
|
||||||
|
Walk-Forward 폴드별 AUC:
|
||||||
|
폴드 1: 0.6102
|
||||||
|
폴드 2: 0.6341
|
||||||
|
폴드 3: 0.6198
|
||||||
|
폴드 4: 0.6287
|
||||||
|
폴드 5: 0.6241
|
||||||
|
평균: 0.6234 ± 0.0082
|
||||||
|
------------------------------------------------------------
|
||||||
|
결과 저장: models/tune_results_20260302_143022.json
|
||||||
|
다음 단계: python scripts/train_model.py --tuned-params models/tune_results_20260302_143022.json
|
||||||
|
============================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON 저장 (`models/tune_results_YYYYMMDD_HHMMSS.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-03-02T14:30:22",
|
||||||
|
"n_trials": 50,
|
||||||
|
"elapsed_sec": 1722,
|
||||||
|
"baseline_auc": 0.5891,
|
||||||
|
"best_trial": {
|
||||||
|
"number": 31,
|
||||||
|
"auc": 0.6234,
|
||||||
|
"fold_aucs": [0.6102, 0.6341, 0.6198, 0.6287, 0.6241],
|
||||||
|
"params": { ... }
|
||||||
|
},
|
||||||
|
"all_trials": [ ... ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사용법
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 기본 실행 (50 trials, 5폴드)
|
||||||
|
python scripts/tune_hyperparams.py
|
||||||
|
|
||||||
|
# 빠른 테스트 (10 trials, 3폴드)
|
||||||
|
python scripts/tune_hyperparams.py --trials 10 --folds 3
|
||||||
|
|
||||||
|
# 데이터 경로 지정
|
||||||
|
python scripts/tune_hyperparams.py --data data/combined_15m.parquet --trials 100
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 변경 목록
|
||||||
|
|
||||||
|
| 파일 | 변경 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| `scripts/tune_hyperparams.py` | **신규 생성** | Optuna 튜닝 스크립트 |
|
||||||
|
| `requirements.txt` | **수정** | `optuna` 의존성 추가 |
|
||||||
|
| `README.md` | **수정** | 튜닝 사용법 섹션 추가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 향후 확장 (2단계)
|
||||||
|
|
||||||
|
`dataset_builder.py`의 `_calc_signals()` 함수를 파라미터화하여 기술 지표 임계값도 탐색 공간에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 추가될 탐색 공간 예시
|
||||||
|
rsi_long_threshold = trial.suggest_int("rsi_long", 25, 40)
|
||||||
|
rsi_short_threshold = trial.suggest_int("rsi_short", 60, 75)
|
||||||
|
vol_surge_mult = trial.suggest_float("vol_surge_mult", 1.2, 2.5)
|
||||||
|
entry_threshold = trial.suggest_int("entry_threshold", 3, 5)
|
||||||
|
stoch_low = trial.suggest_int("stoch_low", 10, 30)
|
||||||
|
stoch_high = trial.suggest_int("stoch_high", 70, 90)
|
||||||
|
```
|
||||||
569
docs/plans/2026-03-02-optuna-hyperparam-tuning-plan.md
Normal file
569
docs/plans/2026-03-02-optuna-hyperparam-tuning-plan.md
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
# Optuna 하이퍼파라미터 자동 튜닝 Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** `scripts/tune_hyperparams.py`를 신규 생성하여 Optuna + Walk-Forward AUC 기반 LightGBM 하이퍼파라미터 자동 탐색 파이프라인을 구축한다.
|
||||||
|
|
||||||
|
**Architecture:** 데이터셋을 study 시작 전 1회만 생성해 캐싱하고, 각 Optuna trial에서 LightGBM 파라미터를 샘플링 → Walk-Forward 5폴드 AUC를 목적 함수로 최대화한다. `num_leaves <= 2^max_depth - 1` 제약을 코드 레벨에서 강제하여 소규모 데이터셋 과적합을 방지한다. 결과는 콘솔 리포트 + JSON 파일로 출력한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.11+, optuna, lightgbm, numpy, pandas, scikit-learn (기존 의존성 재활용)
|
||||||
|
|
||||||
|
**설계 문서:** `docs/plans/2026-03-02-optuna-hyperparam-tuning-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: optuna 의존성 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `requirements.txt`
|
||||||
|
|
||||||
|
**Step 1: requirements.txt에 optuna 추가**
|
||||||
|
|
||||||
|
```
|
||||||
|
optuna>=3.6.0
|
||||||
|
```
|
||||||
|
|
||||||
|
`requirements.txt` 파일 끝에 추가한다.
|
||||||
|
|
||||||
|
**Step 2: 설치 확인 (로컬)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install optuna
|
||||||
|
python -c "import optuna; print(optuna.__version__)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 버전 번호 출력 (예: `3.6.0`)
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add requirements.txt
|
||||||
|
git commit -m "feat: add optuna dependency for hyperparameter tuning"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `scripts/tune_hyperparams.py` 핵심 구조 생성
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/tune_hyperparams.py`
|
||||||
|
|
||||||
|
**Step 1: 파일 생성 — 전체 코드**
|
||||||
|
|
||||||
|
아래 코드를 `scripts/tune_hyperparams.py`로 저장한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
Optuna를 사용한 LightGBM 하이퍼파라미터 자동 탐색.
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
python scripts/tune_hyperparams.py # 기본 (50 trials, 5폴드)
|
||||||
|
python scripts/tune_hyperparams.py --trials 10 --folds 3 # 빠른 테스트
|
||||||
|
python scripts/tune_hyperparams.py --data data/combined_15m.parquet --trials 100
|
||||||
|
|
||||||
|
결과:
|
||||||
|
- 콘솔: Best Params + Walk-Forward 리포트
|
||||||
|
- JSON: models/tune_results_YYYYMMDD_HHMMSS.json
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import lightgbm as lgb
|
||||||
|
import optuna
|
||||||
|
from optuna.samplers import TPESampler
|
||||||
|
from optuna.pruners import MedianPruner
|
||||||
|
from sklearn.metrics import roc_auc_score
|
||||||
|
|
||||||
|
from src.ml_features import FEATURE_COLS
|
||||||
|
from src.dataset_builder import generate_dataset_vectorized
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 데이터 로드 및 데이터셋 생성 (1회 캐싱)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||||
|
"""
|
||||||
|
parquet 로드 → 벡터화 데이터셋 생성 → (X, y, w) numpy 배열 반환.
|
||||||
|
study 시작 전 1회만 호출하여 모든 trial이 공유한다.
|
||||||
|
"""
|
||||||
|
print(f"데이터 로드: {data_path}")
|
||||||
|
df_raw = pd.read_parquet(data_path)
|
||||||
|
print(f"캔들 수: {len(df_raw):,}, 컬럼: {list(df_raw.columns)}")
|
||||||
|
|
||||||
|
base_cols = ["open", "high", "low", "close", "volume"]
|
||||||
|
btc_df = eth_df = None
|
||||||
|
|
||||||
|
if "close_btc" in df_raw.columns:
|
||||||
|
btc_df = df_raw[[c + "_btc" for c in base_cols]].copy()
|
||||||
|
btc_df.columns = base_cols
|
||||||
|
print("BTC 피처 활성화")
|
||||||
|
|
||||||
|
if "close_eth" in df_raw.columns:
|
||||||
|
eth_df = df_raw[[c + "_eth" for c in base_cols]].copy()
|
||||||
|
eth_df.columns = base_cols
|
||||||
|
print("ETH 피처 활성화")
|
||||||
|
|
||||||
|
df = df_raw[base_cols].copy()
|
||||||
|
|
||||||
|
print("\n데이터셋 생성 중 (1회만 실행)...")
|
||||||
|
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=0.0)
|
||||||
|
|
||||||
|
if dataset.empty or "label" not in dataset.columns:
|
||||||
|
raise ValueError("데이터셋 생성 실패: 샘플 0개")
|
||||||
|
|
||||||
|
actual_feature_cols = [c for c in FEATURE_COLS if c in dataset.columns]
|
||||||
|
X = dataset[actual_feature_cols].values.astype(np.float32)
|
||||||
|
y = dataset["label"].values.astype(np.int8)
|
||||||
|
w = dataset["sample_weight"].values.astype(np.float32)
|
||||||
|
|
||||||
|
pos = y.sum()
|
||||||
|
neg = (y == 0).sum()
|
||||||
|
print(f"데이터셋 완성: {len(dataset):,}개 샘플 (양성={pos:.0f}, 음성={neg:.0f})")
|
||||||
|
print(f"사용 피처: {len(actual_feature_cols)}개\n")
|
||||||
|
|
||||||
|
return X, y, w
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Walk-Forward 교차검증
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _walk_forward_cv(
|
||||||
|
X: np.ndarray,
|
||||||
|
y: np.ndarray,
|
||||||
|
w: np.ndarray,
|
||||||
|
params: dict,
|
||||||
|
n_splits: int,
|
||||||
|
train_ratio: float,
|
||||||
|
trial: optuna.Trial | None = None,
|
||||||
|
) -> tuple[float, list[float]]:
|
||||||
|
"""
|
||||||
|
Walk-Forward 교차검증으로 평균 AUC를 반환한다.
|
||||||
|
trial이 제공되면 각 폴드 후 Optuna에 중간 값을 보고하여 Pruning을 활성화한다.
|
||||||
|
"""
|
||||||
|
n = len(X)
|
||||||
|
step = max(1, int(n * (1 - train_ratio) / n_splits))
|
||||||
|
train_end_start = int(n * train_ratio)
|
||||||
|
|
||||||
|
fold_aucs = []
|
||||||
|
|
||||||
|
for fold_idx in range(n_splits):
|
||||||
|
tr_end = train_end_start + fold_idx * step
|
||||||
|
val_end = tr_end + step
|
||||||
|
if val_end > n:
|
||||||
|
break
|
||||||
|
|
||||||
|
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
|
||||||
|
X_val, y_val = X[tr_end:val_end], y[tr_end:val_end]
|
||||||
|
|
||||||
|
# 클래스 불균형 처리: 언더샘플링 (시간 순서 유지)
|
||||||
|
pos_idx = np.where(y_tr == 1)[0]
|
||||||
|
neg_idx = np.where(y_tr == 0)[0]
|
||||||
|
if len(neg_idx) > len(pos_idx) and len(pos_idx) > 0:
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
neg_idx = rng.choice(neg_idx, size=len(pos_idx), replace=False)
|
||||||
|
bal_idx = np.sort(np.concatenate([pos_idx, neg_idx]))
|
||||||
|
|
||||||
|
if len(bal_idx) < 20 or len(np.unique(y_val)) < 2:
|
||||||
|
fold_aucs.append(0.5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
model = lgb.LGBMClassifier(**params, random_state=42, verbose=-1)
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore")
|
||||||
|
model.fit(X_tr[bal_idx], y_tr[bal_idx], sample_weight=w_tr[bal_idx])
|
||||||
|
|
||||||
|
proba = model.predict_proba(X_val)[:, 1]
|
||||||
|
auc = roc_auc_score(y_val, proba) if len(np.unique(y_val)) > 1 else 0.5
|
||||||
|
fold_aucs.append(auc)
|
||||||
|
|
||||||
|
# Optuna Pruning: 중간 값 보고
|
||||||
|
if trial is not None:
|
||||||
|
trial.report(float(np.mean(fold_aucs)), step=fold_idx)
|
||||||
|
if trial.should_prune():
|
||||||
|
raise optuna.TrialPruned()
|
||||||
|
|
||||||
|
mean_auc = float(np.mean(fold_aucs)) if fold_aucs else 0.5
|
||||||
|
return mean_auc, fold_aucs
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Optuna 목적 함수
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def make_objective(
|
||||||
|
X: np.ndarray,
|
||||||
|
y: np.ndarray,
|
||||||
|
w: np.ndarray,
|
||||||
|
n_splits: int,
|
||||||
|
train_ratio: float,
|
||||||
|
):
|
||||||
|
"""클로저로 데이터셋을 캡처한 목적 함수를 반환한다."""
|
||||||
|
|
||||||
|
def objective(trial: optuna.Trial) -> float:
|
||||||
|
# ── 하이퍼파라미터 샘플링 ──
|
||||||
|
n_estimators = trial.suggest_int("n_estimators", 100, 600)
|
||||||
|
learning_rate = trial.suggest_float("learning_rate", 0.01, 0.2, log=True)
|
||||||
|
max_depth = trial.suggest_int("max_depth", 2, 7)
|
||||||
|
|
||||||
|
# 핵심 제약: num_leaves <= 2^max_depth - 1 (leaf-wise 과적합 방지)
|
||||||
|
max_leaves_upper = min(31, 2 ** max_depth - 1)
|
||||||
|
num_leaves = trial.suggest_int("num_leaves", 7, max(7, max_leaves_upper))
|
||||||
|
|
||||||
|
min_child_samples = trial.suggest_int("min_child_samples", 10, 50)
|
||||||
|
subsample = trial.suggest_float("subsample", 0.5, 1.0)
|
||||||
|
colsample_bytree = trial.suggest_float("colsample_bytree", 0.5, 1.0)
|
||||||
|
reg_alpha = trial.suggest_float("reg_alpha", 1e-4, 1.0, log=True)
|
||||||
|
reg_lambda = trial.suggest_float("reg_lambda", 1e-4, 1.0, log=True)
|
||||||
|
|
||||||
|
# time_weight_decay는 데이터셋 생성 시 적용되어야 하지만,
|
||||||
|
# 데이터셋을 1회 캐싱하는 구조이므로 LightGBM sample_weight 스케일로 근사한다.
|
||||||
|
# 실제 decay 효과는 w 배열에 이미 반영되어 있으므로 스케일 파라미터로 활용한다.
|
||||||
|
weight_scale = trial.suggest_float("weight_scale", 0.5, 2.0)
|
||||||
|
w_scaled = (w * weight_scale).astype(np.float32)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"n_estimators": n_estimators,
|
||||||
|
"learning_rate": learning_rate,
|
||||||
|
"max_depth": max_depth,
|
||||||
|
"num_leaves": num_leaves,
|
||||||
|
"min_child_samples": min_child_samples,
|
||||||
|
"subsample": subsample,
|
||||||
|
"colsample_bytree": colsample_bytree,
|
||||||
|
"reg_alpha": reg_alpha,
|
||||||
|
"reg_lambda": reg_lambda,
|
||||||
|
}
|
||||||
|
|
||||||
|
mean_auc, fold_aucs = _walk_forward_cv(
|
||||||
|
X, y, w_scaled, params,
|
||||||
|
n_splits=n_splits,
|
||||||
|
train_ratio=train_ratio,
|
||||||
|
trial=trial,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 폴드별 AUC를 user_attrs에 저장 (결과 리포트용)
|
||||||
|
trial.set_user_attr("fold_aucs", fold_aucs)
|
||||||
|
|
||||||
|
return mean_auc
|
||||||
|
|
||||||
|
return objective
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 베이스라인 AUC 측정 (현재 고정 파라미터)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def measure_baseline(
|
||||||
|
X: np.ndarray,
|
||||||
|
y: np.ndarray,
|
||||||
|
w: np.ndarray,
|
||||||
|
n_splits: int,
|
||||||
|
train_ratio: float,
|
||||||
|
) -> tuple[float, list[float]]:
|
||||||
|
"""train_model.py의 현재 고정 파라미터로 베이스라인 AUC를 측정한다."""
|
||||||
|
baseline_params = {
|
||||||
|
"n_estimators": 500,
|
||||||
|
"learning_rate": 0.05,
|
||||||
|
"num_leaves": 31,
|
||||||
|
"min_child_samples": 15,
|
||||||
|
"subsample": 0.8,
|
||||||
|
"colsample_bytree": 0.8,
|
||||||
|
"reg_alpha": 0.05,
|
||||||
|
"reg_lambda": 0.1,
|
||||||
|
"max_depth": -1, # 현재 train_model.py는 max_depth 미설정
|
||||||
|
}
|
||||||
|
print("베이스라인 측정 중 (현재 train_model.py 고정 파라미터)...")
|
||||||
|
return _walk_forward_cv(X, y, w, baseline_params, n_splits=n_splits, train_ratio=train_ratio)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 결과 출력 및 저장
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def print_report(
|
||||||
|
study: optuna.Study,
|
||||||
|
baseline_auc: float,
|
||||||
|
baseline_folds: list[float],
|
||||||
|
elapsed_sec: float,
|
||||||
|
output_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""콘솔에 최종 리포트를 출력한다."""
|
||||||
|
best = study.best_trial
|
||||||
|
best_auc = best.value
|
||||||
|
best_folds = best.user_attrs.get("fold_aucs", [])
|
||||||
|
improvement = best_auc - baseline_auc
|
||||||
|
improvement_pct = (improvement / baseline_auc * 100) if baseline_auc > 0 else 0.0
|
||||||
|
|
||||||
|
elapsed_min = int(elapsed_sec // 60)
|
||||||
|
elapsed_s = int(elapsed_sec % 60)
|
||||||
|
|
||||||
|
sep = "=" * 62
|
||||||
|
dash = "-" * 62
|
||||||
|
|
||||||
|
print(f"\n{sep}")
|
||||||
|
print(f" Optuna 튜닝 완료 | {len(study.trials)} trials | 소요: {elapsed_min}분 {elapsed_s}초")
|
||||||
|
print(sep)
|
||||||
|
print(f" Best AUC : {best_auc:.4f} (Trial #{best.number})")
|
||||||
|
print(f" Baseline : {baseline_auc:.4f} (현재 train_model.py 고정값)")
|
||||||
|
sign = "+" if improvement >= 0 else ""
|
||||||
|
print(f" 개선폭 : {sign}{improvement:.4f} ({sign}{improvement_pct:.1f}%)")
|
||||||
|
print(dash)
|
||||||
|
print(" Best Parameters:")
|
||||||
|
for k, v in best.params.items():
|
||||||
|
if isinstance(v, float):
|
||||||
|
print(f" {k:<22}: {v:.6f}")
|
||||||
|
else:
|
||||||
|
print(f" {k:<22}: {v}")
|
||||||
|
print(dash)
|
||||||
|
print(" Walk-Forward 폴드별 AUC (Best Trial):")
|
||||||
|
for i, auc in enumerate(best_folds, 1):
|
||||||
|
print(f" 폴드 {i}: {auc:.4f}")
|
||||||
|
if best_folds:
|
||||||
|
print(f" 평균: {np.mean(best_folds):.4f} ± {np.std(best_folds):.4f}")
|
||||||
|
print(dash)
|
||||||
|
print(" Baseline 폴드별 AUC:")
|
||||||
|
for i, auc in enumerate(baseline_folds, 1):
|
||||||
|
print(f" 폴드 {i}: {auc:.4f}")
|
||||||
|
if baseline_folds:
|
||||||
|
print(f" 평균: {np.mean(baseline_folds):.4f} ± {np.std(baseline_folds):.4f}")
|
||||||
|
print(dash)
|
||||||
|
print(f" 결과 저장: {output_path}")
|
||||||
|
print(f" 다음 단계: python scripts/train_model.py --tuned-params {output_path}")
|
||||||
|
print(sep)
|
||||||
|
|
||||||
|
|
||||||
|
def save_results(
|
||||||
|
study: optuna.Study,
|
||||||
|
baseline_auc: float,
|
||||||
|
baseline_folds: list[float],
|
||||||
|
elapsed_sec: float,
|
||||||
|
data_path: str,
|
||||||
|
) -> Path:
|
||||||
|
"""결과를 JSON 파일로 저장하고 경로를 반환한다."""
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
output_path = Path(f"models/tune_results_{timestamp}.json")
|
||||||
|
output_path.parent.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
best = study.best_trial
|
||||||
|
|
||||||
|
all_trials = []
|
||||||
|
for t in study.trials:
|
||||||
|
if t.state == optuna.trial.TrialState.COMPLETE:
|
||||||
|
all_trials.append({
|
||||||
|
"number": t.number,
|
||||||
|
"auc": round(t.value, 6),
|
||||||
|
"fold_aucs": [round(a, 6) for a in t.user_attrs.get("fold_aucs", [])],
|
||||||
|
"params": {k: (round(v, 6) if isinstance(v, float) else v) for k, v in t.params.items()},
|
||||||
|
})
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"data_path": data_path,
|
||||||
|
"n_trials_total": len(study.trials),
|
||||||
|
"n_trials_complete": len(all_trials),
|
||||||
|
"elapsed_sec": round(elapsed_sec, 1),
|
||||||
|
"baseline": {
|
||||||
|
"auc": round(baseline_auc, 6),
|
||||||
|
"fold_aucs": [round(a, 6) for a in baseline_folds],
|
||||||
|
},
|
||||||
|
"best_trial": {
|
||||||
|
"number": best.number,
|
||||||
|
"auc": round(best.value, 6),
|
||||||
|
"fold_aucs": [round(a, 6) for a in best.user_attrs.get("fold_aucs", [])],
|
||||||
|
"params": {k: (round(v, 6) if isinstance(v, float) else v) for k, v in best.params.items()},
|
||||||
|
},
|
||||||
|
"all_trials": all_trials,
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(result, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 메인
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Optuna LightGBM 하이퍼파라미터 튜닝")
|
||||||
|
parser.add_argument("--data", default="data/combined_15m.parquet", help="학습 데이터 경로")
|
||||||
|
parser.add_argument("--trials", type=int, default=50, help="Optuna trial 수 (기본: 50)")
|
||||||
|
parser.add_argument("--folds", type=int, default=5, help="Walk-Forward 폴드 수 (기본: 5)")
|
||||||
|
parser.add_argument("--train-ratio", type=float, default=0.6, help="학습 구간 비율 (기본: 0.6)")
|
||||||
|
parser.add_argument("--no-baseline", action="store_true", help="베이스라인 측정 건너뜀")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# 1. 데이터셋 로드 (1회)
|
||||||
|
X, y, w = load_dataset(args.data)
|
||||||
|
|
||||||
|
# 2. 베이스라인 측정
|
||||||
|
if args.no_baseline:
|
||||||
|
baseline_auc, baseline_folds = 0.0, []
|
||||||
|
print("베이스라인 측정 건너뜀 (--no-baseline)")
|
||||||
|
else:
|
||||||
|
baseline_auc, baseline_folds = measure_baseline(X, y, w, args.folds, args.train_ratio)
|
||||||
|
print(f"베이스라인 AUC: {baseline_auc:.4f} (폴드별: {[round(a,4) for a in baseline_folds]})\n")
|
||||||
|
|
||||||
|
# 3. Optuna study 실행
|
||||||
|
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
||||||
|
sampler = TPESampler(seed=42)
|
||||||
|
pruner = MedianPruner(n_startup_trials=5, n_warmup_steps=2)
|
||||||
|
study = optuna.create_study(
|
||||||
|
direction="maximize",
|
||||||
|
sampler=sampler,
|
||||||
|
pruner=pruner,
|
||||||
|
study_name="lgbm_wf_auc",
|
||||||
|
)
|
||||||
|
|
||||||
|
objective = make_objective(X, y, w, n_splits=args.folds, train_ratio=args.train_ratio)
|
||||||
|
|
||||||
|
print(f"Optuna 탐색 시작: {args.trials} trials, {args.folds}폴드 Walk-Forward")
|
||||||
|
print("(진행 상황은 trial 완료마다 출력됩니다)\n")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
def _progress_callback(study: optuna.Study, trial: optuna.trial.FrozenTrial):
|
||||||
|
if trial.state == optuna.trial.TrialState.COMPLETE:
|
||||||
|
best_so_far = study.best_value
|
||||||
|
print(
|
||||||
|
f" Trial #{trial.number:3d} | AUC={trial.value:.4f} "
|
||||||
|
f"| Best={best_so_far:.4f} "
|
||||||
|
f"| {trial.params.get('num_leaves', '?')}leaves "
|
||||||
|
f"depth={trial.params.get('max_depth', '?')}"
|
||||||
|
)
|
||||||
|
elif trial.state == optuna.trial.TrialState.PRUNED:
|
||||||
|
print(f" Trial #{trial.number:3d} | PRUNED")
|
||||||
|
|
||||||
|
study.optimize(
|
||||||
|
objective,
|
||||||
|
n_trials=args.trials,
|
||||||
|
callbacks=[_progress_callback],
|
||||||
|
show_progress_bar=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
|
||||||
|
# 4. 결과 저장 및 출력
|
||||||
|
output_path = save_results(study, baseline_auc, baseline_folds, elapsed, args.data)
|
||||||
|
print_report(study, baseline_auc, baseline_folds, elapsed, output_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 문법 오류 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/cointrader
|
||||||
|
python -c "import ast; ast.parse(open('scripts/tune_hyperparams.py').read()); print('문법 OK')"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `문법 OK`
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/tune_hyperparams.py
|
||||||
|
git commit -m "feat: add Optuna Walk-Forward AUC hyperparameter tuning script"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: 동작 검증 (빠른 테스트)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Read: `scripts/tune_hyperparams.py`
|
||||||
|
|
||||||
|
**Step 1: 빠른 테스트 실행 (10 trials, 3폴드)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/tune_hyperparams.py --trials 10 --folds 3 --no-baseline
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- 오류 없이 10 trials 완료
|
||||||
|
- `models/tune_results_YYYYMMDD_HHMMSS.json` 생성
|
||||||
|
- 콘솔에 Best Params 출력
|
||||||
|
|
||||||
|
**Step 2: JSON 결과 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat models/tune_results_*.json | python -m json.tool | head -40
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `best_trial.auc`, `best_trial.params` 등 구조 확인
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add models/tune_results_*.json
|
||||||
|
git commit -m "test: verify Optuna tuning pipeline with 10 trials"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: README.md 업데이트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
**Step 1: ML 모델 학습 섹션에 튜닝 사용법 추가**
|
||||||
|
|
||||||
|
`README.md`의 `## ML 모델 학습` 섹션 아래에 다음 내용을 추가한다:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### 하이퍼파라미터 자동 튜닝 (Optuna)
|
||||||
|
|
||||||
|
봇 성능이 저하되거나 데이터가 충분히 축적되었을 때 Optuna로 최적 파라미터를 탐색합니다.
|
||||||
|
결과를 확인하고 직접 승인한 후 재학습에 반영하는 **수동 트리거** 방식입니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 기본 실행 (50 trials, 5폴드 Walk-Forward, ~30분)
|
||||||
|
python scripts/tune_hyperparams.py
|
||||||
|
|
||||||
|
# 빠른 테스트 (10 trials, 3폴드, ~5분)
|
||||||
|
python scripts/tune_hyperparams.py --trials 10 --folds 3
|
||||||
|
|
||||||
|
# 결과 확인 후 승인하면 재학습
|
||||||
|
python scripts/train_model.py
|
||||||
|
```
|
||||||
|
|
||||||
|
결과는 `models/tune_results_YYYYMMDD_HHMMSS.json`에 저장됩니다.
|
||||||
|
Best Params와 베이스라인 대비 개선폭을 확인하고 직접 판단하세요.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md
|
||||||
|
git commit -m "docs: add Optuna hyperparameter tuning usage to README"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 체크리스트
|
||||||
|
|
||||||
|
- [ ] `python -c "import optuna"` 오류 없음
|
||||||
|
- [ ] `python scripts/tune_hyperparams.py --trials 10 --folds 3 --no-baseline` 오류 없이 완료
|
||||||
|
- [ ] `models/tune_results_*.json` 파일 생성 확인
|
||||||
|
- [ ] JSON에 `best_trial.params`, `best_trial.fold_aucs` 포함 확인
|
||||||
|
- [ ] 콘솔 리포트에 Best AUC, 폴드별 AUC, 파라미터 출력 확인
|
||||||
|
- [ ] `num_leaves <= 2^max_depth - 1` 제약이 모든 trial에서 지켜지는지 JSON으로 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 향후 확장 (2단계 — 별도 플랜)
|
||||||
|
|
||||||
|
파이프라인 안정화 후 `dataset_builder.py`의 `_calc_signals()` 함수를 파라미터화하여 기술 지표 임계값(RSI, Stochastic RSI, 거래량 배수, 진입 점수 임계값)을 탐색 공간에 추가한다.
|
||||||
399
docs/plans/2026-03-02-realtime-oi-funding-features.md
Normal file
399
docs/plans/2026-03-02-realtime-oi-funding-features.md
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
# 실시간 OI/펀딩비 피처 수집 구현 계획
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** 실시간 봇에서 캔들 마감 시 바이낸스 REST API로 현재 OI와 펀딩비를 수집해 ML 피처에 실제 값을 넣어 학습-추론 불일치(train-serve skew)를 해소한다.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- `exchange.py`에 `get_open_interest()`, `get_funding_rate()` 메서드 추가 (REST 호출)
|
||||||
|
- `bot.py`의 `process_candle()`에서 캔들 마감 시 두 값을 조회하고 `build_features()` 호출 시 전달
|
||||||
|
- `ml_features.py`의 `build_features()`가 `oi_change`, `funding_rate` 파라미터를 받아 실제 값으로 채우도록 수정
|
||||||
|
|
||||||
|
**Tech Stack:** python-binance AsyncClient, aiohttp (이미 사용 중), pytest-asyncio
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: exchange.py — OI / 펀딩비 조회 메서드 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/exchange.py`
|
||||||
|
- Test: `tests/test_exchange.py`
|
||||||
|
|
||||||
|
### Step 1: 실패 테스트 작성
|
||||||
|
|
||||||
|
`tests/test_exchange.py` 파일에 아래 테스트를 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_open_interest(exchange):
|
||||||
|
"""get_open_interest()가 float을 반환하는지 확인."""
|
||||||
|
exchange.client.futures_open_interest = MagicMock(
|
||||||
|
return_value={"openInterest": "123456.789"}
|
||||||
|
)
|
||||||
|
result = await exchange.get_open_interest()
|
||||||
|
assert isinstance(result, float)
|
||||||
|
assert result == pytest.approx(123456.789)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_funding_rate(exchange):
|
||||||
|
"""get_funding_rate()가 float을 반환하는지 확인."""
|
||||||
|
exchange.client.futures_mark_price = MagicMock(
|
||||||
|
return_value={"lastFundingRate": "0.0001"}
|
||||||
|
)
|
||||||
|
result = await exchange.get_funding_rate()
|
||||||
|
assert isinstance(result, float)
|
||||||
|
assert result == pytest.approx(0.0001)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_open_interest_error_returns_none(exchange):
|
||||||
|
"""API 오류 시 None 반환 확인."""
|
||||||
|
from binance.exceptions import BinanceAPIException
|
||||||
|
exchange.client.futures_open_interest = MagicMock(
|
||||||
|
side_effect=BinanceAPIException(MagicMock(status_code=400), 400, '{"code":-1121,"msg":"Invalid symbol"}')
|
||||||
|
)
|
||||||
|
result = await exchange.get_open_interest()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_funding_rate_error_returns_none(exchange):
|
||||||
|
"""API 오류 시 None 반환 확인."""
|
||||||
|
from binance.exceptions import BinanceAPIException
|
||||||
|
exchange.client.futures_mark_price = MagicMock(
|
||||||
|
side_effect=BinanceAPIException(MagicMock(status_code=400), 400, '{"code":-1121,"msg":"Invalid symbol"}')
|
||||||
|
)
|
||||||
|
result = await exchange.get_funding_rate()
|
||||||
|
assert result is None
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: 테스트 실패 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_exchange.py::test_get_open_interest tests/test_exchange.py::test_get_funding_rate -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `FAILED` — `AttributeError: 'BinanceFuturesClient' object has no attribute 'get_open_interest'`
|
||||||
|
|
||||||
|
### Step 3: exchange.py에 메서드 구현
|
||||||
|
|
||||||
|
`src/exchange.py`의 `cancel_all_orders()` 메서드 아래에 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_open_interest(self) -> float | None:
|
||||||
|
"""현재 미결제약정(OI)을 조회한다. 오류 시 None 반환."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self.client.futures_open_interest(symbol=self.config.symbol),
|
||||||
|
)
|
||||||
|
return float(result["openInterest"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"OI 조회 실패 (무시): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_funding_rate(self) -> float | None:
|
||||||
|
"""현재 펀딩비를 조회한다. 오류 시 None 반환."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self.client.futures_mark_price(symbol=self.config.symbol),
|
||||||
|
)
|
||||||
|
return float(result["lastFundingRate"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"펀딩비 조회 실패 (무시): {e}")
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: 테스트 통과 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_exchange.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 기존 테스트 포함 전체 PASS
|
||||||
|
|
||||||
|
### Step 5: 커밋
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/exchange.py tests/test_exchange.py
|
||||||
|
git commit -m "feat: add get_open_interest and get_funding_rate to BinanceFuturesClient"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: ml_features.py — build_features()에 oi/funding 파라미터 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ml_features.py`
|
||||||
|
- Test: `tests/test_ml_features.py`
|
||||||
|
|
||||||
|
### Step 1: 실패 테스트 작성
|
||||||
|
|
||||||
|
`tests/test_ml_features.py`에 아래 테스트를 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_build_features_uses_provided_oi_funding(sample_df_with_indicators):
|
||||||
|
"""oi_change, funding_rate 파라미터가 제공되면 실제 값이 피처에 반영된다."""
|
||||||
|
from src.ml_features import build_features
|
||||||
|
feat = build_features(
|
||||||
|
sample_df_with_indicators,
|
||||||
|
signal="LONG",
|
||||||
|
oi_change=0.05,
|
||||||
|
funding_rate=0.0002,
|
||||||
|
)
|
||||||
|
assert feat["oi_change"] == pytest.approx(0.05)
|
||||||
|
assert feat["funding_rate"] == pytest.approx(0.0002)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_features_defaults_to_zero_when_not_provided(sample_df_with_indicators):
|
||||||
|
"""oi_change, funding_rate 파라미터 미제공 시 0.0으로 채워진다."""
|
||||||
|
from src.ml_features import build_features
|
||||||
|
feat = build_features(sample_df_with_indicators, signal="LONG")
|
||||||
|
assert feat["oi_change"] == pytest.approx(0.0)
|
||||||
|
assert feat["funding_rate"] == pytest.approx(0.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: 테스트 실패 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_ml_features.py::test_build_features_uses_provided_oi_funding -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `FAILED` — `TypeError: build_features() got an unexpected keyword argument 'oi_change'`
|
||||||
|
|
||||||
|
### Step 3: ml_features.py 수정
|
||||||
|
|
||||||
|
`build_features()` 시그니처와 마지막 부분을 수정한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_features(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
signal: str,
|
||||||
|
btc_df: pd.DataFrame | None = None,
|
||||||
|
eth_df: pd.DataFrame | None = None,
|
||||||
|
oi_change: float | None = None,
|
||||||
|
funding_rate: float | None = None,
|
||||||
|
) -> pd.Series:
|
||||||
|
```
|
||||||
|
|
||||||
|
그리고 함수 끝의 `setdefault` 부분을 아래로 교체한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 실시간에서 실제 값이 제공되면 사용, 없으면 0으로 채운다
|
||||||
|
base["oi_change"] = float(oi_change) if oi_change is not None else 0.0
|
||||||
|
base["funding_rate"] = float(funding_rate) if funding_rate is not None else 0.0
|
||||||
|
|
||||||
|
return pd.Series(base)
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 코드:
|
||||||
|
```python
|
||||||
|
# 실시간에서는 OI/펀딩비를 수집하지 않으므로 0으로 채워 학습 피처(23개)와 일치시킨다
|
||||||
|
base.setdefault("oi_change", 0.0)
|
||||||
|
base.setdefault("funding_rate", 0.0)
|
||||||
|
|
||||||
|
return pd.Series(base)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: 테스트 통과 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_ml_features.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 전체 PASS
|
||||||
|
|
||||||
|
### Step 5: 커밋
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ml_features.py tests/test_ml_features.py
|
||||||
|
git commit -m "feat: build_features accepts oi_change and funding_rate params"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: bot.py — 캔들 마감 시 OI/펀딩비 조회 후 피처에 전달
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py`
|
||||||
|
- Test: `tests/test_bot.py`
|
||||||
|
|
||||||
|
### Step 1: 실패 테스트 작성
|
||||||
|
|
||||||
|
`tests/test_bot.py`에 아래 테스트를 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_candle_fetches_oi_and_funding(config, sample_df):
|
||||||
|
"""process_candle()이 OI와 펀딩비를 조회하고 build_features에 전달하는지 확인."""
|
||||||
|
with patch("src.bot.BinanceFuturesClient"):
|
||||||
|
bot = TradingBot(config)
|
||||||
|
|
||||||
|
bot.exchange = AsyncMock()
|
||||||
|
bot.exchange.get_balance = AsyncMock(return_value=1000.0)
|
||||||
|
bot.exchange.get_position = AsyncMock(return_value=None)
|
||||||
|
bot.exchange.place_order = AsyncMock(return_value={"orderId": "1"})
|
||||||
|
bot.exchange.set_leverage = AsyncMock()
|
||||||
|
bot.exchange.get_open_interest = AsyncMock(return_value=5000000.0)
|
||||||
|
bot.exchange.get_funding_rate = AsyncMock(return_value=0.0001)
|
||||||
|
|
||||||
|
with patch("src.bot.build_features") as mock_build:
|
||||||
|
mock_build.return_value = pd.Series({col: 0.0 for col in __import__("src.ml_features", fromlist=["FEATURE_COLS"]).FEATURE_COLS})
|
||||||
|
# ML 필터는 비활성화
|
||||||
|
bot.ml_filter.is_model_loaded = MagicMock(return_value=False)
|
||||||
|
await bot.process_candle(sample_df)
|
||||||
|
|
||||||
|
# build_features가 oi_change, funding_rate 키워드 인자와 함께 호출됐는지 확인
|
||||||
|
assert mock_build.called
|
||||||
|
call_kwargs = mock_build.call_args.kwargs
|
||||||
|
assert "oi_change" in call_kwargs
|
||||||
|
assert "funding_rate" in call_kwargs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: 테스트 실패 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_bot.py::test_process_candle_fetches_oi_and_funding -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `FAILED` — `AssertionError: assert 'oi_change' in {}`
|
||||||
|
|
||||||
|
### Step 3: bot.py 수정
|
||||||
|
|
||||||
|
`process_candle()` 메서드에서 OI/펀딩비를 조회하고 `build_features()`에 전달한다.
|
||||||
|
|
||||||
|
`process_candle()` 메서드 시작 부분에 OI/펀딩비 조회를 추가한다:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def process_candle(self, df, btc_df=None, eth_df=None):
|
||||||
|
self.ml_filter.check_and_reload()
|
||||||
|
|
||||||
|
if not self.risk.is_trading_allowed():
|
||||||
|
logger.warning("리스크 한도 초과 - 거래 중단")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 캔들 마감 시 OI/펀딩비 실시간 조회 (실패해도 0으로 폴백)
|
||||||
|
oi_change, funding_rate = await self._fetch_market_microstructure()
|
||||||
|
|
||||||
|
ind = Indicators(df)
|
||||||
|
df_with_indicators = ind.calculate_all()
|
||||||
|
raw_signal = ind.get_signal(df_with_indicators)
|
||||||
|
# ... (이하 동일)
|
||||||
|
```
|
||||||
|
|
||||||
|
그리고 `build_features()` 호출 부분 두 곳을 모두 수정한다:
|
||||||
|
|
||||||
|
```python
|
||||||
|
features = build_features(
|
||||||
|
df_with_indicators, signal,
|
||||||
|
btc_df=btc_df, eth_df=eth_df,
|
||||||
|
oi_change=oi_change, funding_rate=funding_rate,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`_fetch_market_microstructure()` 메서드를 추가한다:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _fetch_market_microstructure(self) -> tuple[float, float]:
|
||||||
|
"""OI 변화율과 펀딩비를 실시간으로 조회한다. 실패 시 0.0으로 폴백."""
|
||||||
|
oi_val, fr_val = await asyncio.gather(
|
||||||
|
self.exchange.get_open_interest(),
|
||||||
|
self.exchange.get_funding_rate(),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
oi_float = float(oi_val) if isinstance(oi_val, (int, float)) else 0.0
|
||||||
|
fr_float = float(fr_val) if isinstance(fr_val, (int, float)) else 0.0
|
||||||
|
|
||||||
|
# OI는 절대값이므로 이전 값 대비 변화율로 변환
|
||||||
|
oi_change = self._calc_oi_change(oi_float)
|
||||||
|
logger.debug(f"OI={oi_float:.0f}, OI변화율={oi_change:.6f}, 펀딩비={fr_float:.6f}")
|
||||||
|
return oi_change, fr_float
|
||||||
|
```
|
||||||
|
|
||||||
|
`_calc_oi_change()` 메서드와 `_prev_oi` 상태를 추가한다:
|
||||||
|
|
||||||
|
`__init__()` 에 추가:
|
||||||
|
```python
|
||||||
|
self._prev_oi: float | None = None # OI 변화율 계산용 이전 값
|
||||||
|
```
|
||||||
|
|
||||||
|
메서드 추가:
|
||||||
|
```python
|
||||||
|
def _calc_oi_change(self, current_oi: float) -> float:
|
||||||
|
"""이전 OI 대비 변화율을 계산한다. 첫 캔들은 0.0 반환."""
|
||||||
|
if self._prev_oi is None or self._prev_oi == 0.0:
|
||||||
|
self._prev_oi = current_oi
|
||||||
|
return 0.0
|
||||||
|
change = (current_oi - self._prev_oi) / self._prev_oi
|
||||||
|
self._prev_oi = current_oi
|
||||||
|
return change
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: 테스트 통과 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_bot.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 전체 PASS
|
||||||
|
|
||||||
|
### Step 5: 커밋
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/bot.py tests/test_bot.py
|
||||||
|
git commit -m "feat: fetch realtime OI and funding rate on candle close for ML features"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: 전체 테스트 통과 확인 및 README 업데이트
|
||||||
|
|
||||||
|
### Step 1: 전체 테스트 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/run_tests.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 전체 PASS (새 테스트 포함)
|
||||||
|
|
||||||
|
### Step 2: README.md 업데이트
|
||||||
|
|
||||||
|
`README.md`의 "주요 기능" 섹션에서 ML 피처 설명을 수정한다.
|
||||||
|
|
||||||
|
기존:
|
||||||
|
```
|
||||||
|
- **23개 ML 피처**: XRP 기술 지표 13개 + BTC/ETH 수익률·상대강도 8개 + OI 변화율·펀딩비 2개 (실시간 미수집 항목은 0으로 채움)
|
||||||
|
```
|
||||||
|
|
||||||
|
변경:
|
||||||
|
```
|
||||||
|
- **23개 ML 피처**: XRP 기술 지표 13개 + BTC/ETH 수익률·상대강도 8개 + OI 변화율·펀딩비 2개 (캔들 마감 시 REST API로 실시간 수집)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: 최종 커밋
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md
|
||||||
|
git commit -m "docs: update README to reflect realtime OI/funding rate collection"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 후 검증 포인트
|
||||||
|
|
||||||
|
1. 봇 실행 로그에서 `OI=xxx, OI변화율=xxx, 펀딩비=xxx` 라인이 15분마다 출력되는지 확인
|
||||||
|
2. API 오류(네트워크 단절 등) 시 `WARNING: OI 조회 실패 (무시)` 로그 후 0.0으로 폴백해 봇이 정상 동작하는지 확인
|
||||||
|
3. `build_features()` 호출 시 `oi_change`, `funding_rate`가 실제 값으로 채워지는지 로그 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다음 단계: 접근법 B (OI/펀딩비 누적 저장)
|
||||||
|
|
||||||
|
A 완료 후 진행할 계획:
|
||||||
|
- `scripts/fetch_history.py` 실행 시 기존 parquet에 새 30일치를 **append(중복 제거)** 방식으로 저장
|
||||||
|
- 시간이 지날수록 OI/펀딩비 학습 데이터가 누적되어 모델 품질 향상
|
||||||
|
- 별도 플랜 문서로 작성 예정
|
||||||
125
docs/plans/2026-03-02-reverse-signal-reenter-design.md
Normal file
125
docs/plans/2026-03-02-reverse-signal-reenter-design.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# 반대 시그널 시 청산 후 즉시 재진입 설계
|
||||||
|
|
||||||
|
- **날짜**: 2026-03-02
|
||||||
|
- **파일**: `src/bot.py`
|
||||||
|
- **상태**: 설계 완료, 구현 대기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
현재 `TradingBot.process_candle`은 반대 방향 시그널이 오면 기존 포지션을 청산만 하고 종료한다.
|
||||||
|
새 포지션은 다음 캔들에서 시그널이 다시 나와야 잡힌다.
|
||||||
|
|
||||||
|
```
|
||||||
|
현재: 반대 시그널 → 청산 → 다음 캔들 대기
|
||||||
|
목표: 반대 시그널 → 청산 → (ML 필터 통과 시) 즉시 반대 방향 재진입
|
||||||
|
```
|
||||||
|
|
||||||
|
같은 방향 시그널이 오거나 HOLD이면 기존 포지션을 그대로 유지한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 요구사항
|
||||||
|
|
||||||
|
| 항목 | 결정 |
|
||||||
|
|------|------|
|
||||||
|
| 포지션 크기 | 재진입 시점 잔고 + 동적 증거금 비율로 새로 계산 |
|
||||||
|
| SL/TP | 청산 시 기존 주문 전부 취소, 재진입 시 새로 설정 |
|
||||||
|
| ML 필터 | 재진입에도 동일하게 적용 (차단 시 청산만 하고 대기) |
|
||||||
|
| 같은 방향 시그널 | 포지션 유지 (변경 없음) |
|
||||||
|
| HOLD 시그널 | 포지션 유지 (변경 없음) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 설계
|
||||||
|
|
||||||
|
### 변경 범위
|
||||||
|
|
||||||
|
`src/bot.py` 한 파일만 수정한다.
|
||||||
|
|
||||||
|
1. `_close_and_reenter` 메서드 신규 추가
|
||||||
|
2. `process_candle` 내 반대 시그널 분기에서 `_close_position` 대신 `_close_and_reenter` 호출
|
||||||
|
|
||||||
|
### 데이터 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
process_candle()
|
||||||
|
└─ 반대 시그널 감지
|
||||||
|
└─ _close_and_reenter(position, signal, df, btc_df, eth_df)
|
||||||
|
├─ _close_position(position) # 청산 + cancel_all_orders
|
||||||
|
├─ risk.can_open_new_position() 체크
|
||||||
|
│ └─ 불가 → 로그 + 종료
|
||||||
|
├─ ML 필터 체크 (ml_filter.is_model_loaded())
|
||||||
|
│ ├─ 차단 → 로그 + 종료 (포지션 없는 상태로 대기)
|
||||||
|
│ └─ 통과 → 계속
|
||||||
|
└─ _open_position(signal, df) # 재진입 + 새 SL/TP 설정
|
||||||
|
```
|
||||||
|
|
||||||
|
### `process_candle` 수정
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 변경 전
|
||||||
|
elif position is not None:
|
||||||
|
pos_side = "LONG" if float(position["positionAmt"]) > 0 else "SHORT"
|
||||||
|
if (pos_side == "LONG" and signal == "SHORT") or \
|
||||||
|
(pos_side == "SHORT" and signal == "LONG"):
|
||||||
|
await self._close_position(position)
|
||||||
|
|
||||||
|
# 변경 후
|
||||||
|
elif position is not None:
|
||||||
|
pos_side = "LONG" if float(position["positionAmt"]) > 0 else "SHORT"
|
||||||
|
if (pos_side == "LONG" and signal == "SHORT") or \
|
||||||
|
(pos_side == "SHORT" and signal == "LONG"):
|
||||||
|
await self._close_and_reenter(position, signal, df_with_indicators, btc_df, eth_df)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 신규 메서드 `_close_and_reenter`
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _close_and_reenter(
|
||||||
|
self,
|
||||||
|
position: dict,
|
||||||
|
signal: str,
|
||||||
|
df,
|
||||||
|
btc_df=None,
|
||||||
|
eth_df=None,
|
||||||
|
) -> None:
|
||||||
|
"""기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다."""
|
||||||
|
await self._close_position(position)
|
||||||
|
|
||||||
|
if not self.risk.can_open_new_position():
|
||||||
|
logger.info("최대 포지션 수 도달 — 재진입 건너뜀")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.ml_filter.is_model_loaded():
|
||||||
|
features = build_features(df, signal, btc_df=btc_df, eth_df=eth_df)
|
||||||
|
if not self.ml_filter.should_enter(features):
|
||||||
|
logger.info(f"ML 필터 차단: {signal} 재진입 무시")
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._open_position(signal, df)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 엣지 케이스
|
||||||
|
|
||||||
|
| 상황 | 처리 |
|
||||||
|
|------|------|
|
||||||
|
| 청산 후 ML 필터 차단 | 청산만 하고 포지션 없는 상태로 대기 |
|
||||||
|
| 청산 후 잔고 부족 (명목금액 미달) | `_open_position` 내부 경고 후 건너뜀 (기존 로직) |
|
||||||
|
| 청산 후 최대 포지션 수 초과 | 재진입 건너뜀 |
|
||||||
|
| 같은 방향 시그널 | 포지션 유지 (변경 없음) |
|
||||||
|
| HOLD 시그널 | 포지션 유지 (변경 없음) |
|
||||||
|
| 봇 재시작 후 포지션 복구 | `_recover_position` 로직 변경 없음 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 영향 없는 코드
|
||||||
|
|
||||||
|
- `_close_position` — 변경 없음
|
||||||
|
- `_open_position` — 변경 없음
|
||||||
|
- `_recover_position` — 변경 없음
|
||||||
|
- `RiskManager` — 변경 없음
|
||||||
|
- `MLFilter` — 변경 없음
|
||||||
269
docs/plans/2026-03-02-reverse-signal-reenter-plan.md
Normal file
269
docs/plans/2026-03-02-reverse-signal-reenter-plan.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# 반대 시그널 시 청산 후 즉시 재진입 구현 플랜
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** 반대 방향 시그널이 오면 기존 포지션을 청산하고 ML 필터 통과 시 즉시 반대 방향으로 재진입한다.
|
||||||
|
|
||||||
|
**Architecture:** `src/bot.py`에 `_close_and_reenter` 메서드를 추가하고, `process_candle`의 반대 시그널 분기에서 이를 호출한다. 기존 `_close_position`과 `_open_position`을 그대로 재사용하므로 중복 없음.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, pytest, unittest.mock
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테스트 스크립트
|
||||||
|
|
||||||
|
각 태스크 단계마다 아래 스크립트로 테스트를 실행한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Task 1 — 신규 테스트 실행 (구현 전, FAIL 확인용)
|
||||||
|
bash scripts/test_reverse_reenter.sh 1
|
||||||
|
|
||||||
|
# Task 2 — _close_and_reenter 메서드 테스트 (구현 후, PASS 확인)
|
||||||
|
bash scripts/test_reverse_reenter.sh 2
|
||||||
|
|
||||||
|
# Task 3 — process_candle 분기 테스트 (수정 후, PASS 확인)
|
||||||
|
bash scripts/test_reverse_reenter.sh 3
|
||||||
|
|
||||||
|
# test_bot.py 전체
|
||||||
|
bash scripts/test_reverse_reenter.sh bot
|
||||||
|
|
||||||
|
# 전체 테스트 스위트
|
||||||
|
bash scripts/test_reverse_reenter.sh all
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고 파일
|
||||||
|
|
||||||
|
- 설계 문서: `docs/plans/2026-03-02-reverse-signal-reenter-design.md`
|
||||||
|
- 구현 대상: `src/bot.py`
|
||||||
|
- 기존 테스트: `tests/test_bot.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `_close_and_reenter` 테스트 작성
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/test_bot.py`
|
||||||
|
|
||||||
|
### Step 1: 테스트 3개 추가
|
||||||
|
|
||||||
|
`tests/test_bot.py` 맨 아래에 다음 테스트를 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_and_reenter_calls_open_when_ml_passes(config, sample_df):
|
||||||
|
"""반대 시그널 + ML 필터 통과 시 청산 후 재진입해야 한다."""
|
||||||
|
with patch("src.bot.BinanceFuturesClient"):
|
||||||
|
bot = TradingBot(config)
|
||||||
|
|
||||||
|
bot._close_position = AsyncMock()
|
||||||
|
bot._open_position = AsyncMock()
|
||||||
|
bot.ml_filter = MagicMock()
|
||||||
|
bot.ml_filter.is_model_loaded.return_value = True
|
||||||
|
bot.ml_filter.should_enter.return_value = True
|
||||||
|
|
||||||
|
position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"}
|
||||||
|
await bot._close_and_reenter(position, "SHORT", sample_df)
|
||||||
|
|
||||||
|
bot._close_position.assert_awaited_once_with(position)
|
||||||
|
bot._open_position.assert_awaited_once_with("SHORT", sample_df)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_and_reenter_skips_open_when_ml_blocks(config, sample_df):
|
||||||
|
"""ML 필터 차단 시 청산만 하고 재진입하지 않아야 한다."""
|
||||||
|
with patch("src.bot.BinanceFuturesClient"):
|
||||||
|
bot = TradingBot(config)
|
||||||
|
|
||||||
|
bot._close_position = AsyncMock()
|
||||||
|
bot._open_position = AsyncMock()
|
||||||
|
bot.ml_filter = MagicMock()
|
||||||
|
bot.ml_filter.is_model_loaded.return_value = True
|
||||||
|
bot.ml_filter.should_enter.return_value = False
|
||||||
|
|
||||||
|
position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"}
|
||||||
|
await bot._close_and_reenter(position, "SHORT", sample_df)
|
||||||
|
|
||||||
|
bot._close_position.assert_awaited_once_with(position)
|
||||||
|
bot._open_position.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_and_reenter_skips_open_when_max_positions_reached(config, sample_df):
|
||||||
|
"""최대 포지션 수 도달 시 청산만 하고 재진입하지 않아야 한다."""
|
||||||
|
with patch("src.bot.BinanceFuturesClient"):
|
||||||
|
bot = TradingBot(config)
|
||||||
|
|
||||||
|
bot._close_position = AsyncMock()
|
||||||
|
bot._open_position = AsyncMock()
|
||||||
|
bot.risk = MagicMock()
|
||||||
|
bot.risk.can_open_new_position.return_value = False
|
||||||
|
|
||||||
|
position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"}
|
||||||
|
await bot._close_and_reenter(position, "SHORT", sample_df)
|
||||||
|
|
||||||
|
bot._close_position.assert_awaited_once_with(position)
|
||||||
|
bot._open_position.assert_not_called()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: 테스트 실행 — 실패 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/test_reverse_reenter.sh 1
|
||||||
|
```
|
||||||
|
|
||||||
|
예상 결과: `AttributeError: 'TradingBot' object has no attribute '_close_and_reenter'` 로 3개 FAIL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `_close_and_reenter` 메서드 구현
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py:148` (`_close_position` 메서드 바로 아래에 추가)
|
||||||
|
|
||||||
|
### Step 1: `_close_position` 다음에 메서드 추가
|
||||||
|
|
||||||
|
`src/bot.py`에서 `_close_position` 메서드(148~167번째 줄) 바로 뒤에 다음을 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _close_and_reenter(
|
||||||
|
self,
|
||||||
|
position: dict,
|
||||||
|
signal: str,
|
||||||
|
df,
|
||||||
|
btc_df=None,
|
||||||
|
eth_df=None,
|
||||||
|
) -> None:
|
||||||
|
"""기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다."""
|
||||||
|
await self._close_position(position)
|
||||||
|
|
||||||
|
if not self.risk.can_open_new_position():
|
||||||
|
logger.info("최대 포지션 수 도달 — 재진입 건너뜀")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.ml_filter.is_model_loaded():
|
||||||
|
features = build_features(df, signal, btc_df=btc_df, eth_df=eth_df)
|
||||||
|
if not self.ml_filter.should_enter(features):
|
||||||
|
logger.info(f"ML 필터 차단: {signal} 재진입 무시")
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._open_position(signal, df)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: 테스트 실행 — 통과 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/test_reverse_reenter.sh 2
|
||||||
|
```
|
||||||
|
|
||||||
|
예상 결과: 3개 PASS
|
||||||
|
|
||||||
|
### Step 3: 커밋
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/bot.py tests/test_bot.py
|
||||||
|
git commit -m "feat: add _close_and_reenter method for reverse signal handling"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `process_candle` 분기 수정
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py:83-85`
|
||||||
|
|
||||||
|
### Step 1: 기존 분기 테스트 추가
|
||||||
|
|
||||||
|
`tests/test_bot.py`에 다음 테스트를 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_candle_calls_close_and_reenter_on_reverse_signal(config, sample_df):
|
||||||
|
"""반대 시그널 시 process_candle이 _close_and_reenter를 호출해야 한다."""
|
||||||
|
with patch("src.bot.BinanceFuturesClient"):
|
||||||
|
bot = TradingBot(config)
|
||||||
|
|
||||||
|
bot.exchange = AsyncMock()
|
||||||
|
bot.exchange.get_position = AsyncMock(return_value={
|
||||||
|
"positionAmt": "100",
|
||||||
|
"entryPrice": "0.5",
|
||||||
|
"markPrice": "0.52",
|
||||||
|
})
|
||||||
|
bot._close_and_reenter = AsyncMock()
|
||||||
|
bot.ml_filter = MagicMock()
|
||||||
|
bot.ml_filter.is_model_loaded.return_value = False
|
||||||
|
bot.ml_filter.should_enter.return_value = True
|
||||||
|
|
||||||
|
with patch("src.bot.Indicators") as MockInd:
|
||||||
|
mock_ind = MagicMock()
|
||||||
|
mock_ind.calculate_all.return_value = sample_df
|
||||||
|
mock_ind.get_signal.return_value = "SHORT" # 현재 LONG 포지션에 반대 시그널
|
||||||
|
MockInd.return_value = mock_ind
|
||||||
|
await bot.process_candle(sample_df)
|
||||||
|
|
||||||
|
bot._close_and_reenter.assert_awaited_once()
|
||||||
|
call_args = bot._close_and_reenter.call_args
|
||||||
|
assert call_args.args[1] == "SHORT"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: 테스트 실행 — 실패 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/test_reverse_reenter.sh 3
|
||||||
|
```
|
||||||
|
|
||||||
|
예상 결과: FAIL (`_close_and_reenter`가 아직 호출되지 않음)
|
||||||
|
|
||||||
|
### Step 3: `process_candle` 수정
|
||||||
|
|
||||||
|
`src/bot.py`에서 아래 부분을 찾아 수정한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 변경 전 (81~85번째 줄 근처)
|
||||||
|
elif position is not None:
|
||||||
|
pos_side = "LONG" if float(position["positionAmt"]) > 0 else "SHORT"
|
||||||
|
if (pos_side == "LONG" and signal == "SHORT") or \
|
||||||
|
(pos_side == "SHORT" and signal == "LONG"):
|
||||||
|
await self._close_position(position)
|
||||||
|
|
||||||
|
# 변경 후
|
||||||
|
elif position is not None:
|
||||||
|
pos_side = "LONG" if float(position["positionAmt"]) > 0 else "SHORT"
|
||||||
|
if (pos_side == "LONG" and signal == "SHORT") or \
|
||||||
|
(pos_side == "SHORT" and signal == "LONG"):
|
||||||
|
await self._close_and_reenter(
|
||||||
|
position, signal, df_with_indicators, btc_df=btc_df, eth_df=eth_df
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: 전체 테스트 실행 — 통과 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/test_reverse_reenter.sh bot
|
||||||
|
```
|
||||||
|
|
||||||
|
예상 결과: 전체 PASS (기존 테스트 포함)
|
||||||
|
|
||||||
|
### Step 5: 커밋
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/bot.py tests/test_bot.py
|
||||||
|
git commit -m "feat: call _close_and_reenter on reverse signal in process_candle"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: 전체 테스트 스위트 확인
|
||||||
|
|
||||||
|
### Step 1: 전체 테스트 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/test_reverse_reenter.sh all
|
||||||
|
```
|
||||||
|
|
||||||
|
예상 결과: 모든 테스트 PASS
|
||||||
|
|
||||||
|
### Step 2: 실패 테스트 있으면 수정 후 재실행
|
||||||
|
|
||||||
|
실패가 있으면 원인을 파악하고 수정한다. 기존 테스트를 깨뜨리지 않도록 주의.
|
||||||
203
docs/plans/2026-03-02-rs-divide-mlx-nan-fix.md
Normal file
203
docs/plans/2026-03-02-rs-divide-mlx-nan-fix.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# RS np.divide 복구 / MLX NaN-Safe 통계 저장 구현 계획
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** RS(상대강도) 계산의 epsilon 폭발 이상치를 `np.divide` 방식으로 제거하고, MLXFilter의 `self._mean`/`self._std`에 NaN이 잔류하는 근본 허점을 차단한다.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- `src/dataset_builder.py`: `xrp_btc_rs_raw` / `xrp_eth_rs_raw` 계산을 `np.divide(..., where=...)` 방식으로 교체. 분모(btc_r1, eth_r1)가 0이면 결과를 0.0으로 채워 rolling zscore 윈도우 오염을 방지한다.
|
||||||
|
- `src/mlx_filter.py`: `fit()` 내부에서 `self._mean`/`self._std`를 저장하기 전에 `nan_to_num`을 적용해 전체-NaN 컬럼(OI 초반 구간 등)이 `predict_proba` 시점까지 NaN을 전파하지 않도록 한다.
|
||||||
|
|
||||||
|
**Tech Stack:** numpy, pandas, pytest, mlx(Apple Silicon 전용 — MLX 테스트는 Mac에서만 실행)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `dataset_builder.py` — RS 계산을 `np.divide` 방식으로 교체
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/dataset_builder.py:245-246`
|
||||||
|
- Test: `tests/test_dataset_builder.py`
|
||||||
|
|
||||||
|
**배경:**
|
||||||
|
`btc_r1 = 0.0`(15분 동안 BTC 가격 변동 없음)일 때 `xrp_r1 / (btc_r1 + 1e-8)`는 최대 수백만의 이상치를 만든다. 이 이상치가 288캔들 rolling zscore 윈도우에 들어가면 나머지 287개 값이 전부 0에 가깝게 압사된다.
|
||||||
|
|
||||||
|
**Step 1: 기존 테스트 실행 (기준선 확인)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_dataset_builder.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 모든 테스트 PASS (변경 전 기준선)
|
||||||
|
|
||||||
|
**Step 2: RS 제로-분모 테스트 작성**
|
||||||
|
|
||||||
|
`tests/test_dataset_builder.py` 파일 끝에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_rs_zero_denominator():
|
||||||
|
"""btc_r1=0일 때 RS가 inf/nan이 아닌 0.0이어야 한다 (np.divide 방식 검증)."""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators
|
||||||
|
|
||||||
|
n = 500
|
||||||
|
np.random.seed(7)
|
||||||
|
# XRP close: 약간의 변동
|
||||||
|
xrp_close = np.cumprod(1 + np.random.randn(n) * 0.001) * 1.0
|
||||||
|
xrp_df = pd.DataFrame({
|
||||||
|
"open": xrp_close * 0.999,
|
||||||
|
"high": xrp_close * 1.005,
|
||||||
|
"low": xrp_close * 0.995,
|
||||||
|
"close": xrp_close,
|
||||||
|
"volume": np.random.rand(n) * 1000 + 500,
|
||||||
|
})
|
||||||
|
# BTC close: 완전히 고정 → btc_r1 = 0.0
|
||||||
|
btc_close = np.ones(n) * 50000.0
|
||||||
|
btc_df = pd.DataFrame({
|
||||||
|
"open": btc_close,
|
||||||
|
"high": btc_close,
|
||||||
|
"low": btc_close,
|
||||||
|
"close": btc_close,
|
||||||
|
"volume": np.random.rand(n) * 1000 + 500,
|
||||||
|
})
|
||||||
|
|
||||||
|
from src.dataset_builder import generate_dataset_vectorized
|
||||||
|
result = generate_dataset_vectorized(xrp_df, btc_df=btc_df)
|
||||||
|
|
||||||
|
if result.empty:
|
||||||
|
pytest.skip("신호 없음")
|
||||||
|
|
||||||
|
assert "xrp_btc_rs" in result.columns, "xrp_btc_rs 컬럼이 있어야 함"
|
||||||
|
assert not result["xrp_btc_rs"].isin([np.inf, -np.inf]).any(), \
|
||||||
|
"xrp_btc_rs에 inf가 있으면 안 됨"
|
||||||
|
assert not result["xrp_btc_rs"].isna().all(), \
|
||||||
|
"xrp_btc_rs가 전부 nan이면 안 됨"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 테스트 실행 (FAIL 확인)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_dataset_builder.py::test_rs_zero_denominator -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `xrp_btc_rs에 inf가 있으면 안 됨` (현재 epsilon 방식은 inf 대신 수백만 이상치를 만들어 rolling zscore 후 nan이 될 수 있음)
|
||||||
|
|
||||||
|
> 참고: 현재 코드는 inf를 직접 만들지 않을 수도 있다. 하지만 rolling zscore 후 nan이 생기거나 이상치가 남아있는지 확인하는 것이 목적이다. PASS가 나오더라도 Step 4를 진행한다.
|
||||||
|
|
||||||
|
**Step 4: `dataset_builder.py` 245~246줄 수정**
|
||||||
|
|
||||||
|
`src/dataset_builder.py`의 아래 두 줄을:
|
||||||
|
|
||||||
|
```python
|
||||||
|
xrp_btc_rs_raw = (xrp_r1 / (btc_r1 + 1e-8)).astype(np.float32)
|
||||||
|
xrp_eth_rs_raw = (xrp_r1 / (eth_r1 + 1e-8)).astype(np.float32)
|
||||||
|
```
|
||||||
|
|
||||||
|
다음으로 교체:
|
||||||
|
|
||||||
|
```python
|
||||||
|
xrp_btc_rs_raw = np.divide(
|
||||||
|
xrp_r1, btc_r1,
|
||||||
|
out=np.zeros_like(xrp_r1),
|
||||||
|
where=(btc_r1 != 0),
|
||||||
|
).astype(np.float32)
|
||||||
|
xrp_eth_rs_raw = np.divide(
|
||||||
|
xrp_r1, eth_r1,
|
||||||
|
out=np.zeros_like(xrp_r1),
|
||||||
|
where=(eth_r1 != 0),
|
||||||
|
).astype(np.float32)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: 전체 테스트 실행 (PASS 확인)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_dataset_builder.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 모든 테스트 PASS
|
||||||
|
|
||||||
|
**Step 6: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/dataset_builder.py tests/test_dataset_builder.py
|
||||||
|
git commit -m "fix: RS 계산을 np.divide(where=) 방식으로 교체 — epsilon 이상치 폭발 차단"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `mlx_filter.py` — `self._mean`/`self._std` 저장 전 `nan_to_num` 적용
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/mlx_filter.py:145-146`
|
||||||
|
- Test: `tests/test_mlx_filter.py` (기존 `test_fit_with_nan_features` 활용)
|
||||||
|
|
||||||
|
**배경:**
|
||||||
|
현재 코드는 `self._mean = np.nanmean(X_np, axis=0)`으로 저장한다. 전체가 NaN인 컬럼(Walk-Forward 초반 11개월의 OI 데이터)이 있으면 `np.nanmean`은 해당 컬럼의 평균으로 NaN을 반환한다. 이 NaN이 `self._mean`에 저장되면 `predict_proba` 시점에 `(X_np - self._mean)`이 NaN이 되어 OI 데이터를 영원히 활용하지 못한다.
|
||||||
|
|
||||||
|
**Step 1: 기존 테스트 실행 (기준선 확인)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_mlx_filter.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 모든 테스트 PASS (MLX 없는 환경에서는 전체 SKIP)
|
||||||
|
|
||||||
|
**Step 2: `mlx_filter.py` 145~146줄 수정**
|
||||||
|
|
||||||
|
`src/mlx_filter.py`의 아래 두 줄을:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._mean = np.nanmean(X_np, axis=0)
|
||||||
|
self._std = np.nanstd(X_np, axis=0) + 1e-8
|
||||||
|
```
|
||||||
|
|
||||||
|
다음으로 교체:
|
||||||
|
|
||||||
|
```python
|
||||||
|
mean_vals = np.nanmean(X_np, axis=0)
|
||||||
|
self._mean = np.nan_to_num(mean_vals, nan=0.0) # 전체-NaN 컬럼 → 평균 0.0
|
||||||
|
std_vals = np.nanstd(X_np, axis=0)
|
||||||
|
self._std = np.nan_to_num(std_vals, nan=1.0) + 1e-8 # 전체-NaN 컬럼 → std 1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 테스트 실행 (PASS 확인)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_mlx_filter.py::test_fit_with_nan_features -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS (MLX 없는 환경에서는 SKIP)
|
||||||
|
|
||||||
|
**Step 4: 전체 테스트 실행**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_mlx_filter.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 모든 테스트 PASS (또는 SKIP)
|
||||||
|
|
||||||
|
**Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/mlx_filter.py
|
||||||
|
git commit -m "fix: MLXFilter self._mean/std 저장 전 nan_to_num 적용 — 전체-NaN 컬럼 predict_proba 오염 차단"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: 전체 테스트 통과 확인
|
||||||
|
|
||||||
|
**Step 1: 전체 테스트 실행**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/ -v --tb=short 2>&1 | tail -40
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 모든 테스트 PASS (MLX 관련은 SKIP 허용)
|
||||||
|
|
||||||
|
**Step 2: 최종 커밋 (필요 시)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: RS epsilon 폭발 차단 + MLX NaN-Safe 통계 저장 통합"
|
||||||
|
```
|
||||||
300
docs/plans/2026-03-02-user-data-stream-tp-sl-detection-design.md
Normal file
300
docs/plans/2026-03-02-user-data-stream-tp-sl-detection-design.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
# User Data Stream TP/SL 감지 설계
|
||||||
|
|
||||||
|
**날짜:** 2026-03-02
|
||||||
|
**목적:** Binance Futures User Data Stream을 도입하여 TP/SL 작동을 실시간 감지하고, 순수익(Net PnL)을 기록하며, Discord에 상세 청산 알림을 전송한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 배경 및 문제
|
||||||
|
|
||||||
|
기존 봇은 매 캔들 마감마다 `get_position()`을 폴링하여 포지션 소멸 여부를 확인하는 방식이었다. 이 구조의 한계:
|
||||||
|
|
||||||
|
1. **TP/SL 작동 후 최대 15분 지연** — 캔들 마감 전까지 감지 불가
|
||||||
|
2. **청산 원인 구분 불가** — TP인지 SL인지 수동 청산인지 알 수 없음
|
||||||
|
3. **PnL 기록 누락** — `_close_position()`을 봇이 직접 호출하지 않으면 `record_pnl()` 미실행
|
||||||
|
4. **Discord 알림 누락** — 동일 이유로 `notify_close()` 미호출
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 선택한 접근 방식
|
||||||
|
|
||||||
|
**방식 A: `python-binance` 내장 User Data Stream + 30분 수동 keepalive 보강**
|
||||||
|
|
||||||
|
- 기존 `BinanceSocketManager` 활용으로 추가 의존성 없음
|
||||||
|
- `futures_user_socket(listenKey)`로 User Data Stream 연결
|
||||||
|
- 별도 30분 keepalive 백그라운드 태스크로 안정성 보강
|
||||||
|
- `while True: try-except` 무한 재연결 루프로 네트워크 단절 복구
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 전체 아키텍처
|
||||||
|
|
||||||
|
### 파일 변경 목록
|
||||||
|
|
||||||
|
| 파일 | 변경 유형 | 내용 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `src/user_data_stream.py` | **신규** | User Data Stream 전담 클래스 |
|
||||||
|
| `src/bot.py` | 수정 | `UserDataStream` 초기화, `run()` 병렬 실행, `_on_position_closed()` 콜백, `_entry_price`/`_entry_quantity` 상태 추가 |
|
||||||
|
| `src/exchange.py` | 수정 | `create_listen_key()`, `keepalive_listen_key()`, `delete_listen_key()` 메서드 추가 |
|
||||||
|
| `src/notifier.py` | 수정 | `notify_close()`에 `close_reason`, `estimated_pnl`, `net_pnl` 파라미터 추가 |
|
||||||
|
| `src/risk_manager.py` | 수정 | `record_pnl()`이 net_pnl을 받도록 유지 (인터페이스 변경 없음) |
|
||||||
|
|
||||||
|
### 실행 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
bot.run()
|
||||||
|
└── AsyncClient 단일 인스턴스 생성
|
||||||
|
└── asyncio.gather()
|
||||||
|
├── MultiSymbolStream.start(client) ← 기존 캔들 스트림
|
||||||
|
└── UserDataStream.start() ← 신규
|
||||||
|
├── [백그라운드] _keepalive_loop() 30분마다 PUT /listenKey
|
||||||
|
└── [메인루프] while True:
|
||||||
|
try:
|
||||||
|
listenKey 발급
|
||||||
|
futures_user_socket() 연결
|
||||||
|
async for msg: _handle_message()
|
||||||
|
except CancelledError: break
|
||||||
|
except Exception: sleep(5) → 재연결
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 섹션 1: UserDataStream 클래스 (`src/user_data_stream.py`)
|
||||||
|
|
||||||
|
### 상수
|
||||||
|
|
||||||
|
```python
|
||||||
|
KEEPALIVE_INTERVAL = 30 * 60 # 30분 (listenKey 만료 60분의 절반)
|
||||||
|
RECONNECT_DELAY = 5 # 재연결 대기 초
|
||||||
|
```
|
||||||
|
|
||||||
|
### listenKey 생명주기
|
||||||
|
|
||||||
|
| 단계 | API | 시점 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 발급 | `POST /fapi/v1/listenKey` | 연결 시작 / 재연결 시 |
|
||||||
|
| 갱신 | `PUT /fapi/v1/listenKey` | 30분마다 (백그라운드 태스크) |
|
||||||
|
| 삭제 | `DELETE /fapi/v1/listenKey` | 봇 정상 종료 시 (`CancelledError`) |
|
||||||
|
|
||||||
|
### 재연결 로직
|
||||||
|
|
||||||
|
```python
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
listen_key = await exchange.create_listen_key()
|
||||||
|
keepalive_task = asyncio.create_task(_keepalive_loop(listen_key))
|
||||||
|
async with bm.futures_user_socket(listen_key):
|
||||||
|
async for msg:
|
||||||
|
await _handle_message(msg)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await exchange.delete_listen_key(listen_key)
|
||||||
|
keepalive_task.cancel()
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"User Data Stream 끊김: {e}, {RECONNECT_DELAY}초 후 재연결")
|
||||||
|
keepalive_task.cancel()
|
||||||
|
await asyncio.sleep(RECONNECT_DELAY)
|
||||||
|
# while True 상단으로 돌아가 listenKey 재발급
|
||||||
|
```
|
||||||
|
|
||||||
|
### keepalive 백그라운드 태스크
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _keepalive_loop(listen_key: str):
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(KEEPALIVE_INTERVAL)
|
||||||
|
try:
|
||||||
|
await exchange.keepalive_listen_key(listen_key)
|
||||||
|
logger.debug("listenKey 갱신 완료")
|
||||||
|
except Exception:
|
||||||
|
logger.warning("listenKey 갱신 실패 → 재연결 루프가 처리")
|
||||||
|
break # 재연결 루프가 새 태스크 생성
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 섹션 2: 이벤트 파싱 로직
|
||||||
|
|
||||||
|
### 페이로드 구조 (Binance Futures ORDER_TRADE_UPDATE)
|
||||||
|
|
||||||
|
주문 상세 정보는 최상위가 아닌 **내부 `"o"` 딕셔너리에 중첩**되어 있다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"e": "ORDER_TRADE_UPDATE",
|
||||||
|
"o": {
|
||||||
|
"x": "TRADE", // Execution Type
|
||||||
|
"X": "FILLED", // Order Status
|
||||||
|
"o": "TAKE_PROFIT_MARKET", // Order Type
|
||||||
|
"R": true, // reduceOnly
|
||||||
|
"rp": "0.48210000", // realizedProfit
|
||||||
|
"n": "0.02100000", // commission
|
||||||
|
"ap": "1.3393" // average price (체결가)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 판단 트리
|
||||||
|
|
||||||
|
```
|
||||||
|
msg["e"] == "ORDER_TRADE_UPDATE"?
|
||||||
|
└── order = msg["o"]
|
||||||
|
order["x"] == "TRADE" AND order["X"] == "FILLED"?
|
||||||
|
└── 청산 주문인가?
|
||||||
|
(order["R"] == true OR float(order["rp"]) != 0
|
||||||
|
OR order["o"] in {"TAKE_PROFIT_MARKET", "STOP_MARKET"})
|
||||||
|
├── NO → 무시 (진입 주문)
|
||||||
|
└── YES → close_reason 판별:
|
||||||
|
"TAKE_PROFIT_MARKET" → "TP"
|
||||||
|
"STOP_MARKET" → "SL"
|
||||||
|
그 외 → "MANUAL"
|
||||||
|
net_pnl = float(rp) - abs(float(n))
|
||||||
|
exit_price = float(order["ap"])
|
||||||
|
await on_order_filled(net_pnl, close_reason, exit_price)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 섹션 3: `_on_position_closed()` 콜백 (`src/bot.py`)
|
||||||
|
|
||||||
|
### 진입가 상태 저장
|
||||||
|
|
||||||
|
`_open_position()` 내부에서 진입가와 수량을 인스턴스 변수로 저장한다. 청산 시점에는 포지션이 이미 사라져 있으므로 사전 저장이 필수다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# __init__에 추가
|
||||||
|
self._entry_price: float | None = None
|
||||||
|
self._entry_quantity: float | None = None
|
||||||
|
|
||||||
|
# _open_position() 내부에서 저장
|
||||||
|
self._entry_price = price
|
||||||
|
self._entry_quantity = quantity
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예상 PnL 계산
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _calc_estimated_pnl(self, exit_price: float) -> float:
|
||||||
|
if self._entry_price is None or self._entry_quantity is None:
|
||||||
|
return 0.0
|
||||||
|
if self.current_trade_side == "LONG":
|
||||||
|
return (exit_price - self._entry_price) * self._entry_quantity
|
||||||
|
else: # SHORT
|
||||||
|
return (self._entry_price - exit_price) * self._entry_quantity
|
||||||
|
```
|
||||||
|
|
||||||
|
### 콜백 전체 흐름
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _on_position_closed(
|
||||||
|
self,
|
||||||
|
net_pnl: float,
|
||||||
|
close_reason: str, # "TP" | "SL" | "MANUAL"
|
||||||
|
exit_price: float,
|
||||||
|
):
|
||||||
|
estimated_pnl = self._calc_estimated_pnl(exit_price)
|
||||||
|
diff = net_pnl - estimated_pnl # 슬리피지 + 수수료 차이
|
||||||
|
|
||||||
|
# RiskManager에 순수익 기록
|
||||||
|
self.risk.record_pnl(net_pnl)
|
||||||
|
|
||||||
|
# Discord 알림
|
||||||
|
self.notifier.notify_close(
|
||||||
|
symbol=self.config.symbol,
|
||||||
|
side=self.current_trade_side or "UNKNOWN",
|
||||||
|
close_reason=close_reason,
|
||||||
|
exit_price=exit_price,
|
||||||
|
estimated_pnl=estimated_pnl,
|
||||||
|
net_pnl=net_pnl,
|
||||||
|
diff=diff,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
f"포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
|
||||||
|
f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 봇 상태 초기화 (Flat 상태로 복귀)
|
||||||
|
self.current_trade_side = None
|
||||||
|
self._entry_price = None
|
||||||
|
self._entry_quantity = None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 기존 `_close_position()` 변경
|
||||||
|
|
||||||
|
봇이 직접 청산하는 경우(`_close_and_reenter`)에도 User Data Stream의 `ORDER_TRADE_UPDATE`가 발생한다. **중복 처리 방지**를 위해 `_close_position()`에서 `notify_close()`와 `record_pnl()` 호출을 제거한다. 모든 청산 후처리는 `_on_position_closed()` 콜백 하나로 일원화한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 섹션 4: Discord 알림 포맷 (`src/notifier.py`)
|
||||||
|
|
||||||
|
### `notify_close()` 시그니처 변경
|
||||||
|
|
||||||
|
```python
|
||||||
|
def notify_close(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
side: str,
|
||||||
|
close_reason: str, # "TP" | "SL" | "MANUAL"
|
||||||
|
exit_price: float,
|
||||||
|
estimated_pnl: float,
|
||||||
|
net_pnl: float,
|
||||||
|
diff: float, # net_pnl - estimated_pnl
|
||||||
|
) -> None:
|
||||||
|
```
|
||||||
|
|
||||||
|
### 알림 포맷
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ [XRPUSDT] SHORT TP 청산
|
||||||
|
청산가: `1.3393`
|
||||||
|
예상 수익: `+0.4821 USDT`
|
||||||
|
실제 순수익: `+0.4612 USDT`
|
||||||
|
차이(슬리피지+수수료): `-0.0209 USDT`
|
||||||
|
```
|
||||||
|
|
||||||
|
| 청산 원인 | 이모지 |
|
||||||
|
|----------|--------|
|
||||||
|
| TP | ✅ |
|
||||||
|
| SL | ❌ |
|
||||||
|
| MANUAL | 🔶 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 섹션 5: `src/exchange.py` 추가 메서드
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def create_listen_key(self) -> str:
|
||||||
|
"""POST /fapi/v1/listenKey — listenKey 신규 발급"""
|
||||||
|
|
||||||
|
async def keepalive_listen_key(self, listen_key: str) -> None:
|
||||||
|
"""PUT /fapi/v1/listenKey — listenKey 만료 연장"""
|
||||||
|
|
||||||
|
async def delete_listen_key(self, listen_key: str) -> None:
|
||||||
|
"""DELETE /fapi/v1/listenKey — listenKey 삭제 (정상 종료 시)"""
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 흐름 요약
|
||||||
|
|
||||||
|
```
|
||||||
|
Binance WebSocket
|
||||||
|
→ ORDER_TRADE_UPDATE (FILLED, reduceOnly)
|
||||||
|
→ UserDataStream._handle_message()
|
||||||
|
→ net_pnl = rp - |commission|
|
||||||
|
→ bot._on_position_closed(net_pnl, close_reason, exit_price)
|
||||||
|
├── estimated_pnl = (exit - entry) × qty (봇 계산)
|
||||||
|
├── diff = net_pnl - estimated_pnl
|
||||||
|
├── risk.record_pnl(net_pnl) → 일일 PnL 누적
|
||||||
|
├── notifier.notify_close(...) → Discord 알림
|
||||||
|
└── 상태 초기화 (current_trade_side, _entry_price, _entry_quantity = None)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 제외 범위 (YAGNI)
|
||||||
|
|
||||||
|
- DB 영구 저장 (SQLite/Postgres) — 현재 로그 기반으로 충분
|
||||||
|
- 진입 주문 체결 알림 (`TRADE` + not reduceOnly) — 기존 `notify_open()`으로 커버
|
||||||
|
- 부분 청산(partial fill) 처리 — 현재 봇은 전량 청산만 사용
|
||||||
510
docs/plans/2026-03-02-user-data-stream-tp-sl-detection-plan.md
Normal file
510
docs/plans/2026-03-02-user-data-stream-tp-sl-detection-plan.md
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
# User Data Stream TP/SL 감지 Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Binance Futures User Data Stream을 도입하여 TP/SL 작동을 실시간 감지하고, 순수익(Net PnL)을 기록하며, Discord에 예상 수익 vs 실제 순수익 비교 알림을 전송한다.
|
||||||
|
|
||||||
|
**Architecture:** `python-binance`의 `futures_user_socket(listenKey)`로 User Data Stream에 연결하고, 30분 keepalive 백그라운드 태스크와 `while True: try-except` 무한 재연결 루프로 안정성을 확보한다. `ORDER_TRADE_UPDATE` 이벤트에서 청산 주문을 감지하면 `bot._on_position_closed()` 콜백을 호출하여 PnL 기록과 Discord 알림을 일원화한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, python-binance (AsyncClient, BinanceSocketManager), asyncio, loguru
|
||||||
|
|
||||||
|
**Design Doc:** `docs/plans/2026-03-02-user-data-stream-tp-sl-detection-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `exchange.py`에 listenKey 관리 메서드 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/exchange.py` (끝에 메서드 추가)
|
||||||
|
|
||||||
|
**Step 1: listenKey 3개 메서드 구현**
|
||||||
|
|
||||||
|
`src/exchange.py` 끝에 아래 메서드 3개를 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def create_listen_key(self) -> str:
|
||||||
|
"""POST /fapi/v1/listenKey — listenKey 신규 발급"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self.client.futures_stream_get_listen_key(),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def keepalive_listen_key(self, listen_key: str) -> None:
|
||||||
|
"""PUT /fapi/v1/listenKey — listenKey 만료 연장 (60분 → 리셋)"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self.client.futures_stream_keepalive(listenKey=listen_key),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_listen_key(self, listen_key: str) -> None:
|
||||||
|
"""DELETE /fapi/v1/listenKey — listenKey 삭제 (정상 종료 시)"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self.client.futures_stream_close(listenKey=listen_key),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"listenKey 삭제 실패 (무시): {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/exchange.py
|
||||||
|
git commit -m "feat: add listenKey create/keepalive/delete methods to exchange"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `notifier.py`의 `notify_close()` 시그니처 확장
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/notifier.py`
|
||||||
|
|
||||||
|
**Step 1: `notify_close()` 메서드 교체**
|
||||||
|
|
||||||
|
기존 `notify_close()`를 아래로 교체한다. `close_reason`, `estimated_pnl`, `net_pnl`, `diff` 파라미터가 추가된다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def notify_close(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
side: str,
|
||||||
|
close_reason: str, # "TP" | "SL" | "MANUAL"
|
||||||
|
exit_price: float,
|
||||||
|
estimated_pnl: float, # 봇 계산 (entry-exit 기반)
|
||||||
|
net_pnl: float, # 바이낸스 rp - |commission|
|
||||||
|
diff: float, # net_pnl - estimated_pnl (슬리피지+수수료)
|
||||||
|
) -> None:
|
||||||
|
emoji_map = {"TP": "✅", "SL": "❌", "MANUAL": "🔶"}
|
||||||
|
emoji = emoji_map.get(close_reason, "🔶")
|
||||||
|
msg = (
|
||||||
|
f"{emoji} **[{symbol}] {side} {close_reason} 청산**\n"
|
||||||
|
f"청산가: `{exit_price:.4f}`\n"
|
||||||
|
f"예상 수익: `{estimated_pnl:+.4f} USDT`\n"
|
||||||
|
f"실제 순수익: `{net_pnl:+.4f} USDT`\n"
|
||||||
|
f"차이(슬리피지+수수료): `{diff:+.4f} USDT`"
|
||||||
|
)
|
||||||
|
self._send(msg)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/notifier.py
|
||||||
|
git commit -m "feat: extend notify_close with close_reason, net_pnl, diff fields"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `src/user_data_stream.py` 신규 생성
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/user_data_stream.py`
|
||||||
|
|
||||||
|
**Step 1: 파일 전체 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from typing import Callable
|
||||||
|
from binance import AsyncClient, BinanceSocketManager
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
_KEEPALIVE_INTERVAL = 30 * 60 # 30분 (listenKey 만료 60분의 절반)
|
||||||
|
_RECONNECT_DELAY = 5 # 재연결 대기 초
|
||||||
|
|
||||||
|
_CLOSE_ORDER_TYPES = {"TAKE_PROFIT_MARKET", "STOP_MARKET"}
|
||||||
|
|
||||||
|
|
||||||
|
class UserDataStream:
|
||||||
|
"""
|
||||||
|
Binance Futures User Data Stream을 구독하여 주문 체결 이벤트를 처리한다.
|
||||||
|
|
||||||
|
- listenKey 30분 keepalive 백그라운드 태스크
|
||||||
|
- 네트워크 단절 시 무한 재연결 루프
|
||||||
|
- ORDER_TRADE_UPDATE 이벤트에서 청산 주문만 필터링하여 콜백 호출
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
exchange, # BinanceFuturesClient 인스턴스
|
||||||
|
on_order_filled: Callable, # bot._on_position_closed 콜백
|
||||||
|
):
|
||||||
|
self._exchange = exchange
|
||||||
|
self._on_order_filled = on_order_filled
|
||||||
|
self._listen_key: str | None = None
|
||||||
|
self._keepalive_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
async def start(self, api_key: str, api_secret: str) -> None:
|
||||||
|
"""User Data Stream 메인 루프 — 봇 종료 시까지 실행."""
|
||||||
|
client = await AsyncClient.create(
|
||||||
|
api_key=api_key,
|
||||||
|
api_secret=api_secret,
|
||||||
|
)
|
||||||
|
bm = BinanceSocketManager(client)
|
||||||
|
try:
|
||||||
|
await self._run_loop(bm)
|
||||||
|
finally:
|
||||||
|
await client.close_connection()
|
||||||
|
|
||||||
|
async def _run_loop(self, bm: BinanceSocketManager) -> None:
|
||||||
|
"""listenKey 발급 → 연결 → 재연결 무한 루프."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
self._listen_key = await self._exchange.create_listen_key()
|
||||||
|
logger.info(f"User Data Stream listenKey 발급: {self._listen_key[:8]}...")
|
||||||
|
|
||||||
|
self._keepalive_task = asyncio.create_task(
|
||||||
|
self._keepalive_loop(self._listen_key)
|
||||||
|
)
|
||||||
|
|
||||||
|
async with bm.futures_user_socket(self._listen_key) as stream:
|
||||||
|
logger.info("User Data Stream 연결 완료")
|
||||||
|
async for msg in stream:
|
||||||
|
await self._handle_message(msg)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("User Data Stream 정상 종료")
|
||||||
|
if self._listen_key:
|
||||||
|
await self._exchange.delete_listen_key(self._listen_key)
|
||||||
|
if self._keepalive_task:
|
||||||
|
self._keepalive_task.cancel()
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"User Data Stream 끊김: {e} — "
|
||||||
|
f"{_RECONNECT_DELAY}초 후 재연결"
|
||||||
|
)
|
||||||
|
if self._keepalive_task:
|
||||||
|
self._keepalive_task.cancel()
|
||||||
|
self._keepalive_task = None
|
||||||
|
await asyncio.sleep(_RECONNECT_DELAY)
|
||||||
|
|
||||||
|
async def _keepalive_loop(self, listen_key: str) -> None:
|
||||||
|
"""30분마다 listenKey를 갱신한다."""
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(_KEEPALIVE_INTERVAL)
|
||||||
|
try:
|
||||||
|
await self._exchange.keepalive_listen_key(listen_key)
|
||||||
|
logger.debug("listenKey 갱신 완료")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"listenKey 갱신 실패: {e} — 재연결 루프가 처리")
|
||||||
|
break
|
||||||
|
|
||||||
|
async def _handle_message(self, msg: dict) -> None:
|
||||||
|
"""ORDER_TRADE_UPDATE 이벤트에서 청산 주문을 필터링하여 콜백을 호출한다."""
|
||||||
|
if msg.get("e") != "ORDER_TRADE_UPDATE":
|
||||||
|
return
|
||||||
|
|
||||||
|
order = msg.get("o", {})
|
||||||
|
|
||||||
|
# x: Execution Type, X: Order Status
|
||||||
|
if order.get("x") != "TRADE" or order.get("X") != "FILLED":
|
||||||
|
return
|
||||||
|
|
||||||
|
order_type = order.get("o", "")
|
||||||
|
is_reduce = order.get("R", False)
|
||||||
|
realized_pnl = float(order.get("rp", "0"))
|
||||||
|
|
||||||
|
# 청산 주문 판별: reduceOnly이거나, TP/SL 타입이거나, rp != 0
|
||||||
|
is_close = is_reduce or order_type in _CLOSE_ORDER_TYPES or realized_pnl != 0
|
||||||
|
if not is_close:
|
||||||
|
return
|
||||||
|
|
||||||
|
commission = abs(float(order.get("n", "0")))
|
||||||
|
net_pnl = realized_pnl - commission
|
||||||
|
exit_price = float(order.get("ap", "0"))
|
||||||
|
|
||||||
|
if order_type == "TAKE_PROFIT_MARKET":
|
||||||
|
close_reason = "TP"
|
||||||
|
elif order_type == "STOP_MARKET":
|
||||||
|
close_reason = "SL"
|
||||||
|
else:
|
||||||
|
close_reason = "MANUAL"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"청산 감지({close_reason}): exit={exit_price:.4f}, "
|
||||||
|
f"rp={realized_pnl:+.4f}, commission={commission:.4f}, "
|
||||||
|
f"net_pnl={net_pnl:+.4f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._on_order_filled(
|
||||||
|
net_pnl=net_pnl,
|
||||||
|
close_reason=close_reason,
|
||||||
|
exit_price=exit_price,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/user_data_stream.py
|
||||||
|
git commit -m "feat: add UserDataStream with keepalive and reconnect loop"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `bot.py` 수정 — 상태 변수 추가 및 `_open_position()` 저장
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py`
|
||||||
|
|
||||||
|
**Step 1: `__init__`에 상태 변수 추가**
|
||||||
|
|
||||||
|
`TradingBot.__init__()` 내부에서 `self.current_trade_side` 선언 바로 아래에 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._entry_price: float | None = None
|
||||||
|
self._entry_quantity: float | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: `_open_position()` 내부에서 진입가/수량 저장**
|
||||||
|
|
||||||
|
`self.current_trade_side = signal` 바로 아래에 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._entry_price = price
|
||||||
|
self._entry_quantity = quantity
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/bot.py
|
||||||
|
git commit -m "feat: store entry_price and entry_quantity on position open"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: `bot.py` 수정 — `_on_position_closed()` 콜백 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py`
|
||||||
|
|
||||||
|
**Step 1: `_calc_estimated_pnl()` 헬퍼 메서드 추가**
|
||||||
|
|
||||||
|
`_close_position()` 메서드 바로 위에 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _calc_estimated_pnl(self, exit_price: float) -> float:
|
||||||
|
"""진입가·수량 기반 예상 PnL 계산 (수수료 미반영)."""
|
||||||
|
if self._entry_price is None or self._entry_quantity is None:
|
||||||
|
return 0.0
|
||||||
|
if self.current_trade_side == "LONG":
|
||||||
|
return (exit_price - self._entry_price) * self._entry_quantity
|
||||||
|
return (self._entry_price - exit_price) * self._entry_quantity
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: `_on_position_closed()` 콜백 추가**
|
||||||
|
|
||||||
|
`_calc_estimated_pnl()` 바로 아래에 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _on_position_closed(
|
||||||
|
self,
|
||||||
|
net_pnl: float,
|
||||||
|
close_reason: str,
|
||||||
|
exit_price: float,
|
||||||
|
) -> None:
|
||||||
|
"""User Data Stream에서 청산 감지 시 호출되는 콜백."""
|
||||||
|
estimated_pnl = self._calc_estimated_pnl(exit_price)
|
||||||
|
diff = net_pnl - estimated_pnl
|
||||||
|
|
||||||
|
self.risk.record_pnl(net_pnl)
|
||||||
|
|
||||||
|
self.notifier.notify_close(
|
||||||
|
symbol=self.config.symbol,
|
||||||
|
side=self.current_trade_side or "UNKNOWN",
|
||||||
|
close_reason=close_reason,
|
||||||
|
exit_price=exit_price,
|
||||||
|
estimated_pnl=estimated_pnl,
|
||||||
|
net_pnl=net_pnl,
|
||||||
|
diff=diff,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
f"포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
|
||||||
|
f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Flat 상태로 초기화
|
||||||
|
self.current_trade_side = None
|
||||||
|
self._entry_price = None
|
||||||
|
self._entry_quantity = None
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/bot.py
|
||||||
|
git commit -m "feat: add _on_position_closed callback with net PnL and discord alert"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: `bot.py` 수정 — `_close_position()`에서 중복 후처리 제거
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py`
|
||||||
|
|
||||||
|
**배경:** 봇이 직접 청산(`_close_and_reenter`)하는 경우에도 User Data Stream의 `ORDER_TRADE_UPDATE`가 발생한다. 중복 방지를 위해 `_close_position()`에서 `notify_close()`와 `record_pnl()` 호출을 제거한다.
|
||||||
|
|
||||||
|
**Step 1: `_close_position()` 수정**
|
||||||
|
|
||||||
|
기존 코드:
|
||||||
|
```python
|
||||||
|
async def _close_position(self, position: dict):
|
||||||
|
amt = abs(float(position["positionAmt"]))
|
||||||
|
side = "SELL" if float(position["positionAmt"]) > 0 else "BUY"
|
||||||
|
pos_side = "LONG" if side == "SELL" else "SHORT"
|
||||||
|
await self.exchange.cancel_all_orders()
|
||||||
|
await self.exchange.place_order(side=side, quantity=amt, reduce_only=True)
|
||||||
|
|
||||||
|
entry = float(position["entryPrice"])
|
||||||
|
mark = float(position["markPrice"])
|
||||||
|
pnl = (mark - entry) * amt if side == "SELL" else (entry - mark) * amt
|
||||||
|
|
||||||
|
self.notifier.notify_close(
|
||||||
|
symbol=self.config.symbol,
|
||||||
|
side=pos_side,
|
||||||
|
exit_price=mark,
|
||||||
|
pnl=pnl,
|
||||||
|
)
|
||||||
|
self.risk.record_pnl(pnl)
|
||||||
|
self.current_trade_side = None
|
||||||
|
logger.success(f"포지션 청산: PnL={pnl:.4f} USDT")
|
||||||
|
```
|
||||||
|
|
||||||
|
수정 후 (`notify_close`, `record_pnl`, `current_trade_side = None` 제거 — User Data Stream 콜백이 처리):
|
||||||
|
```python
|
||||||
|
async def _close_position(self, position: dict):
|
||||||
|
"""포지션 청산 주문만 실행한다. PnL 기록/알림은 _on_position_closed 콜백이 담당."""
|
||||||
|
amt = abs(float(position["positionAmt"]))
|
||||||
|
side = "SELL" if float(position["positionAmt"]) > 0 else "BUY"
|
||||||
|
await self.exchange.cancel_all_orders()
|
||||||
|
await self.exchange.place_order(side=side, quantity=amt, reduce_only=True)
|
||||||
|
logger.info(f"청산 주문 전송 완료 (side={side}, qty={amt})")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/bot.py
|
||||||
|
git commit -m "refactor: remove duplicate pnl/notify from _close_position (handled by callback)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: `bot.py` 수정 — `run()`에서 UserDataStream 병렬 실행
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py`
|
||||||
|
|
||||||
|
**Step 1: import 추가**
|
||||||
|
|
||||||
|
파일 상단 import 블록에 추가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.user_data_stream import UserDataStream
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: `run()` 메서드 수정**
|
||||||
|
|
||||||
|
기존:
|
||||||
|
```python
|
||||||
|
async def run(self):
|
||||||
|
logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x")
|
||||||
|
await self._recover_position()
|
||||||
|
balance = await self.exchange.get_balance()
|
||||||
|
self.risk.set_base_balance(balance)
|
||||||
|
logger.info(f"기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)")
|
||||||
|
await self.stream.start(
|
||||||
|
api_key=self.config.api_key,
|
||||||
|
api_secret=self.config.api_secret,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
수정 후:
|
||||||
|
```python
|
||||||
|
async def run(self):
|
||||||
|
logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x")
|
||||||
|
await self._recover_position()
|
||||||
|
balance = await self.exchange.get_balance()
|
||||||
|
self.risk.set_base_balance(balance)
|
||||||
|
logger.info(f"기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)")
|
||||||
|
|
||||||
|
user_stream = UserDataStream(
|
||||||
|
exchange=self.exchange,
|
||||||
|
on_order_filled=self._on_position_closed,
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
self.stream.start(
|
||||||
|
api_key=self.config.api_key,
|
||||||
|
api_secret=self.config.api_secret,
|
||||||
|
),
|
||||||
|
user_stream.start(
|
||||||
|
api_key=self.config.api_key,
|
||||||
|
api_secret=self.config.api_secret,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/bot.py
|
||||||
|
git commit -m "feat: run UserDataStream in parallel with candle stream"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: README.md 업데이트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
**Step 1: 기능 목록에 User Data Stream 항목 추가**
|
||||||
|
|
||||||
|
README의 주요 기능 섹션에 아래 내용을 추가한다.
|
||||||
|
|
||||||
|
- **실시간 TP/SL 감지**: Binance User Data Stream으로 TP/SL 작동을 즉시 감지 (캔들 마감 대기 없음)
|
||||||
|
- **순수익(Net PnL) 기록**: 바이낸스 `realizedProfit - commission`으로 정확한 순수익 계산
|
||||||
|
- **Discord 상세 청산 알림**: 예상 수익 vs 실제 순수익 + 슬리피지/수수료 차이 표시
|
||||||
|
- **listenKey 자동 갱신**: 30분 keepalive + 네트워크 단절 시 자동 재연결
|
||||||
|
|
||||||
|
**Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md
|
||||||
|
git commit -m "docs: update README with User Data Stream TP/SL detection feature"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 최종 검증
|
||||||
|
|
||||||
|
봇 실행 후 로그에서 아래 메시지가 순서대로 나타나면 정상 동작:
|
||||||
|
|
||||||
|
```
|
||||||
|
INFO | User Data Stream listenKey 발급: xxxxxxxx...
|
||||||
|
INFO | User Data Stream 연결 완료
|
||||||
|
DEBUG | listenKey 갱신 완료 ← 30분 후
|
||||||
|
INFO | 청산 감지(TP): exit=1.3393, rp=+0.4821, commission=0.0209, net_pnl=+0.4612
|
||||||
|
SUCCESS | 포지션 청산(TP): 예상=+0.4821, 순수익=+0.4612, 차이=-0.0209 USDT
|
||||||
|
```
|
||||||
|
|
||||||
|
Discord에는 아래 형식의 알림이 전송됨:
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ [XRPUSDT] SHORT TP 청산
|
||||||
|
청산가: 1.3393
|
||||||
|
예상 수익: +0.4821 USDT
|
||||||
|
실제 순수익: +0.4612 USDT
|
||||||
|
차이(슬리피지+수수료): -0.0209 USDT
|
||||||
|
```
|
||||||
49
docs/plans/2026-03-03-adx-ml-feature-migration-design.md
Normal file
49
docs/plans/2026-03-03-adx-ml-feature-migration-design.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# ADX ML 피처 마이그레이션 설계
|
||||||
|
|
||||||
|
**Goal:** ADX 하드필터(< 25)를 제거하고, ADX를 ML 피처로 추가하여 횡보장 판단을 ML 모델에 위임한다.
|
||||||
|
|
||||||
|
**Background:** 운영 로그 분석 결과, ADX < 25 하드필터가 하루 종일 시그널을 차단하여 ML 필터가 평가할 기회 자체가 없었음. ADX 10~24 구간에서도 수익 가능한 패턴이 존재할 수 있으나, 현재 구조에서는 ML이 이를 학습할 수 없음.
|
||||||
|
|
||||||
|
**Tech Stack:** LightGBM, pandas-ta (기존 사용 중)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 변경 사항
|
||||||
|
|
||||||
|
### 1. ML 피처에 ADX 추가 (23 → 24 피처)
|
||||||
|
|
||||||
|
- `src/ml_features.py`: `FEATURE_COLS`에 `"adx"` 추가
|
||||||
|
- `build_features()`: ADX 값 추출 로직 추가
|
||||||
|
|
||||||
|
### 2. 데이터셋 빌더에서 ADX 하드필터 제거
|
||||||
|
|
||||||
|
- `src/dataset_builder.py`: `_calc_signals()`에서 ADX < 25 → HOLD 강제 로직 제거
|
||||||
|
- ADX 낮은 구간의 시그널도 학습 데이터에 포함됨
|
||||||
|
|
||||||
|
### 3. indicators.py ADX 하드필터 제거
|
||||||
|
|
||||||
|
- `src/indicators.py`: `get_signal()`에서 ADX < 25 early-return 제거
|
||||||
|
- ADX 값은 항상 로그에 남김 (대시보드 표시용)
|
||||||
|
|
||||||
|
### 4. ADX 로깅 개선
|
||||||
|
|
||||||
|
- ADX ≥ 25일 때도 로그 출력 → 대시보드에서 ADX 차트 끊김 해소
|
||||||
|
|
||||||
|
### 5. 테스트 업데이트
|
||||||
|
|
||||||
|
- ADX 하드필터 관련 기존 테스트 수정/제거
|
||||||
|
- ML 피처에 ADX 포함 확인 테스트 추가
|
||||||
|
|
||||||
|
## 데이터 흐름 (변경 후)
|
||||||
|
|
||||||
|
```
|
||||||
|
캔들 → get_signal() → 지표 가중치 기반 LONG/SHORT/HOLD (ADX 필터 없음)
|
||||||
|
→ ADX 값 항상 로그 출력
|
||||||
|
→ signal != HOLD → build_features() [24 피처, ADX 포함]
|
||||||
|
→ ML 필터 (threshold ≥ 0.55) → 진입 판단
|
||||||
|
```
|
||||||
|
|
||||||
|
## 주의 사항
|
||||||
|
|
||||||
|
- 기존 학습된 모델(23 피처)은 24 피처 입력과 호환 안 됨 → **재학습 필수**
|
||||||
|
- 재학습 전까지 봇 운영 불가 → 배포 시 `train_and_deploy.sh` 먼저 실행
|
||||||
227
docs/plans/2026-03-03-adx-ml-feature-migration-plan.md
Normal file
227
docs/plans/2026-03-03-adx-ml-feature-migration-plan.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# ADX ML 피처 마이그레이션 구현 계획
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** ADX 하드필터를 제거하고 ADX를 24번째 ML 피처로 추가하여, 횡보장 판단을 ML 모델에 위임한다. ADX 값을 항상 로그에 남겨 대시보드 끊김도 해소한다.
|
||||||
|
|
||||||
|
**Architecture:** `indicators.py`에서 ADX < 25 early-return 삭제, `ml_features.py`에 ADX 피처 추가 (23 → 24개), `dataset_builder.py`에서 ADX 하드필터 삭제 + ADX 피처 추출 추가. 기존 모델과 호환 안 되므로 재학습 필수.
|
||||||
|
|
||||||
|
**Tech Stack:** LightGBM, pandas-ta, pytest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: ML 피처 테스트 업데이트 (24개 피처)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/test_ml_features.py:52-54`
|
||||||
|
|
||||||
|
**Step 1: Update the test**
|
||||||
|
|
||||||
|
`test_feature_cols_has_23_items`를 24개로 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_feature_cols_has_24_items():
|
||||||
|
from src.ml_features import FEATURE_COLS
|
||||||
|
assert len(FEATURE_COLS) == 24
|
||||||
|
```
|
||||||
|
|
||||||
|
`test_build_features_with_btc_eth_has_21_features`의 assert도 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_build_features_with_btc_eth_has_24_features():
|
||||||
|
xrp_df = _make_df(10, base_price=1.0)
|
||||||
|
btc_df = _make_df(10, base_price=50000.0)
|
||||||
|
eth_df = _make_df(10, base_price=3000.0)
|
||||||
|
features = build_features(xrp_df, "LONG", btc_df=btc_df, eth_df=eth_df)
|
||||||
|
assert len(features) == 24
|
||||||
|
```
|
||||||
|
|
||||||
|
`test_build_features_without_btc_eth_has_13_features`도 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_build_features_without_btc_eth_has_16_features():
|
||||||
|
xrp_df = _make_df(10, base_price=1.0)
|
||||||
|
features = build_features(xrp_df, "LONG")
|
||||||
|
assert len(features) == 16
|
||||||
|
```
|
||||||
|
|
||||||
|
`_make_df`에 `"adx": [20.0] * n` 컬럼 추가.
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_features.py::test_feature_cols_has_24_items -v`
|
||||||
|
Expected: FAIL — 현재 23개
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: FEATURE_COLS에 ADX 추가 + build_features() 수정
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ml_features.py:4-14` (FEATURE_COLS), `src/ml_features.py:98-112` (base dict)
|
||||||
|
|
||||||
|
**Step 3: Add ADX to FEATURE_COLS**
|
||||||
|
|
||||||
|
```python
|
||||||
|
FEATURE_COLS = [
|
||||||
|
"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_ret_1", "btc_ret_3", "btc_ret_5",
|
||||||
|
"eth_ret_1", "eth_ret_3", "eth_ret_5",
|
||||||
|
"xrp_btc_rs", "xrp_eth_rs",
|
||||||
|
"oi_change", "funding_rate",
|
||||||
|
"adx",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Add ADX extraction in build_features()**
|
||||||
|
|
||||||
|
`base` dict 생성 부분 (line 112 이후)에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
base["adx"] = float(last.get("adx", 0))
|
||||||
|
```
|
||||||
|
|
||||||
|
docstring의 "23개 피처"를 "24개 피처"로 변경.
|
||||||
|
|
||||||
|
**Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_features.py -v`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ml_features.py tests/test_ml_features.py
|
||||||
|
git commit -m "feat: add ADX as 24th ML feature"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: indicators.py ADX 하드필터 제거 + 항상 로깅
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/indicators.py:63-67`
|
||||||
|
|
||||||
|
**Step 7: Replace ADX hard filter with always-log**
|
||||||
|
|
||||||
|
`get_signal()` 메서드에서 기존 ADX 필터 코드:
|
||||||
|
|
||||||
|
```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"
|
||||||
|
```
|
||||||
|
|
||||||
|
를 다음으로 교체:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ADX 로깅 (ML 피처로 위임, 하드필터 제거)
|
||||||
|
adx = last.get("adx", None)
|
||||||
|
if adx is not None and not pd.isna(adx):
|
||||||
|
logger.debug(f"ADX: {adx:.1f}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 8: Run ADX-related tests**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_indicators.py -k "adx" -v`
|
||||||
|
Expected: `test_adx_column_exists` PASS, `test_adx_nan_falls_through` PASS, `test_adx_filter_blocks_low_adx` FAIL (필터 제거됨)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: ADX 필터 테스트 업데이트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/test_indicators.py:57-71`
|
||||||
|
|
||||||
|
**Step 9: Replace block test with pass-through test**
|
||||||
|
|
||||||
|
`test_adx_filter_blocks_low_adx`를 제거하고 새 테스트로 교체:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_adx_low_does_not_block_signal(sample_df):
|
||||||
|
"""ADX < 25여도 시그널이 차단되지 않는다 (ML에 위임)."""
|
||||||
|
ind = Indicators(sample_df)
|
||||||
|
df = ind.calculate_all()
|
||||||
|
# 강한 LONG 신호가 나오도록 지표 조작
|
||||||
|
df.loc[df.index[-1], "rsi"] = 20
|
||||||
|
df.loc[df.index[-2], "macd"] = -1
|
||||||
|
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
|
||||||
|
df["adx"] = 15.0
|
||||||
|
signal = ind.get_signal(df)
|
||||||
|
# ADX 낮아도 지표 조건 충족 시 LONG 반환 (ML이 최종 판단)
|
||||||
|
assert signal == "LONG"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 10: Run all indicator tests**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_indicators.py -v`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
**Step 11: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/indicators.py tests/test_indicators.py
|
||||||
|
git commit -m "feat: remove ADX hard filter, delegate to ML"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: dataset_builder.py ADX 하드필터 제거 + ADX 피처 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/dataset_builder.py:119-123` (ADX 필터 삭제), `src/dataset_builder.py:215-230` (ADX 피처 추가)
|
||||||
|
|
||||||
|
**Step 12: Remove ADX hard filter in _calc_signals()**
|
||||||
|
|
||||||
|
`_calc_signals()` 함수에서 다음 코드 삭제 (lines 119-123):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ADX 횡보장 필터: ADX < 25이면 추세 부재로 판단하여 진입 차단
|
||||||
|
if "adx" in d.columns:
|
||||||
|
adx = d["adx"].values
|
||||||
|
low_adx = (~np.isnan(adx)) & (adx < 25)
|
||||||
|
signal_arr[low_adx] = "HOLD"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 13: Add ADX feature to _calc_features_vectorized()**
|
||||||
|
|
||||||
|
`_calc_features_vectorized()` 함수의 `result` DataFrame 생성 부분에 `"adx"` 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ADX (ML 피처로 제공 — rolling z-score 정규화)
|
||||||
|
adx_raw = d["adx"].values.astype(np.float64) if "adx" in d.columns else np.zeros(len(d), dtype=np.float64)
|
||||||
|
adx_z = _rolling_zscore(adx_raw)
|
||||||
|
```
|
||||||
|
|
||||||
|
`result` DataFrame에 `"adx": adx_z,` 추가 (side 다음에).
|
||||||
|
|
||||||
|
**Step 14: Run full test suite**
|
||||||
|
|
||||||
|
Run: `pytest tests/ -v --tb=short`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
**Step 15: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/dataset_builder.py
|
||||||
|
git commit -m "feat: remove ADX hard filter from dataset builder, add ADX as ML feature"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: 전체 테스트 + 최종 검증
|
||||||
|
|
||||||
|
**Step 16: Run full test suite**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
**Step 17: Final commit if needed**
|
||||||
|
|
||||||
|
주의: 기존 모델(23 피처)은 24 피처 입력과 호환 안 됨. 배포 전 반드시 `bash scripts/train_and_deploy.sh` 실행하여 재학습 필요.
|
||||||
80
docs/plans/2026-03-03-optuna-precision-objective-plan.md
Normal file
80
docs/plans/2026-03-03-optuna-precision-objective-plan.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Optuna 목적함수를 Precision 중심으로 변경
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** 현재 ROC-AUC만 최적화하는 Optuna objective를 **recall >= 0.35 제약 하에서 precision을 최대화**하는 방향으로 변경한다. AUC는 threshold-independent 지표라 실제 운용 시점의 성능(precision)을 반영하지 못하며, 오탐(false positive = 잘못된 진입)이 실제 손실을 발생시키므로 precision 우선 최적화가 필요하다.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, LightGBM, Optuna, scikit-learn
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 변경 파일
|
||||||
|
- `scripts/tune_hyperparams.py` (유일한 변경 대상)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 단계
|
||||||
|
|
||||||
|
### 1. `_find_best_precision_at_recall` 헬퍼 함수 추가
|
||||||
|
- `sklearn.metrics.precision_recall_curve`로 recall >= min_recall 조건의 최대 precision과 threshold 반환
|
||||||
|
- 조건 불만족 시 `(0.0, 0.0, 0.50)` fallback
|
||||||
|
- train_model.py:277-292와 동일한 로직
|
||||||
|
|
||||||
|
### 2. `_walk_forward_cv` 수정
|
||||||
|
- 기존 반환: `(mean_auc, fold_aucs)` → 신규: `(mean_score, details_dict)`
|
||||||
|
- `details_dict` 키: `fold_aucs`, `fold_precisions`, `fold_recalls`, `fold_thresholds`, `fold_n_pos`, `mean_auc`, `mean_precision`, `mean_recall`
|
||||||
|
- **Score 공식**: `precision + auc * 0.001` (AUC는 precision 동률 시 tiebreaker)
|
||||||
|
- fold 내 양성 < 3개면 해당 fold precision=0.0으로 처리, 평균 계산에서 제외
|
||||||
|
- 인자 추가: `min_recall: float = 0.35`
|
||||||
|
- import 추가: `from sklearn.metrics import precision_recall_curve`
|
||||||
|
- Pruning: 양성 충분한 fold만 report하여 false pruning 방지
|
||||||
|
|
||||||
|
### 3. `make_objective` 수정
|
||||||
|
- `min_recall` 인자 추가 → `_walk_forward_cv`에 전달
|
||||||
|
- `trial.set_user_attr`로 precision/recall/threshold/n_pos 등 저장
|
||||||
|
- 반환값: `mean_score` (precision + auc * 0.001)
|
||||||
|
|
||||||
|
### 4. `measure_baseline` 수정
|
||||||
|
- `min_recall` 인자 추가
|
||||||
|
- 반환값을 `(mean_score, details_dict)` 형태로 변경
|
||||||
|
|
||||||
|
### 5. `--min-recall` CLI 인자 추가
|
||||||
|
- `parser.add_argument("--min-recall", type=float, default=0.35)`
|
||||||
|
- `make_objective`와 `measure_baseline`에 전달
|
||||||
|
|
||||||
|
### 6. `print_report` 수정
|
||||||
|
- Best Score, Precision, AUC 모두 표시
|
||||||
|
- 폴드별 AUC + Precision + Recall + Threshold + 양성수 표시
|
||||||
|
- Baseline과 비교 시 precision 기준 개선폭 표시
|
||||||
|
|
||||||
|
### 7. `save_results` 수정
|
||||||
|
- JSON에 `min_recall_constraint`, precision/recall/threshold 필드 추가
|
||||||
|
- `best_trial` 내 `score`, `precision`, `recall`, `threshold`, `fold_precisions`, `fold_recalls`, `fold_thresholds`, `fold_n_pos` 추가
|
||||||
|
- `best_trial.params` 구조는 그대로 유지 (하위호환)
|
||||||
|
|
||||||
|
### 8. 비교 로직 및 기타 수정
|
||||||
|
- line 440: `study.best_value > baseline_auc` → `study.best_value > baseline_score`
|
||||||
|
- `study_name`: `"lgbm_wf_auc"` → `"lgbm_wf_precision"`
|
||||||
|
- progress callback: Precision과 AUC 동시 표시
|
||||||
|
- `n_warmup_steps` 2 → 3 (precision이 AUC보다 노이즈가 크므로)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 방법
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 기본 실행 (min_recall=0.35)
|
||||||
|
python scripts/tune_hyperparams.py --trials 10 --folds 3
|
||||||
|
|
||||||
|
# min_recall 조절
|
||||||
|
python scripts/tune_hyperparams.py --trials 10 --min-recall 0.4
|
||||||
|
|
||||||
|
# 기존 테스트 통과 확인
|
||||||
|
bash scripts/run_tests.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
확인 포인트:
|
||||||
|
- 폴드별 precision/recall/threshold가 리포트에 표시되는지
|
||||||
|
- recall >= min_recall 제약이 올바르게 동작하는지
|
||||||
|
- active_lgbm_params.json이 precision 기준으로 갱신되는지
|
||||||
|
- train_model.py가 새 JSON 포맷을 기존과 동일하게 읽는지
|
||||||
35
docs/plans/2026-03-03-position-monitor-logging.md
Normal file
35
docs/plans/2026-03-03-position-monitor-logging.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# 포지션 모니터 로깅 (실시간 가격 추적)
|
||||||
|
|
||||||
|
**Goal:** 포지션 보유 중 5분마다 현재가 기준 미실현 손익을 로그로 출력하여, 봇 운영 중 포지션 상태를 실시간 모니터링할 수 있게 한다.
|
||||||
|
|
||||||
|
**Status:** Completed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 변경 사항
|
||||||
|
|
||||||
|
### 1. MultiSymbolStream에 latest_price 속성 추가
|
||||||
|
|
||||||
|
- `src/data_stream.py`: `self.latest_price: float | None = None` 초기화
|
||||||
|
- `handle_message()`에서 **모든 kline 메시지** (미확정 캔들 포함)에 대해 primary symbol(XRPUSDT)의 close 가격으로 업데이트
|
||||||
|
- 기존에는 확정 캔들만 처리했으나, 실시간 가격 추적을 위해 미확정 캔들도 반영
|
||||||
|
- BTC/ETH 등 비주 심볼은 latest_price 갱신 안 함
|
||||||
|
|
||||||
|
### 2. _position_monitor() 코루틴 추가
|
||||||
|
|
||||||
|
- `src/bot.py`: `_MONITOR_INTERVAL = 300` (5분) 클래스 상수 정의
|
||||||
|
- `async def _position_monitor()` 무한 루프: 5분마다 실행
|
||||||
|
- 포지션 없으면(`current_trade_side is None`) skip
|
||||||
|
- 포지션 있으면: `_calc_estimated_pnl(price)`로 미실현 PnL 계산, 퍼센트 산출 후 INFO 로그 출력
|
||||||
|
- `asyncio.gather()`에 추가하여 기존 user_data_stream, candle processing과 병렬 실행
|
||||||
|
|
||||||
|
### 3. 테스트
|
||||||
|
|
||||||
|
- `tests/test_bot.py`: 포지션 보유 시 PnL 로깅 확인, 포지션 없을 때 정상 skip 확인 (2 cases)
|
||||||
|
- `tests/test_data_stream.py`: 미확정 캔들로 latest_price 갱신, 비주 심볼은 무시 확인 (1 case)
|
||||||
|
|
||||||
|
## 설계 결정
|
||||||
|
|
||||||
|
- WebSocket 스트림 재사용 (추가 API 연결 불필요)
|
||||||
|
- `_MONITOR_INTERVAL`은 클래스 상수로 정의 (테스트에서 0으로 오버라이드 가능)
|
||||||
|
- 가격/진입가/수량 중 하나라도 None이면 graceful skip
|
||||||
72
docs/plans/2026-03-03-testnet-1m-125x-design.md
Normal file
72
docs/plans/2026-03-03-testnet-1m-125x-design.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Demo 1분봉 125x 트레이딩 설계
|
||||||
|
|
||||||
|
**날짜**: 2026-03-03
|
||||||
|
**상태**: Approved (testnet → demo 변경 반영)
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
바이낸스 선물 데모(`demo-fapi.binance.com`)에서 XRPUSDT 1분봉, 125x 레버리지로 ML 기반 자동매매를 테스트한다.
|
||||||
|
로컬 맥미니의 워크트리에서 격리하여 메인 코드베이스에 영향 없이 실험한다.
|
||||||
|
|
||||||
|
## 환경 설정
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| 네트워크 | Binance Futures Demo (`demo-fapi.binance.com`) |
|
||||||
|
| 심볼 | XRPUSDT |
|
||||||
|
| 타임프레임 | 1m (1분봉) |
|
||||||
|
| 레버리지 | 125x |
|
||||||
|
| ML Lookahead | 60캔들 (1시간) |
|
||||||
|
| 작업 방식 | git worktree (격리) |
|
||||||
|
| 실행 환경 | 로컬 맥미니 (서버 배포 없음) |
|
||||||
|
|
||||||
|
## 코드 변경 사항
|
||||||
|
|
||||||
|
### 1. Config (`src/config.py`)
|
||||||
|
|
||||||
|
- `demo: bool` 플래그 추가
|
||||||
|
- `BINANCE_DEMO=true`이면 `BINANCE_DEMO_API_KEY/SECRET` 사용
|
||||||
|
- `INTERVAL` 환경변수 추가 (기본값 `15m` → 데모에서 `1m`)
|
||||||
|
|
||||||
|
### 2. Exchange (`src/exchange.py`)
|
||||||
|
|
||||||
|
- `config.demo=True`이면 Client의 `FUTURES_URL`을 `demo-fapi.binance.com`으로 오버라이드
|
||||||
|
- `testnet=True` 미사용 (demo 엔드포인트는 라이브러리 미지원)
|
||||||
|
|
||||||
|
### 3. DataStream (`src/data_stream.py`)
|
||||||
|
|
||||||
|
- `AsyncClient.create()` 후 demo이면 `FUTURES_URL` 오버라이드
|
||||||
|
- interval을 Config에서 받도록 수정
|
||||||
|
|
||||||
|
### 4. UserDataStream (`src/user_data_stream.py`)
|
||||||
|
|
||||||
|
- `AsyncClient.create()` 후 demo이면 `FUTURES_URL` 오버라이드
|
||||||
|
|
||||||
|
### 5. Bot (`src/bot.py`)
|
||||||
|
|
||||||
|
- `demo` 플래그를 각 stream/exchange에 전달
|
||||||
|
|
||||||
|
### 6. 학습 파이프라인
|
||||||
|
|
||||||
|
- `fetch_history.py`로 1분봉 데이터 수집 (30일+, 프로덕션 API 사용)
|
||||||
|
- `dataset_builder.py`에서 `LOOKAHEAD=60` (1시간)
|
||||||
|
- SL/TP: ATR 기반이므로 자동 적응
|
||||||
|
- LightGBM 학습 → 로컬 models/ 저장 (서버 배포 없음)
|
||||||
|
|
||||||
|
### 7. 환경변수 (`.env`)
|
||||||
|
|
||||||
|
```
|
||||||
|
BINANCE_DEMO=true
|
||||||
|
BINANCE_DEMO_API_KEY=<demo_key>
|
||||||
|
BINANCE_DEMO_API_SECRET=<demo_secret>
|
||||||
|
INTERVAL=1m
|
||||||
|
LEVERAGE=125
|
||||||
|
```
|
||||||
|
|
||||||
|
## 변경하지 않는 것
|
||||||
|
|
||||||
|
- 지표 계산 로직 (RSI, MACD, BB, EMA, StochRSI, ATR, ADX) — 타임프레임 독립
|
||||||
|
- ML 피처 추출 — 캔들 데이터 기반, 그대로 동작
|
||||||
|
- 리스크 매니저 — 비율 기반, 자동 적응
|
||||||
|
- Discord 알림 — 그대로 사용
|
||||||
|
- ONNX 변환 파이프라인 — 동일
|
||||||
426
docs/plans/2026-03-03-testnet-1m-125x-plan.md
Normal file
426
docs/plans/2026-03-03-testnet-1m-125x-plan.md
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
# Testnet 1분봉 125x 트레이딩 Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** 바이낸스 테스트넷에서 XRPUSDT 1분봉, 125x 레버리지로 ML 기반 자동매매를 실행한다.
|
||||||
|
|
||||||
|
**Architecture:** Config에 `testnet` 플래그를 추가하고, Exchange/DataStream/UserDataStream에 `testnet=True`를 전달한다. 학습 파이프라인은 LOOKAHEAD=60(1시간)으로 조정하여 1분봉 데이터로 새 모델을 학습한다.
|
||||||
|
|
||||||
|
**Tech Stack:** python-binance (testnet=True), LightGBM, asyncio
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Config에 testnet 지원 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/config.py:8-33`
|
||||||
|
- Test: `tests/test_config.py` (기존 테스트 수정 필요시)
|
||||||
|
|
||||||
|
**Step 1: Config에 testnet, interval 필드 추가**
|
||||||
|
|
||||||
|
`src/config.py`에서 `Config` dataclass에 `testnet`, `interval` 필드를 추가하고, `__post_init__`에서 `BINANCE_TESTNET=true`이면 테스트넷 키를 사용하도록 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
api_key: str = ""
|
||||||
|
api_secret: str = ""
|
||||||
|
symbol: str = "XRPUSDT"
|
||||||
|
leverage: int = 10
|
||||||
|
testnet: bool = False
|
||||||
|
interval: str = "15m"
|
||||||
|
max_positions: int = 3
|
||||||
|
stop_loss_pct: float = 0.015 # 1.5%
|
||||||
|
take_profit_pct: float = 0.045 # 4.5% (3:1 RR)
|
||||||
|
trailing_stop_pct: float = 0.01 # 1%
|
||||||
|
discord_webhook_url: str = ""
|
||||||
|
margin_max_ratio: float = 0.50
|
||||||
|
margin_min_ratio: float = 0.20
|
||||||
|
margin_decay_rate: float = 0.0006
|
||||||
|
ml_threshold: float = 0.55
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self.testnet = os.getenv("BINANCE_TESTNET", "").lower() in ("true", "1", "yes")
|
||||||
|
self.interval = os.getenv("INTERVAL", "15m")
|
||||||
|
|
||||||
|
if self.testnet:
|
||||||
|
self.api_key = os.getenv("BINANCE_TESTNET_API_KEY", "")
|
||||||
|
self.api_secret = os.getenv("BINANCE_TESTNET_API_SECRET", "")
|
||||||
|
else:
|
||||||
|
self.api_key = os.getenv("BINANCE_API_KEY", "")
|
||||||
|
self.api_secret = os.getenv("BINANCE_API_SECRET", "")
|
||||||
|
|
||||||
|
self.symbol = os.getenv("SYMBOL", "XRPUSDT")
|
||||||
|
self.leverage = int(os.getenv("LEVERAGE", "10"))
|
||||||
|
self.discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL", "")
|
||||||
|
self.margin_max_ratio = float(os.getenv("MARGIN_MAX_RATIO", "0.50"))
|
||||||
|
self.margin_min_ratio = float(os.getenv("MARGIN_MIN_RATIO", "0.20"))
|
||||||
|
self.margin_decay_rate = float(os.getenv("MARGIN_DECAY_RATE", "0.0006"))
|
||||||
|
self.ml_threshold = float(os.getenv("ML_THRESHOLD", "0.55"))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 테스트 실행**
|
||||||
|
|
||||||
|
Run: `pytest tests/ -v --tb=short -x`
|
||||||
|
Expected: 기존 테스트 모두 PASS (testnet 미설정 시 기존 동작 유지)
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/config.py
|
||||||
|
git commit -m "feat: add testnet and interval support to Config"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Exchange에 testnet 전달
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/exchange.py:9-14`
|
||||||
|
|
||||||
|
**Step 1: Client 생성자에 testnet 전달**
|
||||||
|
|
||||||
|
`src/exchange.py`에서 `BinanceFuturesClient.__init__`을 수정:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BinanceFuturesClient:
|
||||||
|
def __init__(self, config: Config):
|
||||||
|
self.config = config
|
||||||
|
self.client = Client(
|
||||||
|
api_key=config.api_key,
|
||||||
|
api_secret=config.api_secret,
|
||||||
|
testnet=config.testnet,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 테스트 실행**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_exchange.py -v --tb=short -x`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/exchange.py
|
||||||
|
git commit -m "feat: pass testnet flag to Binance Client"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: DataStream에 testnet 전달
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/data_stream.py:78-82,185-189`
|
||||||
|
|
||||||
|
**Step 1: KlineStream.start()에 testnet 파라미터 추가**
|
||||||
|
|
||||||
|
`src/data_stream.py`의 `KlineStream.start()` (line 78)을 수정:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def start(self, api_key: str, api_secret: str, testnet: bool = False):
|
||||||
|
client = await AsyncClient.create(
|
||||||
|
api_key=api_key,
|
||||||
|
api_secret=api_secret,
|
||||||
|
testnet=testnet,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: MultiSymbolStream.start()에 testnet 파라미터 추가**
|
||||||
|
|
||||||
|
`src/data_stream.py`의 `MultiSymbolStream.start()` (line 185)을 수정:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def start(self, api_key: str, api_secret: str, testnet: bool = False):
|
||||||
|
client = await AsyncClient.create(
|
||||||
|
api_key=api_key,
|
||||||
|
api_secret=api_secret,
|
||||||
|
testnet=testnet,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 테스트 실행**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_data_stream.py -v --tb=short -x`
|
||||||
|
Expected: PASS (testnet 기본값 False이므로 기존 동작 유지)
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/data_stream.py
|
||||||
|
git commit -m "feat: pass testnet flag to AsyncClient in data streams"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: UserDataStream에 testnet 전달
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/user_data_stream.py:28-33`
|
||||||
|
|
||||||
|
**Step 1: start()에 testnet 파라미터 추가**
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def start(self, api_key: str, api_secret: str, testnet: bool = False) -> None:
|
||||||
|
"""User Data Stream 메인 루프 — 봇 종료 시까지 실행."""
|
||||||
|
client = await AsyncClient.create(
|
||||||
|
api_key=api_key,
|
||||||
|
api_secret=api_secret,
|
||||||
|
testnet=testnet,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 테스트 실행**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_user_data_stream.py -v --tb=short -x`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/user_data_stream.py
|
||||||
|
git commit -m "feat: pass testnet flag to AsyncClient in user data stream"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Bot에서 testnet/interval 전달
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py:27-31,300-322`
|
||||||
|
|
||||||
|
**Step 1: MultiSymbolStream에 config.interval 전달**
|
||||||
|
|
||||||
|
`src/bot.py` line 27-31을 수정:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.stream = MultiSymbolStream(
|
||||||
|
symbols=[config.symbol, "BTCUSDT", "ETHUSDT"],
|
||||||
|
interval=config.interval,
|
||||||
|
on_candle=self._on_candle_closed,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: run()에서 testnet 전달**
|
||||||
|
|
||||||
|
`src/bot.py` line 300-322의 `run()` 메서드에서 stream.start() 호출 시 testnet 전달:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def run(self):
|
||||||
|
logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x, 테스트넷={self.config.testnet}")
|
||||||
|
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(
|
||||||
|
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,
|
||||||
|
testnet=self.config.testnet,
|
||||||
|
),
|
||||||
|
user_stream.start(
|
||||||
|
api_key=self.config.api_key,
|
||||||
|
api_secret=self.config.api_secret,
|
||||||
|
testnet=self.config.testnet,
|
||||||
|
),
|
||||||
|
self._position_monitor(),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 테스트 실행**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_bot.py -v --tb=short -x`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/bot.py
|
||||||
|
git commit -m "feat: pass testnet and interval from config to streams"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: .env 설정 및 전체 테스트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.env`
|
||||||
|
- Modify: `.env.example`
|
||||||
|
|
||||||
|
**Step 1: .env.example 업데이트**
|
||||||
|
|
||||||
|
`.env.example`에 새 변수 추가:
|
||||||
|
|
||||||
|
```
|
||||||
|
BINANCE_API_KEY=
|
||||||
|
BINANCE_API_SECRET=
|
||||||
|
BINANCE_TESTNET=false
|
||||||
|
BINANCE_TESTNET_API_KEY=
|
||||||
|
BINANCE_TESTNET_API_SECRET=
|
||||||
|
SYMBOL=XRPUSDT
|
||||||
|
LEVERAGE=10
|
||||||
|
INTERVAL=15m
|
||||||
|
RISK_PER_TRADE=0.02
|
||||||
|
DISCORD_WEBHOOK_URL=
|
||||||
|
ML_THRESHOLD=0.55
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 워크트리의 .env에 테스트넷 설정**
|
||||||
|
|
||||||
|
`.env` 파일에 테스트넷 키와 설정 적용:
|
||||||
|
|
||||||
|
```
|
||||||
|
BINANCE_TESTNET=true
|
||||||
|
BINANCE_TESTNET_API_KEY=<사용자의_테스트넷_키>
|
||||||
|
BINANCE_TESTNET_API_SECRET=<사용자의_테스트넷_시크릿>
|
||||||
|
SYMBOL=XRPUSDT
|
||||||
|
LEVERAGE=125
|
||||||
|
INTERVAL=1m
|
||||||
|
ML_THRESHOLD=0.55
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 전체 테스트 실행**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: 모든 테스트 PASS
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .env.example
|
||||||
|
git commit -m "feat: add testnet and interval env vars to .env.example"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: 학습 파이프라인 — LOOKAHEAD 조정 및 1분봉 데이터 수집
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/dataset_builder.py:14` (LOOKAHEAD 변경)
|
||||||
|
- Modify: `scripts/train_model.py:56` (LOOKAHEAD 변경)
|
||||||
|
- Modify: `scripts/train_and_deploy.sh:32-50` (1분봉 데이터 경로)
|
||||||
|
|
||||||
|
**Step 1: dataset_builder.py LOOKAHEAD 변경**
|
||||||
|
|
||||||
|
`src/dataset_builder.py` line 14:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 변경 전:
|
||||||
|
LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 뷰
|
||||||
|
|
||||||
|
# 변경 후:
|
||||||
|
LOOKAHEAD = 60 # 1분봉 × 60 = 1시간 뷰
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: train_model.py LOOKAHEAD 변경**
|
||||||
|
|
||||||
|
`scripts/train_model.py` line 56:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 변경 전:
|
||||||
|
LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 (dataset_builder.py와 동기화)
|
||||||
|
|
||||||
|
# 변경 후:
|
||||||
|
LOOKAHEAD = 60 # 1분봉 × 60 = 1시간 (dataset_builder.py와 동기화)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: train_and_deploy.sh 수정 — 1분봉 파이프라인**
|
||||||
|
|
||||||
|
`scripts/train_and_deploy.sh`의 데이터 경로와 수집 파라미터를 1분봉으로 변경:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# line 32: 파일명 변경
|
||||||
|
PARQUET_FILE="data/combined_1m.parquet"
|
||||||
|
|
||||||
|
# line 46-50: --interval 1m으로 변경
|
||||||
|
python scripts/fetch_history.py \
|
||||||
|
--symbols XRPUSDT BTCUSDT ETHUSDT \
|
||||||
|
--interval 1m \
|
||||||
|
--days "$FETCH_DAYS" \
|
||||||
|
$UPSERT_FLAG \
|
||||||
|
--output "$PARQUET_FILE"
|
||||||
|
|
||||||
|
# line 57, 60: --data 경로 변경
|
||||||
|
python scripts/train_mlx_model.py --data data/combined_1m.parquet --decay "$DECAY"
|
||||||
|
# ...
|
||||||
|
python scripts/train_model.py --data data/combined_1m.parquet --decay "$DECAY"
|
||||||
|
|
||||||
|
# walk-forward 섹션도 동일하게 --data data/combined_1m.parquet로 변경
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/dataset_builder.py scripts/train_model.py scripts/train_and_deploy.sh
|
||||||
|
git commit -m "feat: adjust LOOKAHEAD to 60 for 1m candles, update training pipeline"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: 1분봉 데이터 수집
|
||||||
|
|
||||||
|
**Step 1: 1분봉 데이터 수집 (30일)**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
python scripts/fetch_history.py \
|
||||||
|
--symbols XRPUSDT BTCUSDT ETHUSDT \
|
||||||
|
--interval 1m \
|
||||||
|
--days 30 \
|
||||||
|
--no-oi \
|
||||||
|
--no-upsert \
|
||||||
|
--output data/combined_1m.parquet
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `data/combined_1m.parquet` 생성 (약 43,000행 × 15컬럼)
|
||||||
|
|
||||||
|
> Note: 테스트넷 학습용이므로 OI/펀딩비는 건너뜀 (--no-oi). 1분봉 30일 데이터면 약 43,200개 캔들.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: ML 모델 학습
|
||||||
|
|
||||||
|
**Step 1: LightGBM 모델 학습**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
python scripts/train_model.py --data data/combined_1m.parquet --decay 2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `models/lgbm_filter.pkl` 생성, AUC 출력
|
||||||
|
|
||||||
|
**Step 2: 학습 결과 확인**
|
||||||
|
|
||||||
|
학습 결과의 AUC가 0.50 이상인지 확인. 모델이 생성되었는지 확인:
|
||||||
|
|
||||||
|
Run: `ls -la models/lgbm_filter.pkl`
|
||||||
|
Expected: 파일 존재
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: 테스트넷 봇 실행
|
||||||
|
|
||||||
|
**Step 1: 최종 확인**
|
||||||
|
|
||||||
|
`.env`에 테스트넷 설정이 올바른지 확인:
|
||||||
|
- `BINANCE_TESTNET=true`
|
||||||
|
- `BINANCE_TESTNET_API_KEY` 설정됨
|
||||||
|
- `BINANCE_TESTNET_API_SECRET` 설정됨
|
||||||
|
- `LEVERAGE=125`
|
||||||
|
- `INTERVAL=1m`
|
||||||
|
|
||||||
|
**Step 2: 봇 실행**
|
||||||
|
|
||||||
|
Run: `python main.py`
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
```
|
||||||
|
봇 시작: XRPUSDT, 레버리지 125x, 테스트넷=True
|
||||||
|
```
|
||||||
|
|
||||||
|
봇이 정상 시작되면 1분봉 캔들을 수신하고 ML 필터를 통해 거래 신호를 처리한다.
|
||||||
70
docs/plans/2026-03-04-oi-derived-features-design.md
Normal file
70
docs/plans/2026-03-04-oi-derived-features-design.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# OI 파생 피처 설계
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
기존 `oi_change` 피처에 더해, OI 데이터에서 파생 피처 2개를 만들어 LightGBM 학습 데이터에 추가하고, 피처 추가 전후 검증셋 성능을 자동 비교한다.
|
||||||
|
|
||||||
|
## 제약사항
|
||||||
|
|
||||||
|
- Binance OI 히스토리 API는 최근 30일분만 제공
|
||||||
|
- 학습 데이터에서 OI 유효 구간 ≈ 2,880개 15분 캔들
|
||||||
|
- A/B 비교 결과는 방향성 참고용 (통계적 유의성 제한)
|
||||||
|
|
||||||
|
## 파생 피처
|
||||||
|
|
||||||
|
### 1. `oi_change_ma5`
|
||||||
|
|
||||||
|
- **계산**: OI 변화율의 5캔들(75분) 이동평균
|
||||||
|
- **의미**: OI 단기 추세. 급감/급증 노이즈 제거된 방향성
|
||||||
|
- **정규화**: rolling z-score (288캔들 윈도우, 기존 패턴 동일)
|
||||||
|
- **기존 `oi_change`와의 관계**: smoothed 버전. 상관관계 높을 수 있으나 LightGBM이 자연 선택. importance 낮으면 이후 제거
|
||||||
|
|
||||||
|
### 2. `oi_price_spread`
|
||||||
|
|
||||||
|
- **계산**: `rolling_zscore(oi_change) - rolling_zscore(price_ret_1)`
|
||||||
|
- **의미**: OI와 가격 움직임 간 괴리도 (연속값)
|
||||||
|
- 양수: OI가 가격 대비 강세 (자금 유입)
|
||||||
|
- 음수: OI가 가격 대비 약세 (자금 유출)
|
||||||
|
- **정규화**: 양쪽 입력이 이미 z-score이므로 추가 정규화 불필요
|
||||||
|
- **바이너리 대신 연속값 채택 이유**: sign() 기반 바이너리는 미미한 차이도 1/0으로 분류 → 노이즈 과잉. 연속값은 LightGBM이 분할점을 학습
|
||||||
|
|
||||||
|
## 수정 대상 파일
|
||||||
|
|
||||||
|
### dataset_builder.py
|
||||||
|
|
||||||
|
- OI 파생 피처 2개 계산 로직 추가
|
||||||
|
- 기존 `oi_change` z-score 결과를 재사용하여 `oi_change_ma5` 계산
|
||||||
|
- `oi_price_spread` = `oi_change` z-score - `ret_1` z-score
|
||||||
|
|
||||||
|
### ml_features.py
|
||||||
|
|
||||||
|
- `FEATURE_COLS`에 `oi_change_ma5`, `oi_price_spread` 추가 (24→26)
|
||||||
|
- `build_features()`에 실시간 계산 로직 추가
|
||||||
|
- `oi_change_ma5`: bot에서 전달받은 최근 5봉 OI MA
|
||||||
|
- `oi_price_spread`: 실시간 z-scored OI - z-scored price change
|
||||||
|
|
||||||
|
### train_model.py
|
||||||
|
|
||||||
|
- `--compare` 플래그 추가
|
||||||
|
- Baseline (기존 24피처) vs New (26피처) 자동 비교 출력:
|
||||||
|
- Precision, Recall, F1, AUC-ROC
|
||||||
|
- Feature importance top 10
|
||||||
|
- Best threshold
|
||||||
|
- 검증셋 크기 (n=XX) 및 "방향성 참고용" 경고
|
||||||
|
|
||||||
|
### bot.py
|
||||||
|
|
||||||
|
- OI 변화율 히스토리 deque(maxlen=5) 관리
|
||||||
|
- `_init_oi_history()`: 봇 시작 시 Binance OI hist API에서 최근 5봉 fetch → cold start 해결
|
||||||
|
- `_fetch_market_microstructure()` 확장: MA5 계산, price_spread 계산 후 build_features()에 전달
|
||||||
|
|
||||||
|
### exchange.py
|
||||||
|
|
||||||
|
- `get_oi_history(limit=5)`: 봇 초기화용 최근 OI 히스토리 fetch 메서드 추가
|
||||||
|
|
||||||
|
### scripts/collect_oi.py (신규)
|
||||||
|
|
||||||
|
- OI 장기 수집 스크립트
|
||||||
|
- 15분마다 cron 실행
|
||||||
|
- Binance `/fapi/v1/openInterest` 호출 → `data/oi_history.parquet`에 append
|
||||||
|
- 기존 fetch_history.py의 30일 데이터 보완용
|
||||||
764
docs/plans/2026-03-04-oi-derived-features-plan.md
Normal file
764
docs/plans/2026-03-04-oi-derived-features-plan.md
Normal file
@@ -0,0 +1,764 @@
|
|||||||
|
# OI 파생 피처 구현 계획
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** OI 파생 피처 2개(`oi_change_ma5`, `oi_price_spread`)를 추가하고, 기존 대비 성능을 자동 비교하며, OI 장기 수집 스크립트를 만든다.
|
||||||
|
|
||||||
|
**Architecture:** dataset_builder.py에 파생 피처 계산 추가 → ml_features.py에 FEATURE_COLS/build_features 확장 → train_model.py에 --compare 플래그로 A/B 비교 → bot.py에 OI deque 히스토리 관리 및 cold start → scripts/collect_oi.py 신규
|
||||||
|
|
||||||
|
**Tech Stack:** Python, LightGBM, pandas, numpy, Binance REST API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: dataset_builder.py — OI 파생 피처 계산
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/dataset_builder.py:277-291` (OI/FR 피처 계산 블록)
|
||||||
|
- Test: `tests/test_dataset_builder.py`
|
||||||
|
|
||||||
|
**Step 1: Write failing tests**
|
||||||
|
|
||||||
|
`tests/test_dataset_builder.py` 끝에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_oi_derived_features_present():
|
||||||
|
"""OI 파생 피처 2개가 결과에 포함되어야 한다."""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators
|
||||||
|
|
||||||
|
n = 300
|
||||||
|
np.random.seed(42)
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"open": np.random.uniform(1, 2, n),
|
||||||
|
"high": np.random.uniform(2, 3, n),
|
||||||
|
"low": np.random.uniform(0.5, 1, n),
|
||||||
|
"close": np.random.uniform(1, 2, n),
|
||||||
|
"volume": np.random.uniform(1000, 5000, n),
|
||||||
|
"oi_change": np.concatenate([np.zeros(100), np.random.uniform(-0.05, 0.05, 200)]),
|
||||||
|
})
|
||||||
|
d = _calc_indicators(df)
|
||||||
|
sig = _calc_signals(d)
|
||||||
|
feat = _calc_features_vectorized(d, sig)
|
||||||
|
|
||||||
|
assert "oi_change_ma5" in feat.columns, "oi_change_ma5 컬럼이 없음"
|
||||||
|
assert "oi_price_spread" in feat.columns, "oi_price_spread 컬럼이 없음"
|
||||||
|
|
||||||
|
|
||||||
|
def test_oi_derived_features_nan_when_no_oi():
|
||||||
|
"""oi_change 컬럼이 없으면 파생 피처도 nan이어야 한다."""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators
|
||||||
|
|
||||||
|
n = 200
|
||||||
|
np.random.seed(0)
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"open": np.random.uniform(1, 2, n),
|
||||||
|
"high": np.random.uniform(2, 3, n),
|
||||||
|
"low": np.random.uniform(0.5, 1, n),
|
||||||
|
"close": np.random.uniform(1, 2, n),
|
||||||
|
"volume": np.random.uniform(1000, 5000, n),
|
||||||
|
})
|
||||||
|
d = _calc_indicators(df)
|
||||||
|
sig = _calc_signals(d)
|
||||||
|
feat = _calc_features_vectorized(d, sig)
|
||||||
|
|
||||||
|
assert feat["oi_change_ma5"].isna().all(), "oi_change 컬럼 없을 때 oi_change_ma5는 전부 nan이어야 함"
|
||||||
|
assert feat["oi_price_spread"].isna().all(), "oi_change 컬럼 없을 때 oi_price_spread는 전부 nan이어야 함"
|
||||||
|
|
||||||
|
|
||||||
|
def test_oi_price_spread_is_continuous():
|
||||||
|
"""oi_price_spread는 바이너리가 아닌 연속값이어야 한다."""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators
|
||||||
|
|
||||||
|
n = 300
|
||||||
|
np.random.seed(42)
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"open": np.random.uniform(1, 2, n),
|
||||||
|
"high": np.random.uniform(2, 3, n),
|
||||||
|
"low": np.random.uniform(0.5, 1, n),
|
||||||
|
"close": np.random.uniform(1, 2, n),
|
||||||
|
"volume": np.random.uniform(1000, 5000, n),
|
||||||
|
"oi_change": np.random.uniform(-0.05, 0.05, n),
|
||||||
|
})
|
||||||
|
d = _calc_indicators(df)
|
||||||
|
sig = _calc_signals(d)
|
||||||
|
feat = _calc_features_vectorized(d, sig)
|
||||||
|
|
||||||
|
valid = feat["oi_price_spread"].dropna()
|
||||||
|
assert len(valid.unique()) > 2, "oi_price_spread는 연속값이어야 함 (2개 초과 유니크 값)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh -k "oi_derived"`
|
||||||
|
Expected: FAIL — `oi_change_ma5`, `oi_price_spread` 컬럼 없음
|
||||||
|
|
||||||
|
**Step 3: Implement in dataset_builder.py**
|
||||||
|
|
||||||
|
`src/dataset_builder.py:277-291` (기존 OI/FR 블록) 뒤에 파생 피처 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# OI 변화율 / 펀딩비 피처
|
||||||
|
# 컬럼 없으면 전체 nan, 있으면 0.0 구간(데이터 미제공 구간)을 nan으로 마스킹
|
||||||
|
if "oi_change" in d.columns:
|
||||||
|
oi_raw = np.where(d["oi_change"].values == 0.0, np.nan, d["oi_change"].values)
|
||||||
|
else:
|
||||||
|
oi_raw = np.full(len(d), np.nan)
|
||||||
|
|
||||||
|
if "funding_rate" in d.columns:
|
||||||
|
fr_raw = np.where(d["funding_rate"].values == 0.0, np.nan, d["funding_rate"].values)
|
||||||
|
else:
|
||||||
|
fr_raw = np.full(len(d), np.nan)
|
||||||
|
|
||||||
|
oi_z = _rolling_zscore(oi_raw.astype(np.float64), window=96)
|
||||||
|
result["oi_change"] = oi_z
|
||||||
|
result["funding_rate"] = _rolling_zscore(fr_raw.astype(np.float64), window=96)
|
||||||
|
|
||||||
|
# --- OI 파생 피처 ---
|
||||||
|
# 1. oi_change_ma5: OI 변화율의 5캔들 이동평균 (단기 추세)
|
||||||
|
oi_series = pd.Series(oi_raw.astype(np.float64))
|
||||||
|
oi_ma5_raw = oi_series.rolling(window=5, min_periods=1).mean().values
|
||||||
|
result["oi_change_ma5"] = _rolling_zscore(oi_ma5_raw, window=96)
|
||||||
|
|
||||||
|
# 2. oi_price_spread: z-scored OI 변화율 - z-scored 가격 수익률 (연속값)
|
||||||
|
# 양수: OI가 가격 대비 강세 (자금 유입)
|
||||||
|
# 음수: OI가 가격 대비 약세 (자금 유출)
|
||||||
|
result["oi_price_spread"] = oi_z - ret_1_z
|
||||||
|
```
|
||||||
|
|
||||||
|
주의: 기존 `oi_change`와 `funding_rate`의 window도 288→96으로 변경. `oi_z` 변수를 재사용하여 `oi_price_spread` 계산. `ret_1_z`는 이미 위에서 계산됨 (line 181).
|
||||||
|
|
||||||
|
**Step 4: Update OPTIONAL_COLS in generate_dataset_vectorized**
|
||||||
|
|
||||||
|
`src/dataset_builder.py:387` 수정:
|
||||||
|
|
||||||
|
```python
|
||||||
|
OPTIONAL_COLS = {"oi_change", "funding_rate", "oi_change_ma5", "oi_price_spread"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh -k "oi_derived"`
|
||||||
|
Expected: 3 tests PASS
|
||||||
|
|
||||||
|
**Step 6: Run full test suite**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: All existing tests PASS (기존 oi_change/funding_rate 테스트 포함)
|
||||||
|
|
||||||
|
**Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/dataset_builder.py tests/test_dataset_builder.py
|
||||||
|
git commit -m "feat: add oi_change_ma5 and oi_price_spread derived features to dataset builder"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: ml_features.py — FEATURE_COLS 및 build_features() 확장
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ml_features.py:4-15` (FEATURE_COLS), `src/ml_features.py:33-139` (build_features)
|
||||||
|
- Test: `tests/test_ml_features.py`
|
||||||
|
|
||||||
|
**Step 1: Write failing tests**
|
||||||
|
|
||||||
|
`tests/test_ml_features.py` 끝에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_feature_cols_has_26_items():
|
||||||
|
from src.ml_features import FEATURE_COLS
|
||||||
|
assert len(FEATURE_COLS) == 26
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_features_with_oi_derived_params():
|
||||||
|
"""oi_change_ma5, oi_price_spread 파라미터가 피처에 반영된다."""
|
||||||
|
xrp_df = _make_df(10, base_price=1.0)
|
||||||
|
btc_df = _make_df(10, base_price=50000.0)
|
||||||
|
eth_df = _make_df(10, base_price=3000.0)
|
||||||
|
features = build_features(
|
||||||
|
xrp_df, "LONG",
|
||||||
|
btc_df=btc_df, eth_df=eth_df,
|
||||||
|
oi_change=0.05, funding_rate=0.0002,
|
||||||
|
oi_change_ma5=0.03, oi_price_spread=0.12,
|
||||||
|
)
|
||||||
|
assert features["oi_change_ma5"] == pytest.approx(0.03)
|
||||||
|
assert features["oi_price_spread"] == pytest.approx(0.12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_features_oi_derived_defaults_to_zero():
|
||||||
|
"""oi_change_ma5, oi_price_spread 미제공 시 0.0으로 채워진다."""
|
||||||
|
xrp_df = _make_df(10, base_price=1.0)
|
||||||
|
features = build_features(xrp_df, "LONG")
|
||||||
|
assert features["oi_change_ma5"] == pytest.approx(0.0)
|
||||||
|
assert features["oi_price_spread"] == pytest.approx(0.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 테스트 수정:
|
||||||
|
- `test_feature_cols_has_24_items` → 삭제 또는 숫자를 26으로 변경
|
||||||
|
- `test_build_features_with_btc_eth_has_24_features` → `assert len(features) == 26`
|
||||||
|
- `test_build_features_without_btc_eth_has_16_features` → `assert len(features) == 18`
|
||||||
|
|
||||||
|
**Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh -k "test_feature_cols_has_26 or test_build_features_oi_derived"`
|
||||||
|
Expected: FAIL
|
||||||
|
|
||||||
|
**Step 3: Implement**
|
||||||
|
|
||||||
|
`src/ml_features.py` FEATURE_COLS 수정 (line 4-15):
|
||||||
|
|
||||||
|
```python
|
||||||
|
FEATURE_COLS = [
|
||||||
|
"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_ret_1", "btc_ret_3", "btc_ret_5",
|
||||||
|
"eth_ret_1", "eth_ret_3", "eth_ret_5",
|
||||||
|
"xrp_btc_rs", "xrp_eth_rs",
|
||||||
|
# 시장 미시구조: OI 변화율(z-score), 펀딩비(z-score)
|
||||||
|
"oi_change", "funding_rate",
|
||||||
|
# OI 파생 피처
|
||||||
|
"oi_change_ma5", "oi_price_spread",
|
||||||
|
"adx",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`build_features()` 시그니처 수정 (line 33-40):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_features(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
signal: str,
|
||||||
|
btc_df: pd.DataFrame | None = None,
|
||||||
|
eth_df: pd.DataFrame | None = None,
|
||||||
|
oi_change: float | None = None,
|
||||||
|
funding_rate: float | None = None,
|
||||||
|
oi_change_ma5: float | None = None,
|
||||||
|
oi_price_spread: float | None = None,
|
||||||
|
) -> pd.Series:
|
||||||
|
```
|
||||||
|
|
||||||
|
`build_features()` 끝부분 (line 134-138) 수정:
|
||||||
|
|
||||||
|
```python
|
||||||
|
base["oi_change"] = float(oi_change) if oi_change is not None else 0.0
|
||||||
|
base["funding_rate"] = float(funding_rate) if funding_rate is not None else 0.0
|
||||||
|
base["oi_change_ma5"] = float(oi_change_ma5) if oi_change_ma5 is not None else 0.0
|
||||||
|
base["oi_price_spread"] = float(oi_price_spread) if oi_price_spread is not None else 0.0
|
||||||
|
base["adx"] = float(last.get("adx", 0))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh -k "test_ml_features"`
|
||||||
|
Expected: All PASS
|
||||||
|
|
||||||
|
**Step 5: Run full test suite**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: All PASS (test_dataset_builder의 FEATURE_COLS 참조도 26개로 통과)
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ml_features.py tests/test_ml_features.py
|
||||||
|
git commit -m "feat: add oi_change_ma5 and oi_price_spread to FEATURE_COLS and build_features"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: train_model.py — --compare A/B 비교 모드
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/train_model.py:425-452` (main, argparse)
|
||||||
|
- Test: 수동 실행 확인 (학습 스크립트는 통합 테스트)
|
||||||
|
|
||||||
|
**Step 1: Implement compare function**
|
||||||
|
|
||||||
|
`scripts/train_model.py`에 `compare()` 함수 추가 (train() 함수 뒤):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def compare(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str | None = None):
|
||||||
|
"""기존 피처 vs OI 파생 피처 추가 버전 A/B 비교."""
|
||||||
|
print("=" * 70)
|
||||||
|
print(" OI 파생 피처 A/B 비교 (30일 데이터 기반, 방향성 참고용)")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
df_raw = pd.read_parquet(data_path)
|
||||||
|
base_cols = ["open", "high", "low", "close", "volume"]
|
||||||
|
btc_df = eth_df = None
|
||||||
|
if "close_btc" in df_raw.columns:
|
||||||
|
btc_df = df_raw[[c + "_btc" for c in base_cols]].copy()
|
||||||
|
btc_df.columns = base_cols
|
||||||
|
if "close_eth" in df_raw.columns:
|
||||||
|
eth_df = df_raw[[c + "_eth" for c in base_cols]].copy()
|
||||||
|
eth_df.columns = base_cols
|
||||||
|
df = df_raw[base_cols].copy()
|
||||||
|
if "oi_change" in df_raw.columns:
|
||||||
|
df["oi_change"] = df_raw["oi_change"]
|
||||||
|
if "funding_rate" in df_raw.columns:
|
||||||
|
df["funding_rate"] = df_raw["funding_rate"]
|
||||||
|
|
||||||
|
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:
|
||||||
|
raise ValueError("데이터셋 생성 실패")
|
||||||
|
|
||||||
|
lgbm_params, weight_scale = _load_lgbm_params(tuned_params_path)
|
||||||
|
|
||||||
|
# Baseline: OI 파생 피처 제외
|
||||||
|
BASELINE_EXCLUDE = {"oi_change_ma5", "oi_price_spread"}
|
||||||
|
baseline_cols = [c for c in FEATURE_COLS if c in dataset.columns and c not in BASELINE_EXCLUDE]
|
||||||
|
new_cols = [c for c in FEATURE_COLS if c in dataset.columns]
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for label, cols in [("Baseline (24)", baseline_cols), ("New (26)", new_cols)]:
|
||||||
|
X = dataset[cols]
|
||||||
|
y = dataset["label"]
|
||||||
|
w = dataset["sample_weight"].values
|
||||||
|
source = dataset["source"].values if "source" in dataset.columns else np.full(len(X), "signal")
|
||||||
|
|
||||||
|
split = int(len(X) * 0.8)
|
||||||
|
X_tr, X_val = X.iloc[:split], X.iloc[split:]
|
||||||
|
y_tr, y_val = y.iloc[:split], y.iloc[split:]
|
||||||
|
w_tr = (w[:split] * weight_scale).astype(np.float32)
|
||||||
|
source_tr = source[:split]
|
||||||
|
|
||||||
|
balanced_idx = stratified_undersample(y_tr.values, source_tr, seed=42)
|
||||||
|
X_tr_b = X_tr.iloc[balanced_idx]
|
||||||
|
y_tr_b = y_tr.iloc[balanced_idx]
|
||||||
|
w_tr_b = w_tr[balanced_idx]
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
model = lgb.LGBMClassifier(**lgbm_params, random_state=42, verbose=-1)
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore")
|
||||||
|
model.fit(X_tr_b, y_tr_b, sample_weight=w_tr_b)
|
||||||
|
|
||||||
|
proba = model.predict_proba(X_val)[:, 1]
|
||||||
|
auc = roc_auc_score(y_val, proba) if len(np.unique(y_val)) > 1 else 0.5
|
||||||
|
|
||||||
|
precs, recs, thrs = precision_recall_curve(y_val, proba)
|
||||||
|
precs, recs = precs[:-1], recs[:-1]
|
||||||
|
valid_idx = np.where(recs >= 0.15)[0]
|
||||||
|
if len(valid_idx) > 0:
|
||||||
|
best_i = valid_idx[np.argmax(precs[valid_idx])]
|
||||||
|
thr, prec, rec = float(thrs[best_i]), float(precs[best_i]), float(recs[best_i])
|
||||||
|
else:
|
||||||
|
thr, prec, rec = 0.50, 0.0, 0.0
|
||||||
|
|
||||||
|
# Feature importance
|
||||||
|
imp = dict(zip(cols, model.feature_importances_))
|
||||||
|
top10 = sorted(imp.items(), key=lambda x: x[1], reverse=True)[:10]
|
||||||
|
|
||||||
|
results[label] = {
|
||||||
|
"auc": auc, "precision": prec, "recall": rec,
|
||||||
|
"threshold": thr, "n_val": len(y_val),
|
||||||
|
"n_val_pos": int(y_val.sum()), "top10": top10,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 비교 테이블 출력
|
||||||
|
print(f"\n{'지표':<20} {'Baseline (24)':>15} {'New (26)':>15} {'Delta':>10}")
|
||||||
|
print("-" * 62)
|
||||||
|
for metric in ["auc", "precision", "recall", "threshold"]:
|
||||||
|
b = results["Baseline (24)"][metric]
|
||||||
|
n = results["New (26)"][metric]
|
||||||
|
d = n - b
|
||||||
|
sign = "+" if d > 0 else ""
|
||||||
|
print(f"{metric:<20} {b:>15.4f} {n:>15.4f} {sign}{d:>9.4f}")
|
||||||
|
|
||||||
|
n_val = results["Baseline (24)"]["n_val"]
|
||||||
|
n_pos = results["Baseline (24)"]["n_val_pos"]
|
||||||
|
print(f"\n검증셋: n={n_val} (양성={n_pos}, 음성={n_val - n_pos})")
|
||||||
|
print("⚠ 30일 데이터 기반 — 방향성 참고용\n")
|
||||||
|
|
||||||
|
print("Feature Importance Top 10 (New):")
|
||||||
|
for feat_name, imp_val in results["New (26)"]["top10"]:
|
||||||
|
marker = " ← NEW" if feat_name in BASELINE_EXCLUDE else ""
|
||||||
|
print(f" {feat_name:<25} {imp_val:>6}{marker}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add --compare flag to argparse**
|
||||||
|
|
||||||
|
`scripts/train_model.py` main() 함수의 argparse에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
parser.add_argument("--compare", action="store_true",
|
||||||
|
help="OI 파생 피처 추가 전후 A/B 성능 비교")
|
||||||
|
```
|
||||||
|
|
||||||
|
main() 분기에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if args.compare:
|
||||||
|
compare(args.data, time_weight_decay=args.decay, tuned_params_path=args.tuned_params)
|
||||||
|
elif args.wf:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/train_model.py
|
||||||
|
git commit -m "feat: add --compare flag for OI derived features A/B comparison"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: bot.py — OI deque 히스토리 및 실시간 파생 피처 공급
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py:15-31` (init), `src/bot.py:60-83` (fetch/calc), `src/bot.py:110-114,287-291` (build_features 호출)
|
||||||
|
- Modify: `src/exchange.py` (get_oi_history 추가)
|
||||||
|
- Test: `tests/test_bot.py`
|
||||||
|
|
||||||
|
**Step 1: Write failing tests**
|
||||||
|
|
||||||
|
`tests/test_bot.py` 끝에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_bot_has_oi_history_deque(config):
|
||||||
|
"""봇이 OI 히스토리 deque를 가져야 한다."""
|
||||||
|
with patch("src.bot.BinanceFuturesClient"):
|
||||||
|
bot = TradingBot(config)
|
||||||
|
from collections import deque
|
||||||
|
assert isinstance(bot._oi_history, deque)
|
||||||
|
assert bot._oi_history.maxlen == 5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_init_oi_history_fills_deque(config):
|
||||||
|
"""_init_oi_history가 deque를 채워야 한다."""
|
||||||
|
with patch("src.bot.BinanceFuturesClient"):
|
||||||
|
bot = TradingBot(config)
|
||||||
|
bot.exchange.get_oi_history = AsyncMock(return_value=[0.01, -0.02, 0.03, -0.01, 0.02])
|
||||||
|
await bot._init_oi_history()
|
||||||
|
assert len(bot._oi_history) == 5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_microstructure_returns_derived_features(config):
|
||||||
|
"""_fetch_market_microstructure가 oi_change_ma5와 oi_price_spread를 반환해야 한다."""
|
||||||
|
with patch("src.bot.BinanceFuturesClient"):
|
||||||
|
bot = TradingBot(config)
|
||||||
|
bot.exchange.get_open_interest = AsyncMock(return_value=5000000.0)
|
||||||
|
bot.exchange.get_funding_rate = AsyncMock(return_value=0.0001)
|
||||||
|
bot._prev_oi = 4900000.0
|
||||||
|
bot._oi_history.extend([0.01, -0.02, 0.03, -0.01])
|
||||||
|
bot._latest_ret_1 = 0.01
|
||||||
|
|
||||||
|
result = await bot._fetch_market_microstructure()
|
||||||
|
assert len(result) == 4 # oi_change, funding_rate, oi_change_ma5, oi_price_spread
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh -k "oi_history or fetch_microstructure_returns_derived"`
|
||||||
|
Expected: FAIL
|
||||||
|
|
||||||
|
**Step 3: Implement exchange.get_oi_history()**
|
||||||
|
|
||||||
|
`src/exchange.py`에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_oi_history(self, limit: int = 5) -> list[float]:
|
||||||
|
"""최근 OI 변화율 히스토리를 조회한다 (봇 초기화용). 실패 시 빈 리스트."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self.client.futures_open_interest_hist(
|
||||||
|
symbol=self.config.symbol, period="15m", limit=limit + 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if len(result) < 2:
|
||||||
|
return []
|
||||||
|
oi_values = [float(r["sumOpenInterest"]) for r in result]
|
||||||
|
changes = []
|
||||||
|
for i in range(1, len(oi_values)):
|
||||||
|
if oi_values[i - 1] > 0:
|
||||||
|
changes.append((oi_values[i] - oi_values[i - 1]) / oi_values[i - 1])
|
||||||
|
else:
|
||||||
|
changes.append(0.0)
|
||||||
|
return changes
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"OI 히스토리 조회 실패 (무시): {e}")
|
||||||
|
return []
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Implement bot.py changes**
|
||||||
|
|
||||||
|
`src/bot.py` `__init__` 수정:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
# __init__에 추가:
|
||||||
|
self._oi_history: deque = deque(maxlen=5)
|
||||||
|
self._latest_ret_1: float = 0.0 # 최신 가격 수익률 (oi_price_spread용)
|
||||||
|
```
|
||||||
|
|
||||||
|
`_init_oi_history()` 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _init_oi_history(self) -> None:
|
||||||
|
"""봇 시작 시 최근 OI 변화율 히스토리를 조회하여 deque를 채운다."""
|
||||||
|
try:
|
||||||
|
changes = await self.exchange.get_oi_history(limit=5)
|
||||||
|
for c in changes:
|
||||||
|
self._oi_history.append(c)
|
||||||
|
if changes:
|
||||||
|
self._prev_oi = None # 다음 실시간 OI로 갱신
|
||||||
|
logger.info(f"OI 히스토리 초기화: {len(self._oi_history)}개")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"OI 히스토리 초기화 실패 (무시): {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
`_fetch_market_microstructure()` 수정 — 4-tuple 반환:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _fetch_market_microstructure(self) -> tuple[float, float, float, float]:
|
||||||
|
"""OI 변화율, 펀딩비, OI MA5, OI-가격 스프레드를 실시간으로 조회한다."""
|
||||||
|
oi_val, fr_val = await asyncio.gather(
|
||||||
|
self.exchange.get_open_interest(),
|
||||||
|
self.exchange.get_funding_rate(),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
if isinstance(oi_val, (int, float)) and oi_val > 0:
|
||||||
|
oi_change = self._calc_oi_change(float(oi_val))
|
||||||
|
else:
|
||||||
|
oi_change = 0.0
|
||||||
|
fr_float = float(fr_val) if isinstance(fr_val, (int, float)) else 0.0
|
||||||
|
|
||||||
|
# OI 히스토리 업데이트 및 MA5 계산
|
||||||
|
self._oi_history.append(oi_change)
|
||||||
|
oi_ma5 = sum(self._oi_history) / len(self._oi_history) if self._oi_history else 0.0
|
||||||
|
|
||||||
|
# OI-가격 스프레드 (단순 차이, 실시간에서는 z-score 없이 raw)
|
||||||
|
oi_price_spread = oi_change - self._latest_ret_1
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"OI={oi_val}, OI변화율={oi_change:.6f}, 펀딩비={fr_float:.6f}, "
|
||||||
|
f"OI_MA5={oi_ma5:.6f}, OI_Price_Spread={oi_price_spread:.6f}"
|
||||||
|
)
|
||||||
|
return oi_change, fr_float, oi_ma5, oi_price_spread
|
||||||
|
```
|
||||||
|
|
||||||
|
`process_candle()` 수정:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 캔들 마감 시 가격 수익률 계산 (oi_price_spread용)
|
||||||
|
if len(df) >= 2:
|
||||||
|
prev_close = df["close"].iloc[-2]
|
||||||
|
curr_close = df["close"].iloc[-1]
|
||||||
|
self._latest_ret_1 = (curr_close - prev_close) / prev_close if prev_close != 0 else 0.0
|
||||||
|
|
||||||
|
oi_change, funding_rate, oi_ma5, oi_price_spread = await self._fetch_market_microstructure()
|
||||||
|
```
|
||||||
|
|
||||||
|
모든 `build_features()` 호출에 새 파라미터 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
features = build_features(
|
||||||
|
df_with_indicators, signal,
|
||||||
|
btc_df=btc_df, eth_df=eth_df,
|
||||||
|
oi_change=oi_change, funding_rate=funding_rate,
|
||||||
|
oi_change_ma5=oi_ma5, oi_price_spread=oi_price_spread,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`_close_and_reenter()` 시그니처도 확장:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _close_and_reenter(
|
||||||
|
self,
|
||||||
|
position: dict,
|
||||||
|
signal: str,
|
||||||
|
df,
|
||||||
|
btc_df=None,
|
||||||
|
eth_df=None,
|
||||||
|
oi_change: float = 0.0,
|
||||||
|
funding_rate: float = 0.0,
|
||||||
|
oi_change_ma5: float = 0.0,
|
||||||
|
oi_price_spread: float = 0.0,
|
||||||
|
) -> None:
|
||||||
|
```
|
||||||
|
|
||||||
|
`run()` 수정 — `_init_oi_history()` 호출 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def run(self):
|
||||||
|
logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x")
|
||||||
|
await self._recover_position()
|
||||||
|
await self._init_oi_history()
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Run tests**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh -k "test_bot"`
|
||||||
|
Expected: All PASS
|
||||||
|
|
||||||
|
**Step 6: Run full test suite**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: All PASS
|
||||||
|
|
||||||
|
**Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/bot.py src/exchange.py tests/test_bot.py
|
||||||
|
git commit -m "feat: add OI history deque, cold start init, and derived features to bot runtime"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: scripts/collect_oi.py — OI 장기 수집 스크립트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/collect_oi.py`
|
||||||
|
|
||||||
|
**Step 1: Implement**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
OI 장기 수집 스크립트.
|
||||||
|
15분마다 cron 실행하여 Binance OI를 data/oi_history.parquet에 누적한다.
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
python scripts/collect_oi.py
|
||||||
|
python scripts/collect_oi.py --symbol XRPUSDT
|
||||||
|
|
||||||
|
crontab 예시:
|
||||||
|
*/15 * * * * cd /path/to/cointrader && .venv/bin/python scripts/collect_oi.py >> logs/collect_oi.log 2>&1
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from binance.client import Client
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import os
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
OI_PATH = Path("data/oi_history.parquet")
|
||||||
|
|
||||||
|
|
||||||
|
def collect(symbol: str = "XRPUSDT"):
|
||||||
|
client = Client(
|
||||||
|
api_key=os.getenv("BINANCE_API_KEY", ""),
|
||||||
|
api_secret=os.getenv("BINANCE_API_SECRET", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = client.futures_open_interest(symbol=symbol)
|
||||||
|
oi_value = float(result["openInterest"])
|
||||||
|
ts = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
new_row = pd.DataFrame([{
|
||||||
|
"timestamp": ts,
|
||||||
|
"symbol": symbol,
|
||||||
|
"open_interest": oi_value,
|
||||||
|
}])
|
||||||
|
|
||||||
|
if OI_PATH.exists():
|
||||||
|
existing = pd.read_parquet(OI_PATH)
|
||||||
|
combined = pd.concat([existing, new_row], ignore_index=True)
|
||||||
|
else:
|
||||||
|
OI_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
combined = new_row
|
||||||
|
|
||||||
|
combined.to_parquet(OI_PATH, index=False)
|
||||||
|
print(f"[{ts.isoformat()}] OI={oi_value:.2f} → {OI_PATH}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="OI 장기 수집")
|
||||||
|
parser.add_argument("--symbol", default="XRPUSDT")
|
||||||
|
args = parser.parse_args()
|
||||||
|
collect(symbol=args.symbol)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/collect_oi.py
|
||||||
|
git commit -m "feat: add OI long-term collection script for cron-based data accumulation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: 기존 테스트 수정 및 전체 검증
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/test_ml_features.py` (피처 수 변경)
|
||||||
|
- Modify: `tests/test_bot.py` (기존 OI 테스트가 4-tuple 반환에 호환되도록)
|
||||||
|
|
||||||
|
**Step 1: Fix test_ml_features.py assertions**
|
||||||
|
|
||||||
|
- `test_feature_cols_has_24_items` → 26으로 변경
|
||||||
|
- `test_build_features_with_btc_eth_has_24_features` → 26
|
||||||
|
- `test_build_features_without_btc_eth_has_16_features` → 18
|
||||||
|
|
||||||
|
**Step 2: Fix test_bot.py**
|
||||||
|
|
||||||
|
기존 `test_process_candle_fetches_oi_and_funding` 등에서 `_fetch_market_microstructure` 반환값이 4-tuple이 되므로 mock 반환값 수정:
|
||||||
|
|
||||||
|
```python
|
||||||
|
bot._fetch_market_microstructure = AsyncMock(return_value=(0.02, 0.0001, 0.015, 0.01))
|
||||||
|
```
|
||||||
|
|
||||||
|
또는 `_fetch_market_microstructure`를 mock하지 않는 테스트는 exchange mock이 정상이면 자동 통과.
|
||||||
|
|
||||||
|
**Step 3: Run full test suite**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: All PASS
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/test_ml_features.py tests/test_bot.py
|
||||||
|
git commit -m "test: update test assertions for 26-feature model and 4-tuple microstructure"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: CLAUDE.md 업데이트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
|
||||||
|
**Step 1: Update plan table**
|
||||||
|
|
||||||
|
CLAUDE.md의 plan history 테이블에 추가:
|
||||||
|
|
||||||
|
```
|
||||||
|
| 2026-03-04 | `oi-derived-features` (design + plan) | In Progress |
|
||||||
|
```
|
||||||
|
|
||||||
|
ml_features.py 설명도 24→26개로 갱신.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs: update CLAUDE.md with OI derived features plan status"
|
||||||
|
```
|
||||||
209
docs/plans/2026-03-05-multi-symbol-trading-design.md
Normal file
209
docs/plans/2026-03-05-multi-symbol-trading-design.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# Multi-Symbol Trading Design
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
현재 XRP 단일 심볼 선물 거래 봇을 TRX, DOGE 등 다중 심볼 동시 거래로 확장한다.
|
||||||
|
|
||||||
|
## 요구사항
|
||||||
|
|
||||||
|
- **거래 심볼**: XRPUSDT, TRXUSDT, DOGEUSDT (3개, 추후 확장 가능)
|
||||||
|
- **상관관계 심볼**: BTCUSDT, ETHUSDT (기존과 동일)
|
||||||
|
- **ML 모델**: 심볼별 개별 학습·배포
|
||||||
|
- **포지션**: 심볼별 동시 포지션 허용 (최대 3개)
|
||||||
|
- **리스크**: 심볼별 독립 운영 + 글로벌 한도 (일일 손실 5%)
|
||||||
|
- **동일 방향 제한**: 같은 방향(LONG/SHORT) 최대 2개까지 (BTC 급락 시 3배 손실 방지)
|
||||||
|
|
||||||
|
## 접근법: 심볼별 독립 TradingBot 인스턴스 + 공유 RiskManager
|
||||||
|
|
||||||
|
기존 TradingBot의 단일 포지션 상태 머신을 유지하면서, 각 심볼마다 독립 인스턴스를 생성하고 `asyncio.gather()`로 병렬 실행한다. RiskManager만 싱글턴으로 공유하여 글로벌 리스크를 관리한다.
|
||||||
|
|
||||||
|
### 선택 이유
|
||||||
|
|
||||||
|
- 기존 TradingBot 상태 머신 수정 최소화
|
||||||
|
- 심볼 간 완전 격리 — 한 심볼의 에러가 다른 심볼에 영향 없음
|
||||||
|
- 점진적 확장 용이 (새 심볼 = 새 인스턴스 추가)
|
||||||
|
- 각 단계마다 기존 XRP 단일 모드로 테스트 가능
|
||||||
|
|
||||||
|
### 기각된 대안: 단일 Bot + 심볼 라우팅
|
||||||
|
|
||||||
|
하나의 TradingBot에서 `Dict[str, PositionState]`로 관리하는 방식. WebSocket 효율적이나 상태 머신 대규모 리팩토링 필요, 한 심볼 에러가 전체에 영향, 복잡도 대폭 증가.
|
||||||
|
|
||||||
|
## 설계 상세
|
||||||
|
|
||||||
|
### 1. Config 변경
|
||||||
|
|
||||||
|
```python
|
||||||
|
# .env
|
||||||
|
SYMBOLS=XRPUSDT,TRXUSDT,DOGEUSDT
|
||||||
|
CORRELATION_SYMBOLS=BTCUSDT,ETHUSDT
|
||||||
|
MAX_SAME_DIRECTION=2
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
symbols: list[str] # ["XRPUSDT", "TRXUSDT", "DOGEUSDT"]
|
||||||
|
correlation_symbols: list[str] # ["BTCUSDT", "ETHUSDT"]
|
||||||
|
max_same_direction: int # 같은 방향 최대 수 (기본 2)
|
||||||
|
# symbol: str 필드 제거
|
||||||
|
```
|
||||||
|
|
||||||
|
- 기존 `SYMBOL` 환경변수 제거, `SYMBOLS`로 통일
|
||||||
|
- `config.symbol` 참조하는 코드 모두 → 각 봇 인스턴스의 `self.symbol`로 전환
|
||||||
|
- 하위호환: `SYMBOLS` 미설정 시 기존 `SYMBOL` 값을 1개짜리 리스트로 변환
|
||||||
|
|
||||||
|
### 2. 실행 구조 (main.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def main():
|
||||||
|
config = Config()
|
||||||
|
risk = RiskManager(config) # 공유 싱글턴
|
||||||
|
|
||||||
|
bots = []
|
||||||
|
for symbol in config.symbols:
|
||||||
|
bot = TradingBot(config, symbol=symbol, risk=risk)
|
||||||
|
bots.append(bot)
|
||||||
|
|
||||||
|
await asyncio.gather(*[bot.run() for bot in bots])
|
||||||
|
```
|
||||||
|
|
||||||
|
- 각 봇은 독립적인 MultiSymbolStream, Exchange, UserDataStream 보유
|
||||||
|
- RiskManager만 공유
|
||||||
|
|
||||||
|
### 3. TradingBot 생성자 변경
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TradingBot:
|
||||||
|
def __init__(self, config: Config, symbol: str, risk: RiskManager):
|
||||||
|
self.symbol = symbol
|
||||||
|
self.config = config
|
||||||
|
self.exchange = BinanceFuturesClient(config, symbol=symbol)
|
||||||
|
self.risk = risk # 외부에서 주입 (공유)
|
||||||
|
self.ml_filter = MLFilter(model_dir=f"models/{symbol.lower()}")
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- `config.symbol` 의존 완전 제거
|
||||||
|
- 각 봇이 자기 심볼을 직접 소유
|
||||||
|
|
||||||
|
### 4. Exchange 심볼 분리
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BinanceFuturesClient:
|
||||||
|
def __init__(self, config: Config, symbol: str):
|
||||||
|
self.symbol = symbol # config.symbol → self.symbol
|
||||||
|
```
|
||||||
|
|
||||||
|
- 모든 API 호출에서 `self.config.symbol` → `self.symbol`
|
||||||
|
|
||||||
|
### 5. RiskManager 공유 설계
|
||||||
|
|
||||||
|
```python
|
||||||
|
class RiskManager:
|
||||||
|
def __init__(self, config):
|
||||||
|
self.daily_pnl = 0.0
|
||||||
|
self.open_positions: dict[str, str] = {} # {symbol: side}
|
||||||
|
self.max_positions = config.max_positions
|
||||||
|
self.max_same_direction = config.max_same_direction # 기본 2
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def can_open_new_position(self, symbol: str, side: str) -> bool:
|
||||||
|
async with self._lock:
|
||||||
|
if len(self.open_positions) >= self.max_positions:
|
||||||
|
return False
|
||||||
|
if symbol in self.open_positions:
|
||||||
|
return False
|
||||||
|
same_dir = sum(1 for s in self.open_positions.values() if s == side)
|
||||||
|
if same_dir >= self.max_same_direction:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def register_position(self, symbol: str, side: str):
|
||||||
|
async with self._lock:
|
||||||
|
self.open_positions[symbol] = side
|
||||||
|
|
||||||
|
async def close_position(self, symbol: str, pnl: float):
|
||||||
|
async with self._lock:
|
||||||
|
self.open_positions.pop(symbol, None)
|
||||||
|
self.daily_pnl += pnl
|
||||||
|
|
||||||
|
def is_trading_allowed(self) -> bool:
|
||||||
|
# 글로벌 일일 손실 한도 체크 (기존과 동일)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `asyncio.Lock()`으로 동시 접근 보호
|
||||||
|
- 동일 방향 2개 제한으로 BTC 급락 시 3배 손실 방지
|
||||||
|
- 마진은 심볼 수(N)로 균등 배분
|
||||||
|
|
||||||
|
### 6. 데이터 스트림
|
||||||
|
|
||||||
|
각 TradingBot이 자기만의 MultiSymbolStream 인스턴스를 가짐:
|
||||||
|
|
||||||
|
```
|
||||||
|
XRP Bot: [XRPUSDT, BTCUSDT, ETHUSDT]
|
||||||
|
TRX Bot: [TRXUSDT, BTCUSDT, ETHUSDT]
|
||||||
|
DOGE Bot: [DOGEUSDT, BTCUSDT, ETHUSDT]
|
||||||
|
```
|
||||||
|
|
||||||
|
- BTC/ETH 데이터 중복 수신되지만 격리성 확보
|
||||||
|
- 각 stream의 primary_symbol이 달라 candle close 콜백 독립적
|
||||||
|
|
||||||
|
### 7. 모델 & 데이터 디렉토리 분리
|
||||||
|
|
||||||
|
```
|
||||||
|
models/
|
||||||
|
├── xrpusdt/
|
||||||
|
│ ├── lgbm_filter.pkl
|
||||||
|
│ └── mlx_filter.weights.onnx
|
||||||
|
├── trxusdt/
|
||||||
|
│ └── ...
|
||||||
|
└── dogeusdt/
|
||||||
|
└── ...
|
||||||
|
|
||||||
|
data/
|
||||||
|
├── xrpusdt/
|
||||||
|
│ └── combined_15m.parquet
|
||||||
|
├── trxusdt/
|
||||||
|
│ └── combined_15m.parquet
|
||||||
|
└── dogeusdt/
|
||||||
|
└── combined_15m.parquet
|
||||||
|
```
|
||||||
|
|
||||||
|
- 각 parquet: 해당 심볼이 primary + BTC/ETH가 correlation
|
||||||
|
- feature 구조 동일 (26 features)
|
||||||
|
|
||||||
|
### 8. 학습 파이프라인 CLI 통일
|
||||||
|
|
||||||
|
모든 스크립트에 `--symbol`과 `--all` 패턴 적용:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 단일 심볼
|
||||||
|
bash scripts/train_and_deploy.sh --symbol TRXUSDT
|
||||||
|
python scripts/fetch_history.py --symbol DOGEUSDT
|
||||||
|
python scripts/train_model.py --symbol TRXUSDT
|
||||||
|
python scripts/tune_hyperparams.py --symbol DOGEUSDT
|
||||||
|
|
||||||
|
# 전체 심볼
|
||||||
|
bash scripts/train_and_deploy.sh --all
|
||||||
|
bash scripts/train_and_deploy.sh # 인자 없으면 --all 동일
|
||||||
|
|
||||||
|
# MLX + 단일 심볼
|
||||||
|
bash scripts/train_and_deploy.sh mlx --symbol DOGEUSDT
|
||||||
|
```
|
||||||
|
|
||||||
|
## 구현 순서
|
||||||
|
|
||||||
|
각 단계마다 기존 XRP 단일 모드로 테스트 가능하도록 점진적 전환:
|
||||||
|
|
||||||
|
1. **Config** — `symbols` 리스트, `max_same_direction` 추가
|
||||||
|
2. **RiskManager** — 공유 싱글턴, asyncio.Lock, 동일 방향 제한
|
||||||
|
3. **exchange.py** — `config.symbol` → `self.symbol` 분리
|
||||||
|
4. **bot.py** — 생성자에 `symbol`, `risk` 파라미터 추가, `config.symbol` 제거
|
||||||
|
5. **main.py** — 심볼별 봇 인스턴스 생성 + `asyncio.gather()`
|
||||||
|
6. **학습 스크립트** — `--symbol`/`--all` CLI, 디렉토리 분리
|
||||||
|
|
||||||
|
## 변경 불필요한 컴포넌트
|
||||||
|
|
||||||
|
- `src/indicators.py` — 이미 심볼에 독립적
|
||||||
|
- `src/notifier.py` — 이미 symbol 파라미터 수용
|
||||||
|
- `src/user_data_stream.py` — 이미 심볼별 필터링 지원
|
||||||
|
- `src/ml_features.py` — 이미 primary + auxiliary 구조
|
||||||
|
- `src/label_builder.py` — 이미 범용적
|
||||||
920
docs/plans/2026-03-05-multi-symbol-trading-plan.md
Normal file
920
docs/plans/2026-03-05-multi-symbol-trading-plan.md
Normal file
@@ -0,0 +1,920 @@
|
|||||||
|
# Multi-Symbol Trading Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** XRP 단일 심볼 거래 봇을 TRX·DOGE 등 다중 심볼 동시 거래로 확장한다.
|
||||||
|
|
||||||
|
**Architecture:** 심볼별 독립 TradingBot 인스턴스를 `asyncio.gather()`로 병렬 실행. RiskManager만 공유 싱글턴으로 글로벌 리스크(일일 손실 한도, 동일 방향 제한)를 관리한다. 각 봇은 자기 심볼을 직접 소유하고, `config.symbol` 의존을 완전 제거한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Python asyncio, LightGBM, ONNX, Binance Futures API
|
||||||
|
|
||||||
|
**Design Doc:** `docs/plans/2026-03-05-multi-symbol-trading-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Config — `symbols` 리스트 추가, `symbol` 필드 유지(하위호환)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/config.py`
|
||||||
|
- Modify: `tests/test_config.py`
|
||||||
|
- Modify: `.env.example`
|
||||||
|
|
||||||
|
**Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
`tests/test_config.py`에 다음 테스트를 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_config_loads_symbols_list():
|
||||||
|
"""SYMBOLS 환경변수로 쉼표 구분 리스트를 로드한다."""
|
||||||
|
os.environ["SYMBOLS"] = "XRPUSDT,TRXUSDT,DOGEUSDT"
|
||||||
|
os.environ.pop("SYMBOL", None)
|
||||||
|
cfg = Config()
|
||||||
|
assert cfg.symbols == ["XRPUSDT", "TRXUSDT", "DOGEUSDT"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_fallback_to_symbol():
|
||||||
|
"""SYMBOLS 미설정 시 SYMBOL에서 1개짜리 리스트로 변환한다."""
|
||||||
|
os.environ.pop("SYMBOLS", None)
|
||||||
|
os.environ["SYMBOL"] = "XRPUSDT"
|
||||||
|
cfg = Config()
|
||||||
|
assert cfg.symbols == ["XRPUSDT"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_correlation_symbols():
|
||||||
|
"""상관관계 심볼 로드."""
|
||||||
|
os.environ["CORRELATION_SYMBOLS"] = "BTCUSDT,ETHUSDT"
|
||||||
|
cfg = Config()
|
||||||
|
assert cfg.correlation_symbols == ["BTCUSDT", "ETHUSDT"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_max_same_direction_default():
|
||||||
|
"""동일 방향 최대 수 기본값 2."""
|
||||||
|
cfg = Config()
|
||||||
|
assert cfg.max_same_direction == 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_config.py -v`
|
||||||
|
Expected: FAIL — `Config` has no `symbols`, `correlation_symbols`, `max_same_direction` attributes
|
||||||
|
|
||||||
|
**Step 3: Implement Config changes**
|
||||||
|
|
||||||
|
`src/config.py`를 수정:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
api_key: str = ""
|
||||||
|
api_secret: str = ""
|
||||||
|
symbol: str = "XRPUSDT"
|
||||||
|
symbols: list = None # NEW
|
||||||
|
correlation_symbols: list = None # NEW
|
||||||
|
leverage: int = 10
|
||||||
|
max_positions: int = 3
|
||||||
|
max_same_direction: int = 2 # NEW
|
||||||
|
stop_loss_pct: float = 0.015
|
||||||
|
take_profit_pct: float = 0.045
|
||||||
|
trailing_stop_pct: float = 0.01
|
||||||
|
discord_webhook_url: str = ""
|
||||||
|
margin_max_ratio: float = 0.50
|
||||||
|
margin_min_ratio: float = 0.20
|
||||||
|
margin_decay_rate: float = 0.0006
|
||||||
|
ml_threshold: float = 0.55
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self.api_key = os.getenv("BINANCE_API_KEY", "")
|
||||||
|
self.api_secret = os.getenv("BINANCE_API_SECRET", "")
|
||||||
|
self.symbol = os.getenv("SYMBOL", "XRPUSDT")
|
||||||
|
self.leverage = int(os.getenv("LEVERAGE", "10"))
|
||||||
|
self.discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL", "")
|
||||||
|
self.margin_max_ratio = float(os.getenv("MARGIN_MAX_RATIO", "0.50"))
|
||||||
|
self.margin_min_ratio = float(os.getenv("MARGIN_MIN_RATIO", "0.20"))
|
||||||
|
self.margin_decay_rate = float(os.getenv("MARGIN_DECAY_RATE", "0.0006"))
|
||||||
|
self.ml_threshold = float(os.getenv("ML_THRESHOLD", "0.55"))
|
||||||
|
self.max_same_direction = int(os.getenv("MAX_SAME_DIRECTION", "2"))
|
||||||
|
|
||||||
|
# symbols: SYMBOLS 환경변수 우선, 없으면 SYMBOL에서 변환
|
||||||
|
symbols_env = os.getenv("SYMBOLS", "")
|
||||||
|
if symbols_env:
|
||||||
|
self.symbols = [s.strip() for s in symbols_env.split(",") if s.strip()]
|
||||||
|
else:
|
||||||
|
self.symbols = [self.symbol]
|
||||||
|
|
||||||
|
# correlation_symbols
|
||||||
|
corr_env = os.getenv("CORRELATION_SYMBOLS", "BTCUSDT,ETHUSDT")
|
||||||
|
self.correlation_symbols = [s.strip() for s in corr_env.split(",") if s.strip()]
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env.example`에 추가:
|
||||||
|
|
||||||
|
```
|
||||||
|
SYMBOLS=XRPUSDT
|
||||||
|
CORRELATION_SYMBOLS=BTCUSDT,ETHUSDT
|
||||||
|
MAX_SAME_DIRECTION=2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_config.py -v`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
**Step 5: Run full test suite to verify no regressions**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS (기존 코드는 `config.symbol`을 여전히 사용 가능하므로 깨지지 않음)
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/config.py tests/test_config.py .env.example
|
||||||
|
git commit -m "feat: add multi-symbol config (symbols list, correlation_symbols, max_same_direction)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: RiskManager — 공유 싱글턴, asyncio.Lock, 동일 방향 제한
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/risk_manager.py`
|
||||||
|
- Modify: `tests/test_risk_manager.py`
|
||||||
|
|
||||||
|
**Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
`tests/test_risk_manager.py`에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shared_risk(config):
|
||||||
|
config.max_same_direction = 2
|
||||||
|
return RiskManager(config)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_can_open_new_position_async(shared_risk):
|
||||||
|
"""비동기 포지션 오픈 허용 체크."""
|
||||||
|
assert await shared_risk.can_open_new_position("XRPUSDT", "LONG") is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_and_close_position(shared_risk):
|
||||||
|
"""포지션 등록 후 닫기."""
|
||||||
|
await shared_risk.register_position("XRPUSDT", "LONG")
|
||||||
|
assert "XRPUSDT" in shared_risk.open_positions
|
||||||
|
await shared_risk.close_position("XRPUSDT", pnl=1.5)
|
||||||
|
assert "XRPUSDT" not in shared_risk.open_positions
|
||||||
|
assert shared_risk.daily_pnl == 1.5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_same_symbol_blocked(shared_risk):
|
||||||
|
"""같은 심볼 중복 진입 차단."""
|
||||||
|
await shared_risk.register_position("XRPUSDT", "LONG")
|
||||||
|
assert await shared_risk.can_open_new_position("XRPUSDT", "SHORT") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_max_same_direction_limit(shared_risk):
|
||||||
|
"""같은 방향 2개 초과 차단."""
|
||||||
|
await shared_risk.register_position("XRPUSDT", "LONG")
|
||||||
|
await shared_risk.register_position("TRXUSDT", "LONG")
|
||||||
|
# 3번째 LONG 차단
|
||||||
|
assert await shared_risk.can_open_new_position("DOGEUSDT", "LONG") is False
|
||||||
|
# SHORT은 허용
|
||||||
|
assert await shared_risk.can_open_new_position("DOGEUSDT", "SHORT") is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_max_positions_global_limit(shared_risk):
|
||||||
|
"""전체 포지션 수 한도 초과 차단."""
|
||||||
|
shared_risk.config.max_positions = 2
|
||||||
|
await shared_risk.register_position("XRPUSDT", "LONG")
|
||||||
|
await shared_risk.register_position("TRXUSDT", "SHORT")
|
||||||
|
assert await shared_risk.can_open_new_position("DOGEUSDT", "LONG") is False
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_risk_manager.py -v -k "async or register or same_direction"`
|
||||||
|
Expected: FAIL — `can_open_new_position`이 sync이고 파라미터가 없음
|
||||||
|
|
||||||
|
**Step 3: Implement RiskManager changes**
|
||||||
|
|
||||||
|
`src/risk_manager.py` 전체 교체:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from loguru import logger
|
||||||
|
from src.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
class RiskManager:
|
||||||
|
def __init__(self, config: Config, max_daily_loss_pct: float = 0.05):
|
||||||
|
self.config = config
|
||||||
|
self.max_daily_loss_pct = max_daily_loss_pct
|
||||||
|
self.daily_pnl: float = 0.0
|
||||||
|
self.initial_balance: float = 0.0
|
||||||
|
self.open_positions: dict[str, str] = {} # {symbol: side}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
def is_trading_allowed(self) -> bool:
|
||||||
|
"""일일 최대 손실 초과 시 거래 중단"""
|
||||||
|
if self.initial_balance <= 0:
|
||||||
|
return True
|
||||||
|
loss_pct = abs(self.daily_pnl) / self.initial_balance
|
||||||
|
if self.daily_pnl < 0 and loss_pct >= self.max_daily_loss_pct:
|
||||||
|
logger.warning(
|
||||||
|
f"일일 손실 한도 초과: {loss_pct:.2%} >= {self.max_daily_loss_pct:.2%}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def can_open_new_position(self, symbol: str, side: str) -> bool:
|
||||||
|
"""포지션 오픈 가능 여부 (전체 한도 + 중복 진입 + 동일 방향 제한)"""
|
||||||
|
async with self._lock:
|
||||||
|
if len(self.open_positions) >= self.config.max_positions:
|
||||||
|
logger.info(f"최대 포지션 수 도달: {len(self.open_positions)}/{self.config.max_positions}")
|
||||||
|
return False
|
||||||
|
if symbol in self.open_positions:
|
||||||
|
logger.info(f"{symbol} 이미 포지션 보유 중")
|
||||||
|
return False
|
||||||
|
same_dir = sum(1 for s in self.open_positions.values() if s == side)
|
||||||
|
if same_dir >= self.config.max_same_direction:
|
||||||
|
logger.info(f"동일 방향({side}) 한도 도달: {same_dir}/{self.config.max_same_direction}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def register_position(self, symbol: str, side: str):
|
||||||
|
"""포지션 등록"""
|
||||||
|
async with self._lock:
|
||||||
|
self.open_positions[symbol] = side
|
||||||
|
logger.info(f"포지션 등록: {symbol} {side} (현재 {len(self.open_positions)}개)")
|
||||||
|
|
||||||
|
async def close_position(self, symbol: str, pnl: float):
|
||||||
|
"""포지션 닫기 + PnL 기록"""
|
||||||
|
async with self._lock:
|
||||||
|
self.open_positions.pop(symbol, None)
|
||||||
|
self.daily_pnl += pnl
|
||||||
|
logger.info(f"포지션 종료: {symbol}, PnL={pnl:+.4f}, 누적={self.daily_pnl:+.4f}")
|
||||||
|
|
||||||
|
def record_pnl(self, pnl: float):
|
||||||
|
self.daily_pnl += pnl
|
||||||
|
logger.info(f"오늘 누적 PnL: {self.daily_pnl:.4f} USDT")
|
||||||
|
|
||||||
|
def reset_daily(self):
|
||||||
|
"""매일 자정 초기화"""
|
||||||
|
self.daily_pnl = 0.0
|
||||||
|
logger.info("일일 PnL 초기화")
|
||||||
|
|
||||||
|
def set_base_balance(self, balance: float) -> None:
|
||||||
|
"""봇 시작 시 기준 잔고 설정"""
|
||||||
|
self.initial_balance = balance
|
||||||
|
|
||||||
|
def get_dynamic_margin_ratio(self, balance: float) -> float:
|
||||||
|
"""잔고에 따라 선형 감소하는 증거금 비율 반환"""
|
||||||
|
ratio = self.config.margin_max_ratio - (
|
||||||
|
(balance - self.initial_balance) * self.config.margin_decay_rate
|
||||||
|
)
|
||||||
|
return max(self.config.margin_min_ratio, min(self.config.margin_max_ratio, ratio))
|
||||||
|
```
|
||||||
|
|
||||||
|
주요 변경:
|
||||||
|
- `open_positions: list` → `dict[str, str]` (심볼→방향 매핑)
|
||||||
|
- `can_open_new_position()` → `async` + `symbol`, `side` 파라미터
|
||||||
|
- `register_position()`, `close_position()` 새 메서드 추가
|
||||||
|
- `asyncio.Lock()` 동시성 보호
|
||||||
|
|
||||||
|
**Step 4: Fix existing tests that break**
|
||||||
|
|
||||||
|
기존 테스트에서 `can_open_new_position()` 호출 방식이 바뀌었으므로 수정:
|
||||||
|
|
||||||
|
`tests/test_risk_manager.py`의 `test_position_size_capped`를 수정:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_position_size_capped(config):
|
||||||
|
rm = RiskManager(config, max_daily_loss_pct=0.05)
|
||||||
|
await rm.register_position("XRPUSDT", "LONG")
|
||||||
|
await rm.register_position("TRXUSDT", "SHORT")
|
||||||
|
await rm.register_position("DOGEUSDT", "LONG")
|
||||||
|
assert await rm.can_open_new_position("SOLUSDT", "SHORT") is False
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_risk_manager.py -v`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
**Step 6: Run full test suite**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: `test_bot.py`에서 `can_open_new_position()` 호출이 깨질 수 있음 — Task 4에서 수정할 것이므로 지금은 bot 테스트 실패 허용
|
||||||
|
|
||||||
|
**Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/risk_manager.py tests/test_risk_manager.py
|
||||||
|
git commit -m "feat: shared RiskManager with async lock, same-direction limit, per-symbol tracking"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Exchange — `config.symbol` → `self.symbol` 분리
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/exchange.py`
|
||||||
|
- Modify: `tests/test_exchange.py`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`tests/test_exchange.py`에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_exchange_uses_own_symbol():
|
||||||
|
"""Exchange 클라이언트가 config.symbol 대신 생성자의 symbol을 사용한다."""
|
||||||
|
os.environ.update({
|
||||||
|
"BINANCE_API_KEY": "test_key",
|
||||||
|
"BINANCE_API_SECRET": "test_secret",
|
||||||
|
"SYMBOL": "XRPUSDT",
|
||||||
|
})
|
||||||
|
config = Config()
|
||||||
|
with patch("src.exchange.Client"):
|
||||||
|
client = BinanceFuturesClient(config, symbol="TRXUSDT")
|
||||||
|
assert client.symbol == "TRXUSDT"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_exchange.py::test_exchange_uses_own_symbol -v`
|
||||||
|
Expected: FAIL — `__init__` doesn't accept `symbol` parameter
|
||||||
|
|
||||||
|
**Step 3: Implement Exchange changes**
|
||||||
|
|
||||||
|
`src/exchange.py` 생성자 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BinanceFuturesClient:
|
||||||
|
def __init__(self, config: Config, symbol: str = None):
|
||||||
|
self.config = config
|
||||||
|
self.symbol = symbol or config.symbol
|
||||||
|
self.client = Client(
|
||||||
|
api_key=config.api_key,
|
||||||
|
api_secret=config.api_secret,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
모든 `self.config.symbol` 참조를 `self.symbol`로 교체 (9곳):
|
||||||
|
- Line 34: `set_leverage` → `symbol=self.symbol`
|
||||||
|
- Line 71: `place_order` params → `symbol=self.symbol`
|
||||||
|
- Line 101: `_place_algo_order` params → `symbol=self.symbol`
|
||||||
|
- Line 123: `get_position` → `symbol=self.symbol`
|
||||||
|
- Line 137: `cancel_all_orders` 일반 → `symbol=self.symbol`
|
||||||
|
- Line 144: `cancel_all_orders` algo → `symbol=self.symbol`
|
||||||
|
- Line 156: `get_open_interest` → `symbol=self.symbol`
|
||||||
|
- Line 169: `get_funding_rate` → `symbol=self.symbol`
|
||||||
|
- Line 183: `get_oi_history` → `symbol=self.symbol`
|
||||||
|
|
||||||
|
**Step 4: Fix existing test fixtures**
|
||||||
|
|
||||||
|
기존 `exchange` 픽스처에서 `BinanceFuturesClient.__new__`를 사용하는 곳에 `c.symbol` 설정 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
config = Config()
|
||||||
|
config.leverage = 10
|
||||||
|
c = BinanceFuturesClient.__new__(BinanceFuturesClient)
|
||||||
|
c.config = config
|
||||||
|
c.symbol = config.symbol # NEW
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def exchange():
|
||||||
|
os.environ.update({
|
||||||
|
"BINANCE_API_KEY": "test_key",
|
||||||
|
"BINANCE_API_SECRET": "test_secret",
|
||||||
|
"SYMBOL": "XRPUSDT",
|
||||||
|
"LEVERAGE": "10",
|
||||||
|
})
|
||||||
|
config = Config()
|
||||||
|
c = BinanceFuturesClient.__new__(BinanceFuturesClient)
|
||||||
|
c.config = config
|
||||||
|
c.symbol = config.symbol # NEW
|
||||||
|
c.client = MagicMock()
|
||||||
|
return c
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_exchange.py -v`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/exchange.py tests/test_exchange.py
|
||||||
|
git commit -m "feat: exchange client accepts explicit symbol parameter, removes config.symbol dependency"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: TradingBot — 생성자에 `symbol`, `risk` 주입
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py`
|
||||||
|
- Modify: `tests/test_bot.py`
|
||||||
|
|
||||||
|
**Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
`tests/test_bot.py`에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_bot_accepts_symbol_and_risk(config):
|
||||||
|
"""TradingBot이 symbol과 risk를 외부에서 주입받을 수 있다."""
|
||||||
|
from src.risk_manager import RiskManager
|
||||||
|
risk = RiskManager(config)
|
||||||
|
with patch("src.bot.BinanceFuturesClient"):
|
||||||
|
bot = TradingBot(config, symbol="TRXUSDT", risk=risk)
|
||||||
|
assert bot.symbol == "TRXUSDT"
|
||||||
|
assert bot.risk is risk
|
||||||
|
|
||||||
|
|
||||||
|
def test_bot_stream_uses_injected_symbol(config):
|
||||||
|
"""봇의 stream이 주입된 심볼을 primary로 사용한다."""
|
||||||
|
from src.risk_manager import RiskManager
|
||||||
|
risk = RiskManager(config)
|
||||||
|
with patch("src.bot.BinanceFuturesClient"):
|
||||||
|
bot = TradingBot(config, symbol="DOGEUSDT", risk=risk)
|
||||||
|
assert "dogeusdt" in bot.stream.buffers
|
||||||
|
|
||||||
|
|
||||||
|
def test_bot_ml_filter_uses_symbol_model_dir(config):
|
||||||
|
"""봇의 MLFilter가 심볼별 모델 디렉토리를 사용한다."""
|
||||||
|
from src.risk_manager import RiskManager
|
||||||
|
risk = RiskManager(config)
|
||||||
|
with patch("src.bot.BinanceFuturesClient"):
|
||||||
|
bot = TradingBot(config, symbol="TRXUSDT", risk=risk)
|
||||||
|
assert "trxusdt" in str(bot.ml_filter._onnx_path)
|
||||||
|
assert "trxusdt" in str(bot.ml_filter._lgbm_path)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_bot.py -v -k "accepts_symbol or injected_symbol or symbol_model_dir"`
|
||||||
|
Expected: FAIL — `TradingBot.__init__` doesn't accept `symbol` or `risk`
|
||||||
|
|
||||||
|
**Step 3: Implement TradingBot changes**
|
||||||
|
|
||||||
|
`src/bot.py`의 `__init__` 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TradingBot:
|
||||||
|
def __init__(self, config: Config, symbol: str = None, risk: RiskManager = None):
|
||||||
|
self.config = config
|
||||||
|
self.symbol = symbol or config.symbol
|
||||||
|
self.exchange = BinanceFuturesClient(config, symbol=self.symbol)
|
||||||
|
self.notifier = DiscordNotifier(config.discord_webhook_url)
|
||||||
|
self.risk = risk or RiskManager(config)
|
||||||
|
self.ml_filter = MLFilter(
|
||||||
|
onnx_path=f"models/{self.symbol.lower()}/mlx_filter.weights.onnx",
|
||||||
|
lgbm_path=f"models/{self.symbol.lower()}/lgbm_filter.pkl",
|
||||||
|
threshold=config.ml_threshold,
|
||||||
|
)
|
||||||
|
self.current_trade_side: str | None = None
|
||||||
|
self._entry_price: float | None = None
|
||||||
|
self._entry_quantity: float | None = None
|
||||||
|
self._is_reentering: bool = False
|
||||||
|
self._prev_oi: float | None = None
|
||||||
|
self._oi_history: deque = deque(maxlen=5)
|
||||||
|
self._latest_ret_1: float = 0.0
|
||||||
|
self.stream = MultiSymbolStream(
|
||||||
|
symbols=[self.symbol] + config.correlation_symbols,
|
||||||
|
interval="15m",
|
||||||
|
on_candle=self._on_candle_closed,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`_on_candle_closed` 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _on_candle_closed(self, candle: dict):
|
||||||
|
primary_df = self.stream.get_dataframe(self.symbol)
|
||||||
|
btc_df = self.stream.get_dataframe("BTCUSDT")
|
||||||
|
eth_df = self.stream.get_dataframe("ETHUSDT")
|
||||||
|
if primary_df is not None:
|
||||||
|
await self.process_candle(primary_df, btc_df=btc_df, eth_df=eth_df)
|
||||||
|
```
|
||||||
|
|
||||||
|
`process_candle`에서 `can_open_new_position` 호출 변경 (2곳):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Line ~138 (신규 진입):
|
||||||
|
if not await self.risk.can_open_new_position(self.symbol, raw_signal):
|
||||||
|
logger.info(f"[{self.symbol}] 포지션 오픈 불가")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Line ~322 (_close_and_reenter 내):
|
||||||
|
if not await self.risk.can_open_new_position(self.symbol, signal):
|
||||||
|
logger.info(f"[{self.symbol}] 최대 포지션 수 도달 — 재진입 건너뜀")
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
`_open_position`에서 `register_position` 호출 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _open_position(self, signal: str, df):
|
||||||
|
balance = await self.exchange.get_balance()
|
||||||
|
# 심볼 수로 마진 균등 배분
|
||||||
|
num_symbols = len(self.config.symbols)
|
||||||
|
per_symbol_balance = balance / num_symbols
|
||||||
|
price = df["close"].iloc[-1]
|
||||||
|
margin_ratio = self.risk.get_dynamic_margin_ratio(balance)
|
||||||
|
quantity = self.exchange.calculate_quantity(
|
||||||
|
balance=per_symbol_balance, price=price,
|
||||||
|
leverage=self.config.leverage, margin_ratio=margin_ratio,
|
||||||
|
)
|
||||||
|
# ... 기존 로직 ...
|
||||||
|
|
||||||
|
# 포지션 등록
|
||||||
|
await self.risk.register_position(self.symbol, signal)
|
||||||
|
self.current_trade_side = signal
|
||||||
|
# ... 나머지 동일 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`_on_position_closed`에서 `close_position` 호출:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _on_position_closed(self, net_pnl, close_reason, exit_price):
|
||||||
|
# ... 기존 PnL 계산 로직 ...
|
||||||
|
await self.risk.close_position(self.symbol, net_pnl)
|
||||||
|
# record_pnl 제거 (close_position 내에서 처리)
|
||||||
|
# ... 나머지 동일 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
모든 `self.config.symbol` 참조를 `self.symbol`로 교체 (6곳):
|
||||||
|
- Line 31 → `self.symbol` (stream symbols)
|
||||||
|
- Line 37 → `self.symbol` (get_dataframe)
|
||||||
|
- Line 197 → `self.symbol` (notify_open)
|
||||||
|
- Line 251 → `self.symbol` (notify_close)
|
||||||
|
- Line 340 → `self.symbol` (run 로그)
|
||||||
|
- Line 348 → `self.symbol` (UserDataStream)
|
||||||
|
|
||||||
|
`run()` 메서드의 로그도 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def run(self):
|
||||||
|
logger.info(f"[{self.symbol}] 봇 시작, 레버리지 {self.config.leverage}x")
|
||||||
|
# ... 나머지 동일, self.config.symbol → self.symbol ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Fix existing bot tests**
|
||||||
|
|
||||||
|
기존 `tests/test_bot.py`의 모든 `TradingBot(config)` 호출은 하위호환되므로 그대로 동작.
|
||||||
|
단, `risk.can_open_new_position` 호출이 async로 바뀌었으므로 mock 수정 필요:
|
||||||
|
|
||||||
|
`test_close_and_reenter_calls_open_when_ml_passes`:
|
||||||
|
```python
|
||||||
|
bot.risk = MagicMock()
|
||||||
|
bot.risk.can_open_new_position = AsyncMock(return_value=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
`test_close_and_reenter_skips_open_when_max_positions_reached`:
|
||||||
|
```python
|
||||||
|
bot.risk = MagicMock()
|
||||||
|
bot.risk.can_open_new_position = AsyncMock(return_value=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
`test_bot_processes_signal`에서 `bot.risk`도 mock:
|
||||||
|
```python
|
||||||
|
bot.risk = MagicMock()
|
||||||
|
bot.risk.is_trading_allowed.return_value = True
|
||||||
|
bot.risk.can_open_new_position = AsyncMock(return_value=True)
|
||||||
|
bot.risk.register_position = AsyncMock()
|
||||||
|
bot.risk.get_dynamic_margin_ratio.return_value = 0.50
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_bot.py -v`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
**Step 6: Run full test suite**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
**Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/bot.py tests/test_bot.py
|
||||||
|
git commit -m "feat: TradingBot accepts symbol and shared RiskManager, removes config.symbol dependency"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: main.py — 심볼별 봇 인스턴스 생성 + asyncio.gather
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `main.py`
|
||||||
|
|
||||||
|
**Step 1: Implement main.py changes**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from src.config import Config
|
||||||
|
from src.bot import TradingBot
|
||||||
|
from src.risk_manager import RiskManager
|
||||||
|
from src.logger_setup import setup_logger
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
setup_logger(log_level="INFO")
|
||||||
|
config = Config()
|
||||||
|
risk = RiskManager(config)
|
||||||
|
|
||||||
|
bots = []
|
||||||
|
for symbol in config.symbols:
|
||||||
|
bot = TradingBot(config, symbol=symbol, risk=risk)
|
||||||
|
bots.append(bot)
|
||||||
|
|
||||||
|
logger.info(f"멀티심볼 봇 시작: {config.symbols} ({len(bots)}개 인스턴스)")
|
||||||
|
await asyncio.gather(*[bot.run() for bot in bots])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run full test suite to verify no regressions**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add main.py
|
||||||
|
git commit -m "feat: main.py spawns per-symbol TradingBot instances with shared RiskManager"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: MLFilter — 심볼별 모델 디렉토리 폴백
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ml_filter.py`
|
||||||
|
|
||||||
|
**Step 1: Implement MLFilter path fallback**
|
||||||
|
|
||||||
|
MLFilter는 이미 `onnx_path`/`lgbm_path`를 생성자에서 받으므로, bot.py에서 심볼별 경로를 주입하면 된다 (Task 4에서 완료).
|
||||||
|
|
||||||
|
다만 기존 `models/lgbm_filter.pkl` 경로에 모델이 있는 경우(단일 심볼 환경)에도 동작하도록, 심볼별 디렉토리에 모델이 없으면 루트 `models/`에서 폴백하는 로직을 `bot.py`에 추가:
|
||||||
|
|
||||||
|
`src/bot.py`의 `__init__`에서:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 심볼별 모델 디렉토리. 없으면 기존 models/ 루트로 폴백
|
||||||
|
symbol_model_dir = Path(f"models/{self.symbol.lower()}")
|
||||||
|
if symbol_model_dir.exists():
|
||||||
|
onnx_path = str(symbol_model_dir / "mlx_filter.weights.onnx")
|
||||||
|
lgbm_path = str(symbol_model_dir / "lgbm_filter.pkl")
|
||||||
|
else:
|
||||||
|
onnx_path = "models/mlx_filter.weights.onnx"
|
||||||
|
lgbm_path = "models/lgbm_filter.pkl"
|
||||||
|
self.ml_filter = MLFilter(
|
||||||
|
onnx_path=onnx_path,
|
||||||
|
lgbm_path=lgbm_path,
|
||||||
|
threshold=config.ml_threshold,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run full test suite**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/bot.py
|
||||||
|
git commit -m "feat: MLFilter falls back to models/ root if symbol-specific dir not found"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: 학습 스크립트 — `--symbol` / `--all` CLI 통일
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/fetch_history.py`
|
||||||
|
- Modify: `scripts/train_model.py`
|
||||||
|
- Modify: `scripts/tune_hyperparams.py`
|
||||||
|
- Modify: `scripts/train_and_deploy.sh`
|
||||||
|
- Modify: `scripts/deploy_model.sh`
|
||||||
|
|
||||||
|
### Step 1: fetch_history.py — `--symbol` 단일 심볼 + 출력 경로 자동 결정
|
||||||
|
|
||||||
|
`scripts/fetch_history.py`의 argparse에 `--symbol` 추가 및 `--output` 자동 결정:
|
||||||
|
|
||||||
|
현재 사용법: `--symbols XRPUSDT BTCUSDT ETHUSDT --output data/combined_15m.parquet`
|
||||||
|
|
||||||
|
변경 후:
|
||||||
|
```python
|
||||||
|
parser.add_argument("--symbol", type=str, default=None,
|
||||||
|
help="단일 거래 심볼 (예: TRXUSDT). 상관관계 심볼 자동 추가")
|
||||||
|
```
|
||||||
|
|
||||||
|
`--symbol TRXUSDT` 지정 시:
|
||||||
|
- `symbols = ["TRXUSDT", "BTCUSDT", "ETHUSDT"]`
|
||||||
|
- `output = "data/trxusdt/combined_15m.parquet"` (자동)
|
||||||
|
|
||||||
|
`--symbols XRPUSDT BTCUSDT ETHUSDT` (기존 방식)도 유지.
|
||||||
|
|
||||||
|
### Step 2: train_model.py — `--symbol` 추가
|
||||||
|
|
||||||
|
```python
|
||||||
|
parser.add_argument("--symbol", type=str, default=None,
|
||||||
|
help="학습 대상 심볼 (예: TRXUSDT). data/{symbol}/ 에서 데이터 로드, models/{symbol}/ 에 저장")
|
||||||
|
```
|
||||||
|
|
||||||
|
`--symbol TRXUSDT` 지정 시:
|
||||||
|
- 데이터: `data/trxusdt/combined_15m.parquet`
|
||||||
|
- 모델: `models/trxusdt/lgbm_filter.pkl`
|
||||||
|
- 로그: `models/trxusdt/training_log.json`
|
||||||
|
|
||||||
|
`--data` 옵션이 명시되면 그것을 우선.
|
||||||
|
|
||||||
|
### Step 3: tune_hyperparams.py — `--symbol` 추가
|
||||||
|
|
||||||
|
train_model.py와 동일한 패턴. `--symbol`이 지정되면:
|
||||||
|
- 데이터: `data/{symbol}/combined_15m.parquet`
|
||||||
|
- 결과: `models/{symbol}/tune_results_*.json`
|
||||||
|
- active params: `models/{symbol}/active_lgbm_params.json`
|
||||||
|
|
||||||
|
### Step 4: train_and_deploy.sh — `--symbol` / `--all` 지원
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 사용법:
|
||||||
|
# bash scripts/train_and_deploy.sh [mlx|lgbm] [--symbol TRXUSDT]
|
||||||
|
# bash scripts/train_and_deploy.sh [mlx|lgbm] --all
|
||||||
|
# bash scripts/train_and_deploy.sh # --all과 동일 (기본값)
|
||||||
|
```
|
||||||
|
|
||||||
|
`--symbol` 지정 시: 해당 심볼만 fetch → train → deploy
|
||||||
|
`--all` 또는 인자 없음: `SYMBOLS` 환경변수의 모든 심볼 순차 처리
|
||||||
|
|
||||||
|
핵심 로직:
|
||||||
|
```bash
|
||||||
|
if [ -n "$SYMBOL_ARG" ]; then
|
||||||
|
TARGETS=("$SYMBOL_ARG")
|
||||||
|
else
|
||||||
|
# .env에서 SYMBOLS 로드
|
||||||
|
TARGETS=($(python -c "from src.config import Config; c=Config(); print(' '.join(c.symbols))"))
|
||||||
|
fi
|
||||||
|
|
||||||
|
for SYM in "${TARGETS[@]}"; do
|
||||||
|
SYM_LOWER=$(echo "$SYM" | tr '[:upper:]' '[:lower:]')
|
||||||
|
mkdir -p "data/$SYM_LOWER" "models/$SYM_LOWER"
|
||||||
|
|
||||||
|
# fetch
|
||||||
|
python scripts/fetch_history.py --symbol "$SYM" ...
|
||||||
|
|
||||||
|
# train
|
||||||
|
python scripts/train_model.py --symbol "$SYM" ...
|
||||||
|
|
||||||
|
# deploy
|
||||||
|
bash scripts/deploy_model.sh "$BACKEND" --symbol "$SYM"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: deploy_model.sh — `--symbol` 지원
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 사용법: bash scripts/deploy_model.sh [lgbm|mlx] [--symbol TRXUSDT]
|
||||||
|
```
|
||||||
|
|
||||||
|
`--symbol` 지정 시:
|
||||||
|
- 로컬: `models/{symbol}/lgbm_filter.pkl`
|
||||||
|
- 원격: `$LXC_MODELS_PATH/{symbol}/lgbm_filter.pkl`
|
||||||
|
|
||||||
|
### Step 6: Run full test suite
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS (스크립트 변경은 unit test에 영향 없음)
|
||||||
|
|
||||||
|
### Step 7: Smoke test 스크립트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# fetch만 소량 테스트
|
||||||
|
python scripts/fetch_history.py --symbol TRXUSDT --interval 15m --days 1
|
||||||
|
ls data/trxusdt/combined_15m.parquet # 파일 존재 확인
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/fetch_history.py scripts/train_model.py scripts/tune_hyperparams.py scripts/train_and_deploy.sh scripts/deploy_model.sh
|
||||||
|
git commit -m "feat: add --symbol/--all CLI to all training scripts for per-symbol pipeline"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: 디렉토리 구조 생성 + .env.example 업데이트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `models/xrpusdt/.gitkeep`
|
||||||
|
- Create: `models/trxusdt/.gitkeep`
|
||||||
|
- Create: `models/dogeusdt/.gitkeep`
|
||||||
|
- Create: `data/xrpusdt/.gitkeep`
|
||||||
|
- Create: `data/trxusdt/.gitkeep`
|
||||||
|
- Create: `data/dogeusdt/.gitkeep`
|
||||||
|
- Modify: `.env.example`
|
||||||
|
|
||||||
|
**Step 1: Create directory structure**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p models/{xrpusdt,trxusdt,dogeusdt}
|
||||||
|
mkdir -p data/{xrpusdt,trxusdt,dogeusdt}
|
||||||
|
touch models/{xrpusdt,trxusdt,dogeusdt}/.gitkeep
|
||||||
|
touch data/{xrpusdt,trxusdt,dogeusdt}/.gitkeep
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update .env.example**
|
||||||
|
|
||||||
|
```
|
||||||
|
BINANCE_API_KEY=
|
||||||
|
BINANCE_API_SECRET=
|
||||||
|
SYMBOLS=XRPUSDT
|
||||||
|
CORRELATION_SYMBOLS=BTCUSDT,ETHUSDT
|
||||||
|
LEVERAGE=10
|
||||||
|
RISK_PER_TRADE=0.02
|
||||||
|
DISCORD_WEBHOOK_URL=
|
||||||
|
ML_THRESHOLD=0.55
|
||||||
|
MAX_SAME_DIRECTION=2
|
||||||
|
BINANCE_TESTNET_API_KEY=
|
||||||
|
BINANCE_TESTNET_API_SECRET=
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add models/ data/ .env.example
|
||||||
|
git commit -m "feat: add per-symbol model/data directories and update .env.example"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: 기존 모델 마이그레이션 안내 + 문서 업데이트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
|
||||||
|
**Step 1: Update CLAUDE.md**
|
||||||
|
|
||||||
|
Architecture 섹션에 멀티심볼 관련 내용 추가:
|
||||||
|
|
||||||
|
- `main.py` → `Config` → 심볼별 `TradingBot` 인스턴스 → `asyncio.gather()`
|
||||||
|
- `RiskManager` 공유 싱글턴 (글로벌 일일 손실 + 동일 방향 제한)
|
||||||
|
- 모델/데이터 디렉토리: `models/{symbol}/`, `data/{symbol}/`
|
||||||
|
|
||||||
|
Common Commands 섹션 업데이트:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 단일 심볼 학습
|
||||||
|
bash scripts/train_and_deploy.sh --symbol TRXUSDT
|
||||||
|
|
||||||
|
# 전체 심볼 학습
|
||||||
|
bash scripts/train_and_deploy.sh --all
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs: update architecture and commands for multi-symbol trading"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 순서 요약
|
||||||
|
|
||||||
|
| Task | 내용 | 의존성 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 1 | Config: `symbols`, `correlation_symbols`, `max_same_direction` | 없음 |
|
||||||
|
| 2 | RiskManager: 공유 싱글턴, async Lock, 동일 방향 제한 | Task 1 |
|
||||||
|
| 3 | Exchange: `self.symbol` 분리 | 없음 (Task 1과 병렬 가능) |
|
||||||
|
| 4 | TradingBot: `symbol`, `risk` 주입, `config.symbol` 제거 | Task 1, 2, 3 |
|
||||||
|
| 5 | main.py: 심볼별 봇 생성 + gather | Task 4 |
|
||||||
|
| 6 | MLFilter: 심볼별 모델 디렉토리 폴백 | Task 4 |
|
||||||
|
| 7 | 학습 스크립트: `--symbol` / `--all` CLI | Task 1 |
|
||||||
|
| 8 | 디렉토리 구조 + .env.example | 없음 |
|
||||||
|
| 9 | 문서 업데이트 | 전체 완료 후 |
|
||||||
|
|
||||||
|
각 태스크 완료 후 기존 XRP 단일 모드에서 전체 테스트를 통과해야 한다.
|
||||||
169
docs/plans/2026-03-06-multi-symbol-dashboard-design.md
Normal file
169
docs/plans/2026-03-06-multi-symbol-dashboard-design.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Multi-Symbol Dashboard Design
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
멀티심볼 트레이딩(XRP, TRX, DOGE) 지원 이후 대시보드가 단일 심볼 기준으로 되어있어 수정 필요. 봇 로그 형식 통일, 파서/DB/API/UI 전체 레이어 변경.
|
||||||
|
|
||||||
|
## 접근 방식
|
||||||
|
|
||||||
|
**A안 채택**: 기존 단일 DB에 `symbol` 컬럼 추가. 대시보드 DB는 로그 파싱으로 재생성 가능하므로 초기화 비용 없음.
|
||||||
|
|
||||||
|
## 1. 봇 로그 수정
|
||||||
|
|
||||||
|
모든 핵심 로그에 `[SYMBOL]` 프리픽스를 일관되게 추가.
|
||||||
|
|
||||||
|
변경 대상 (`src/bot.py`):
|
||||||
|
- `신호: {signal} | 현재가:` → `[{self.symbol}] 신호: ...`
|
||||||
|
- `{signal} 진입: 가격=` → `[{self.symbol}] {signal} 진입: ...`
|
||||||
|
- `기존 포지션 복구:` → `[{self.symbol}] 기존 포지션 복구: ...`
|
||||||
|
- `기준 잔고 설정:` → `[{self.symbol}] 기준 잔고 설정: ...`
|
||||||
|
- `포지션 청산(...)` → `[{self.symbol}] 포지션 청산(...)`
|
||||||
|
- `OI=..., OI변화율=...` → `[{self.symbol}] OI=...` (debug→info로 변경 또는 그대로 debug 유지)
|
||||||
|
|
||||||
|
변경 대상 (`src/user_data_stream.py`):
|
||||||
|
- `청산 감지({reason}):` → `[{self.symbol}] 청산 감지({reason}): ...`
|
||||||
|
|
||||||
|
이미 `[{self.symbol}]`이 있는 로그는 그대로 유지.
|
||||||
|
|
||||||
|
## 2. Log Parser (`log_parser.py`)
|
||||||
|
|
||||||
|
### 정규식 변경
|
||||||
|
|
||||||
|
모든 패턴에 `\[(?P<symbol>\w+)\]` 프리픽스 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"signal": re.compile(
|
||||||
|
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||||
|
r".*\[(?P<symbol>\w+)\] 신호: (?P<signal>\w+) \| 현재가: (?P<price>[\d.]+) USDT"
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
### 상태 추적 멀티심볼 대응
|
||||||
|
|
||||||
|
- `_current_position: dict` → `_current_positions: dict[str, dict]` (심볼별)
|
||||||
|
- `_pending_candle: dict` → `_pending_candles: dict[str, dict[str, dict]]` (심볼별 타임스탬프별)
|
||||||
|
- `_bot_config["symbol"]` 제거, 정규식에서 심볼 직접 파싱
|
||||||
|
|
||||||
|
### 핸들러 변경
|
||||||
|
|
||||||
|
**`_handle_entry`**: symbol을 정규식에서 직접 받음. 중복 체크를 `symbol+direction` 기준으로.
|
||||||
|
|
||||||
|
**`_handle_close`**: `WHERE status='OPEN' AND symbol=?`로 해당 심볼만 닫음.
|
||||||
|
|
||||||
|
### bot_status 키 형식
|
||||||
|
|
||||||
|
- 심볼별: `{symbol}:current_price`, `{symbol}:position_status`, `{symbol}:current_signal` 등
|
||||||
|
- 전역: `balance`, `ml_threshold` 그대로
|
||||||
|
|
||||||
|
## 3. DB 스키마 변경
|
||||||
|
|
||||||
|
### candles 테이블
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE candles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
price REAL NOT NULL,
|
||||||
|
signal TEXT,
|
||||||
|
adx REAL,
|
||||||
|
oi REAL,
|
||||||
|
oi_change REAL,
|
||||||
|
funding_rate REAL,
|
||||||
|
UNIQUE(symbol, ts)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_candles_symbol_ts ON candles(symbol, ts);
|
||||||
|
```
|
||||||
|
|
||||||
|
### daily_pnl 테이블
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE daily_pnl (
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
cumulative_pnl REAL DEFAULT 0,
|
||||||
|
trade_count INTEGER DEFAULT 0,
|
||||||
|
wins INTEGER DEFAULT 0,
|
||||||
|
losses INTEGER DEFAULT 0,
|
||||||
|
last_updated TEXT,
|
||||||
|
PRIMARY KEY(symbol, date)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### trades 테이블
|
||||||
|
|
||||||
|
기존 `symbol` 컬럼 있음. `DEFAULT 'XRPUSDT'` 제거, 파서에서 항상 명시적으로 심볼 전달.
|
||||||
|
|
||||||
|
### bot_status 테이블
|
||||||
|
|
||||||
|
스키마 변경 없음. 키 네이밍만 `{symbol}:{key}` 형태로 변경.
|
||||||
|
|
||||||
|
### 마이그레이션
|
||||||
|
|
||||||
|
`_init_db()`에서 `DROP TABLE IF EXISTS` → 재생성. 기존 데이터는 로그 재파싱으로 복구.
|
||||||
|
|
||||||
|
## 4. API (`dashboard_api.py`)
|
||||||
|
|
||||||
|
모든 엔드포인트에 `symbol` 쿼리 파라미터 추가. 없으면 전체.
|
||||||
|
|
||||||
|
### 변경 엔드포인트
|
||||||
|
|
||||||
|
| 엔드포인트 | 변경 |
|
||||||
|
|-----------|------|
|
||||||
|
| `GET /api/position` | 심볼별 OPEN 포지션 목록 반환. `{"positions": [...], "bot": {...}}` |
|
||||||
|
| `GET /api/trades` | `?symbol=` 필터 추가 |
|
||||||
|
| `GET /api/stats` | `?symbol=` 필터 추가 |
|
||||||
|
| `GET /api/daily` | `?symbol=` 필터 추가 |
|
||||||
|
| `GET /api/candles` | `?symbol=` 필수 파라미터 |
|
||||||
|
|
||||||
|
### 새 엔드포인트
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/symbols → {"symbols": ["XRPUSDT", "TRXUSDT", "DOGEUSDT"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`bot_status`에서 `{symbol}:last_start` 키가 있는 심볼 목록 반환.
|
||||||
|
|
||||||
|
## 5. UI (`App.jsx`)
|
||||||
|
|
||||||
|
### 헤더
|
||||||
|
|
||||||
|
- "XRP/USDT" 하드코딩 제거 → `Live · 3 symbols`
|
||||||
|
- 오픈 포지션 카드를 심볼별 복수 표시 (가로 나열)
|
||||||
|
|
||||||
|
### 심볼 필터 탭
|
||||||
|
|
||||||
|
기존 탭(Overview/Trades/Chart) 위에 심볼 필터 추가: `ALL | XRP | TRX | DOGE`
|
||||||
|
- `/api/symbols`에서 동적 생성
|
||||||
|
- `ALL`: 전체 합산, 개별 심볼: 해당 심볼만
|
||||||
|
|
||||||
|
### Overview 탭
|
||||||
|
|
||||||
|
- `ALL`: 전체 합산 StatCard + 일별 PnL + 최근 거래(심볼 뱃지 표시)
|
||||||
|
- 개별 심볼: 해당 심볼만
|
||||||
|
|
||||||
|
### Trades 탭
|
||||||
|
|
||||||
|
- 선택된 심볼로 필터링
|
||||||
|
|
||||||
|
### Chart 탭
|
||||||
|
|
||||||
|
- `ALL` 선택 시 첫 번째 심볼 자동 선택 (캔들은 심볼별)
|
||||||
|
- 차트 제목 동적: `{SYMBOL}/USDT 15m 가격`
|
||||||
|
|
||||||
|
### 데이터 페칭
|
||||||
|
|
||||||
|
- `fetchAll`에서 선택된 심볼을 쿼리 파라미터로 전달
|
||||||
|
- 심볼 변경 시 즉시 리페치
|
||||||
|
|
||||||
|
## 6. 변경 범위 요약
|
||||||
|
|
||||||
|
| 레이어 | 파일 | 변경 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 봇 | `src/bot.py` | 로그에 `[SYMBOL]` 프리픽스 추가 |
|
||||||
|
| 봇 | `src/user_data_stream.py` | 청산 로그에 `[SYMBOL]` 프리픽스 추가 |
|
||||||
|
| 파서 | `dashboard/api/log_parser.py` | 정규식, 상태 추적, 핸들러 멀티심볼 대응 |
|
||||||
|
| API | `dashboard/api/dashboard_api.py` | `symbol` 파라미터, `/api/symbols` |
|
||||||
|
| UI | `dashboard/ui/src/App.jsx` | 심볼 필터 탭, 복수 포지션, 동적 헤더 |
|
||||||
|
|
||||||
|
봇 이미지와 대시보드 이미지 모두 재빌드 필요.
|
||||||
1259
docs/plans/2026-03-06-multi-symbol-dashboard-plan.md
Normal file
1259
docs/plans/2026-03-06-multi-symbol-dashboard-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
85
docs/plans/2026-03-06-strategy-parameter-sweep-plan.md
Normal file
85
docs/plans/2026-03-06-strategy-parameter-sweep-plan.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# 전략 파라미터 스윕 계획
|
||||||
|
|
||||||
|
**날짜**: 2026-03-06
|
||||||
|
**상태**: 완료
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
Walk-Forward 백테스트를 활용하여 기본 기술 지표 전략(ML OFF)의 수익성 높은 파라미터 조합을 탐색하고, PF >= 1.0을 ML 재설계의 기반으로 확보한다.
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
Walk-Forward 백테스트 결과 현재 XRP 전략이 비수익적(PF 0.71, -641 PnL)으로 확인되었다. 전략 파라미터 스윕은 5개 파라미터의 324개 조합을 체계적으로 테스트하여 수익 구간을 탐색한다.
|
||||||
|
|
||||||
|
## 스윕 파라미터
|
||||||
|
|
||||||
|
|
||||||
|
| 파라미터 | 값 | 설명 |
|
||||||
|
| ------------------- | ------------- | ------------------------------------------- |
|
||||||
|
| `atr_sl_mult` | 1.0, 1.5, 2.0 | 손절 ATR 배수 |
|
||||||
|
| `atr_tp_mult` | 2.0, 3.0, 4.0 | 익절 ATR 배수 |
|
||||||
|
| `signal_threshold` | 3, 4, 5 | 진입을 위한 최소 가중치 지표 점수 |
|
||||||
|
| `adx_threshold` | 0, 20, 25, 30 | ADX 필터 (0=비활성, N=ADX>=N 필요) |
|
||||||
|
| `volume_multiplier` | 1.5, 2.0, 2.5 | 거래량 급증 감지 배수 |
|
||||||
|
|
||||||
|
|
||||||
|
총 조합: 3 x 3 x 3 x 4 x 3 = **324**
|
||||||
|
|
||||||
|
## 구현
|
||||||
|
|
||||||
|
### 수정된 파일
|
||||||
|
|
||||||
|
- `src/indicators.py` — `get_signal()`에 `signal_threshold`, `adx_threshold`, `volume_multiplier` 파라미터 추가
|
||||||
|
- `src/dataset_builder.py` — `_calc_signals()`에 동일 파라미터를 받아 벡터화 계산에 적용
|
||||||
|
- `src/backtester.py` — `BacktestConfig`에 전략 파라미터 포함; `WalkForwardBacktester`가 테스트 폴드에 전파
|
||||||
|
|
||||||
|
### 신규 생성 파일
|
||||||
|
|
||||||
|
- `scripts/strategy_sweep.py` — 파라미터 그리드 스윕 CLI 도구
|
||||||
|
|
||||||
|
### 버그 수정
|
||||||
|
|
||||||
|
- `WalkForwardBacktester`가 `signal_threshold`, `adx_threshold`, `volume_multiplier`, `use_ml`을 폴드 `BacktestConfig`에 전달하지 않는 버그 수정. 모든 신호 파라미터가 기본값으로 적용되어 ADX/거래량/임계값 스윕이 효과 없이 실행되고 있었음.
|
||||||
|
|
||||||
|
## 결과 (XRPUSDT, Walk-Forward 3/1)
|
||||||
|
|
||||||
|
### 상위 10개 조합
|
||||||
|
|
||||||
|
|
||||||
|
| 순위 | SL×ATR | TP×ATR | 신호 | ADX | 거래량 | 거래 수 | 승률 | PF | MDD | PnL | 샤프 |
|
||||||
|
| ---- | ------ | ------ | ---- | --- | ------ | ------- | ------- | ---- | ----- | ---- | ---- |
|
||||||
|
| 1 | 1.5 | 4.0 | 3 | 30 | 2.5 | 19 | 52.6% | 2.39 | 7.0% | +469 | 61.0 |
|
||||||
|
| 2 | 1.5 | 2.0 | 3 | 30 | 2.5 | 19 | 68.4% | 2.23 | 6.5% | +282 | 61.2 |
|
||||||
|
| 3 | 1.0 | 2.0 | 3 | 30 | 2.5 | 19 | 57.9% | 1.98 | 5.0% | +213 | 50.8 |
|
||||||
|
| 4 | 1.0 | 4.0 | 3 | 30 | 2.5 | 19 | 36.8% | 1.80 | 7.7% | +248 | 37.1 |
|
||||||
|
| 5 | 1.5 | 3.0 | 3 | 30 | 2.5 | 19 | 52.6% | 1.76 | 10.1% | +258 | 40.9 |
|
||||||
|
| 6 | 1.5 | 4.0 | 3 | 25 | 2.5 | 28 | 42.9% | 1.75 | 13.1% | +381 | 36.8 |
|
||||||
|
| 7 | 2.0 | 4.0 | 3 | 30 | 1.5 | 39 | 48.7% | 1.67 | 16.9% | +572 | 35.3 |
|
||||||
|
| 8 | 1.0 | 2.0 | 3 | 25 | 2.5 | 28 | 50.0% | 1.64 | 5.8% | +205 | 35.7 |
|
||||||
|
| 9 | 1.5 | 2.0 | 3 | 25 | 2.5 | 28 | 57.1% | 1.62 | 10.3% | +229 | 35.7 |
|
||||||
|
| 10 | 2.0 | 2.0 | 3 | 25 | 2.5 | 27 | 66.7% | 1.57 | 12.0% | +217 | 33.3 |
|
||||||
|
|
||||||
|
|
||||||
|
### 현재 프로덕션 (324개 중 93위)
|
||||||
|
|
||||||
|
|
||||||
|
| SL×ATR | TP×ATR | 신호 | ADX | 거래량 | 거래 수 | 승률 | PF | MDD | PnL |
|
||||||
|
| ------ | ------ | ---- | --- | ------ | ------- | ------- | ---- | ----- | ---- |
|
||||||
|
| 1.5 | 3.0 | 3 | 0 | 1.5 | 118 | 30.5% | 0.71 | 65.9% | -641 |
|
||||||
|
|
||||||
|
|
||||||
|
### 핵심 발견 사항
|
||||||
|
|
||||||
|
1. **ADX 필터가 가장 영향력 있는 단일 파라미터.** 상위 10개 결과 모두 ADX >= 25를 사용하며, 상위 5개는 ADX=30이 지배적. 횡보/박스권 시장에서 노이즈 신호를 필터링한다.
|
||||||
|
2. **거래량 배수 2.5가 지배적.** 높은 거래량 임계값은 진정한 돌파에서만 진입을 보장한다 (노이즈 대비 실질 돌파).
|
||||||
|
3. **신호 임계값 3이 최적.** 더 높은 임계값(4, 5)은 대부분의 ADX 필터링 구간에서 거래가 너무 적거나 0건이었다.
|
||||||
|
4. **SL/TP 비율보다 진입 필터가 더 중요.** 상위 결과는 모든 SL/TP 조합에 걸쳐 있지만, 모두 ADX=25-30 + Vol=2.5를 공유한다.
|
||||||
|
5. **필터 적용 시 거래 수가 크게 감소.** 상위 조합은 19-39건 vs 현재 118건. 적지만 높은 품질의 진입.
|
||||||
|
6. **324개 중 41개 조합이 PF >= 1.0 달성** (12.7%).
|
||||||
|
|
||||||
|
## 권장 다음 단계
|
||||||
|
|
||||||
|
1. **프로덕션 기본값 업데이트**: ADX=25, volume_multiplier=2.0을 보수적 선택으로 적용 (ADX=30보다 더 많은 거래 확보)
|
||||||
|
2. **TRXUSDT, DOGEUSDT에서 검증**: ADX 필터가 XRP에만 특화된 것이 아닌지 확인
|
||||||
|
3. **ML 모델 재학습**: 업데이트된 전략 파라미터로 — ML 필터가 수익성 있는 기반 위에서 개선 가능
|
||||||
|
4. **수익 구간 주변 세밀 스윕**: ADX [25-35], Vol [2.0-3.0]
|
||||||
146
docs/plans/2026-03-07-code-review-improvements.md
Normal file
146
docs/plans/2026-03-07-code-review-improvements.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# 코드 리뷰 개선 사항
|
||||||
|
|
||||||
|
**날짜**: 2026-03-07
|
||||||
|
**상태**: 부분 완료 (#1/#2/#4/#5/#6/#8 완료, #9 보류, #3/#7/#10~13 다음 스프린트)
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
전체 코드베이스 리뷰에서 발견된 버그, 엣지 케이스, 로직 오류를 우선순위별로 정리하고 수정한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical (즉시 수정 필요)
|
||||||
|
|
||||||
|
### 1. OI 변화율 계산 시 Division by Zero
|
||||||
|
|
||||||
|
**파일**: `src/bot.py:120`
|
||||||
|
|
||||||
|
`_prev_oi`가 0.0일 때 `(current_oi - self._prev_oi) / self._prev_oi`에서 ZeroDivisionError 발생. `get_open_interest()` 실패 시 0.0을 반환하므로 실제로 발생 가능.
|
||||||
|
|
||||||
|
**수정**: `_prev_oi == 0.0`이면 `oi_change = 0.0`으로 처리.
|
||||||
|
|
||||||
|
### 2. 누적 트레이드 수 계산 로직 오류
|
||||||
|
|
||||||
|
**파일**: `scripts/weekly_report.py:415-423`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 현재 (잘못됨) — max()로 비교하여 누적이 아닌 최대값만 가져옴
|
||||||
|
cumulative = live_count
|
||||||
|
for rpath in sorted(rdir.glob("report_*.json")):
|
||||||
|
cumulative = max(cumulative, prev.get("live_trades", {}).get("count", 0))
|
||||||
|
```
|
||||||
|
|
||||||
|
ML 재학습 트리거 조건(`≥ 150건`)이 제대로 작동하지 않음.
|
||||||
|
|
||||||
|
**수정**: 이전 리포트의 `live_trades.count`를 합산하도록 변경.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Important (이번 주 수정 권장)
|
||||||
|
|
||||||
|
### 3. Training-Serving Skew (OI/펀딩비 피처)
|
||||||
|
|
||||||
|
**파일**: `src/dataset_builder.py` vs `src/ml_features.py`
|
||||||
|
|
||||||
|
- 학습 시: OI=0 구간을 NaN으로 마스킹 후 z-score
|
||||||
|
- 서빙 시: OI 값을 그대로 NaN으로 설정
|
||||||
|
|
||||||
|
ML 활성화 시 학습/서빙 간 피처 분포 불일치 발생. 현재 ML OFF이므로 당장은 영향 없지만, ML 재활성화 전 반드시 수정 필요.
|
||||||
|
|
||||||
|
### 4. `fetch_history.py` — API 실패/Rate Limit 미처리
|
||||||
|
|
||||||
|
**파일**: `scripts/fetch_history.py:46-61`
|
||||||
|
|
||||||
|
`futures_klines()` 호출에 retry 로직이 없음. Rate limit(429) 발생 시 예외로 크래시. `weekly_report.py`의 subprocess가 무한 대기할 수 있음.
|
||||||
|
|
||||||
|
**수정**: `tenacity` 또는 수동 retry 로직 추가 (최대 3회, exponential backoff).
|
||||||
|
|
||||||
|
### 5. Parquet Upsert 시 중복 타임스탬프 미제거
|
||||||
|
|
||||||
|
**파일**: `scripts/fetch_history.py:314`
|
||||||
|
|
||||||
|
`sort_index()`만 하고 `drop_duplicates()`를 하지 않음. API 응답에 중복 타임스탬프가 있으면 지표 계산이 이중 계산됨.
|
||||||
|
|
||||||
|
**수정**: `sort_index()` 앞에 `df[~df.index.duplicated(keep='last')]` 추가.
|
||||||
|
|
||||||
|
### 6. `record_pnl()`에 asyncio.Lock 미사용
|
||||||
|
|
||||||
|
**파일**: `src/risk_manager.py:55`
|
||||||
|
|
||||||
|
`record_pnl()`이 `self.daily_pnl`을 수정하지만 `async with self._lock`을 사용하지 않음. 멀티심볼 환경에서 동시 호출 시 일일 손실 한도 체크가 부정확할 수 있음.
|
||||||
|
|
||||||
|
**수정**: `record_pnl()`을 async로 변경하고 `async with self._lock:` 추가.
|
||||||
|
|
||||||
|
### 7. 백테스터 Equity Curve 미구현
|
||||||
|
|
||||||
|
**파일**: `src/backtester.py:509-510`
|
||||||
|
|
||||||
|
`_record_equity()`가 `pass`로 비어 있음. MDD 계산이 실현 PnL 기준이지 포트폴리오 가치(미실현 PnL 포함) 기준이 아님. MDD가 과소평가될 수 있음.
|
||||||
|
|
||||||
|
**수정**: 미실현 PnL을 포함한 equity 계산 구현.
|
||||||
|
|
||||||
|
### 8. User Data Stream — exit_price 기본값 0.0
|
||||||
|
|
||||||
|
**파일**: `src/user_data_stream.py:95`
|
||||||
|
|
||||||
|
`order.get("ap", "0")`에서 필드 누락 시 exit_price=0.0으로 설정되어 PnL이 완전히 잘못 계산됨.
|
||||||
|
|
||||||
|
**수정**: `exit_price == 0.0`이면 청산 처리를 스킵하고 WARNING 로그 출력.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Minor (다음 스프린트)
|
||||||
|
|
||||||
|
### 9. 거래량 급증 진입 조건 의도 불일치
|
||||||
|
|
||||||
|
**파일**: `src/indicators.py:115-118`
|
||||||
|
|
||||||
|
`(vol_surge or long_signals >= signal_threshold + 1)` — 거래량 급증만으로도 진입 허용됨. "강한 신호 + 거래량 급증"이 의도라면 AND 조건이어야 하는데, 현재 OR로 구현됨. 현재 전략 파라미터 스윕 결과(ADX=25, Vol=2.5)에서는 큰 문제 없으나, 의도를 확인하고 정리 필요.
|
||||||
|
|
||||||
|
### 10. ML 모델 피처 불일치 시 Silent Failure
|
||||||
|
|
||||||
|
**파일**: `src/ml_filter.py:152`
|
||||||
|
|
||||||
|
ONNX 모델과 현재 FEATURE_COLS가 다르면 예외를 잡고 `False`를 반환(모든 신호 차단). 사용자에게 원인이 보이지 않아 디버깅이 어려움.
|
||||||
|
|
||||||
|
**수정**: 피처 수 불일치는 WARNING이 아닌 ERROR로 로깅하고, 최초 발생 시 Discord 알림 전송.
|
||||||
|
|
||||||
|
### 11. `train_model.py` — 빈 데이터셋 미처리
|
||||||
|
|
||||||
|
**파일**: `scripts/train_model.py:196`
|
||||||
|
|
||||||
|
`generate_dataset_vectorized()`가 빈 DataFrame을 반환하면 Walk-Forward 검증에서 step=0이 되어 무한 루프 가능.
|
||||||
|
|
||||||
|
**수정**: 빈 데이터셋 시 `ValueError("No samples generated")` raise.
|
||||||
|
|
||||||
|
### 12. `data_stream.py` — AsyncClient 생성 실패 시 전체 크래시
|
||||||
|
|
||||||
|
**파일**: `src/data_stream.py:79-82`
|
||||||
|
|
||||||
|
네트워크 단절 상태에서 봇 시작 시 `AsyncClient.create()` 실패로 모든 심볼이 함께 크래시.
|
||||||
|
|
||||||
|
**수정**: retry with exponential backoff (최대 5회) 추가.
|
||||||
|
|
||||||
|
### 13. `fetch_history.py` — Parquet 타임존 처리 불일치
|
||||||
|
|
||||||
|
**파일**: `scripts/fetch_history.py:286-289`
|
||||||
|
|
||||||
|
`tz_localize("UTC")` 호출 시 기존 데이터가 실제로 UTC인지 검증하지 않음. 타임존이 다른 데이터가 섞이면 OI/펀딩비 병합이 시간축으로 어긋남.
|
||||||
|
|
||||||
|
**수정**: `tz_localize(tz='UTC', ambiguous='raise', nonexistent='raise')` 사용.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 수정 우선순위
|
||||||
|
|
||||||
|
| 우선순위 | 이슈 | 난이도 | 영향도 |
|
||||||
|
|---------|------|--------|--------|
|
||||||
|
| 즉시 | #1 OI division by zero | 5분 | 봇 크래시 |
|
||||||
|
| 즉시 | #2 누적 트레이드 계산 | 5분 | ML 트리거 오작동 |
|
||||||
|
| 이번주 | #4 fetch_history retry | 30분 | 데이터 수집 행 |
|
||||||
|
| 이번주 | #5 Parquet 중복 제거 | 5분 | 지표 이중 계산 |
|
||||||
|
| 이번주 | #6 record_pnl Lock | 5분 | 리스크 한도 부정확 |
|
||||||
|
| 이번주 | #8 exit_price=0 방어 | 10분 | PnL 오계산 |
|
||||||
|
| ML 재활성화 전 | #3 Training-Serving skew | 30분 | 예측 품질 저하 |
|
||||||
|
| 다음 스프린트 | #7 Equity curve 구현 | 1시간 | MDD 과소평가 |
|
||||||
|
| 다음 스프린트 | #9-13 기타 | 각 10-30분 | 안정성 개선 |
|
||||||
1071
docs/plans/2026-03-07-weekly-report-plan.md
Normal file
1071
docs/plans/2026-03-07-weekly-report-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
53
docs/plans/2026-03-19-critical-bugfixes.md
Normal file
53
docs/plans/2026-03-19-critical-bugfixes.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Critical Bugfixes Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Fix 4 critical bugs identified in code review (C5, C1, C3, C8)
|
||||||
|
|
||||||
|
**Architecture:** Direct fixes to backtester.py, bot.py, main.py — no new files needed
|
||||||
|
|
||||||
|
**Tech Stack:** Python asyncio, signal handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: C5 — Backtester double fee deduction + atr≤0 fee leak
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/backtester.py:494-501`
|
||||||
|
|
||||||
|
- [x] Remove `self.balance -= entry_fee` at L496. The fee is already deducted in `_close_position` via `net_pnl = gross_pnl - entry_fee - exit_fee`.
|
||||||
|
- [x] This also fixes the atr≤0 early return bug — since balance is no longer modified before ATR check, early return doesn't leak fees.
|
||||||
|
|
||||||
|
## Task 2: C1 — SL/TP atomicity with retry and emergency close
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py:461-475`
|
||||||
|
|
||||||
|
- [x] Wrap SL/TP placement in `_place_sl_tp_with_retry()` with 3 retries and 1s backoff
|
||||||
|
- [x] Track `sl_placed` and `tp_placed` independently to avoid re-placing successful orders
|
||||||
|
- [x] On final failure, call `_emergency_close()` which market-closes the position and notifies via Discord
|
||||||
|
- [x] `_emergency_close` also handles its own failure with critical log + Discord alert
|
||||||
|
|
||||||
|
## Task 3: C3 — PnL double recording race condition
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py` (init, _on_position_closed, _position_monitor)
|
||||||
|
|
||||||
|
- [x] Add `self._close_lock = asyncio.Lock()` to `__init__`
|
||||||
|
- [x] Wrap `_on_position_closed` body with `async with self._close_lock`
|
||||||
|
- [x] Wrap SYNC path in `_position_monitor` with `async with self._close_lock`
|
||||||
|
- [x] Add double-check after lock acquisition in monitor (callback may have already processed)
|
||||||
|
|
||||||
|
## Task 4: C8 — Graceful shutdown with signal handler
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `main.py`
|
||||||
|
|
||||||
|
- [x] Add `signal.SIGTERM` and `signal.SIGINT` handlers via `loop.add_signal_handler()`
|
||||||
|
- [x] Use `asyncio.Event` + `asyncio.wait(FIRST_COMPLETED)` pattern
|
||||||
|
- [x] `_graceful_shutdown()`: cancel all open orders per bot (with 5s timeout), then cancel tasks
|
||||||
|
- [x] Log shutdown progress for each symbol
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- [x] All 138 existing tests pass (0 failures)
|
||||||
108
docs/plans/2026-03-21-code-review-fixes-r2.md
Normal file
108
docs/plans/2026-03-21-code-review-fixes-r2.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Code Review Fixes Round 2 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Fix 9 issues from code review re-evaluation (2 Critical, 3 Important, 4 Minor)
|
||||||
|
|
||||||
|
**Architecture:** Targeted fixes across risk_manager, exchange, bot, config, ml_filter. No new files — all modifications to existing modules.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, asyncio, python-binance, LightGBM, ONNX Runtime
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: #2 Critical — Balance reservation lock for concurrent entry
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/risk_manager.py` — add `_entry_lock` to serialize entry flow
|
||||||
|
- Modify: `src/bot.py:405-413` — acquire entry lock around balance read → order
|
||||||
|
- Test: `tests/test_risk_manager.py`
|
||||||
|
|
||||||
|
The simplest fix: add an asyncio.Lock in RiskManager that serializes the entire _open_position flow across all bots. This prevents two bots from reading the same balance simultaneously.
|
||||||
|
|
||||||
|
- [ ] Add `_entry_lock = asyncio.Lock()` to RiskManager
|
||||||
|
- [ ] Add `async def entry_lock(self)` context manager
|
||||||
|
- [ ] In bot.py `_open_position`, wrap balance read + order under `async with self.risk.entry_lock()`
|
||||||
|
- [ ] Add test for concurrent entry serialization
|
||||||
|
- [ ] Run tests
|
||||||
|
|
||||||
|
### Task 2: #3 Critical — SYNC PnL startTime + single query
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/exchange.py:166-185` — add `start_time` param to `get_recent_income`
|
||||||
|
- Modify: `src/bot.py:75-82` — record `_entry_time` on position open
|
||||||
|
- Modify: `src/bot.py:620-629` — pass `start_time` to income query
|
||||||
|
- Test: `tests/test_exchange.py`
|
||||||
|
|
||||||
|
- [ ] Add `_entry_time: int | None = None` to TradingBot
|
||||||
|
- [ ] Set `_entry_time = int(time.time() * 1000)` on entry and recovery
|
||||||
|
- [ ] Add `start_time` parameter to `get_recent_income()`
|
||||||
|
- [ ] Use start_time in SYNC fallback
|
||||||
|
- [ ] Add test
|
||||||
|
- [ ] Run tests
|
||||||
|
|
||||||
|
### Task 3: #1 Important — Thread-safe Client access
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/exchange.py` — add `threading.Lock` per instance
|
||||||
|
|
||||||
|
- [ ] Add `self._api_lock = threading.Lock()` in `__init__`
|
||||||
|
- [ ] Wrap all `run_in_executor` lambdas with lock acquisition
|
||||||
|
- [ ] Add test
|
||||||
|
- [ ] Run tests
|
||||||
|
|
||||||
|
### Task 4: #4 Important — reset_daily async with lock
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/risk_manager.py:61-64` — make async + lock
|
||||||
|
- Modify: `main.py:22` — await reset_daily
|
||||||
|
- Test: `tests/test_risk_manager.py`
|
||||||
|
|
||||||
|
- [ ] Convert `reset_daily` to async, add lock
|
||||||
|
- [ ] Update `_daily_reset_loop` call
|
||||||
|
- [ ] Add test
|
||||||
|
- [ ] Run tests
|
||||||
|
|
||||||
|
### Task 5: #8 Important — exchange_info cache TTL
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/exchange.py:25-34` — add TTL (24h)
|
||||||
|
|
||||||
|
- [ ] Add `_exchange_info_time: float = 0.0`
|
||||||
|
- [ ] Check TTL in `_get_exchange_info`
|
||||||
|
- [ ] Add test
|
||||||
|
- [ ] Run tests
|
||||||
|
|
||||||
|
### Task 6: #7 Minor — Pass pre-computed indicators to _open_position
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py:392,415,736` — pass df_with_indicators
|
||||||
|
|
||||||
|
- [ ] Add `df_with_indicators` parameter to `_open_position`
|
||||||
|
- [ ] Use passed df instead of re-creating Indicators
|
||||||
|
- [ ] Run tests
|
||||||
|
|
||||||
|
### Task 7: #11 Minor — Config input validation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/config.py:39` — add range checks
|
||||||
|
- Test: `tests/test_config.py`
|
||||||
|
|
||||||
|
- [ ] Add validation for LEVERAGE, MARGIN ratios, ML_THRESHOLD
|
||||||
|
- [ ] Add test for invalid values
|
||||||
|
- [ ] Run tests
|
||||||
|
|
||||||
|
### Task 8: #12 Minor — Dynamic correlation symbol access
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py:196-198` — iterate dynamically
|
||||||
|
|
||||||
|
- [ ] Replace hardcoded [0]/[1] with dict-based access
|
||||||
|
- [ ] Run tests
|
||||||
|
|
||||||
|
### Task 9: #14 Minor — Normalize NaN handling for LightGBM
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ml_filter.py:144-147` — apply nan_to_num for LightGBM too
|
||||||
|
|
||||||
|
- [ ] Add `np.nan_to_num` to LightGBM path
|
||||||
|
- [ ] Run tests
|
||||||
66
docs/plans/2026-03-21-dashboard-code-review-r2.md
Normal file
66
docs/plans/2026-03-21-dashboard-code-review-r2.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Dashboard Code Review R2
|
||||||
|
|
||||||
|
**날짜**: 2026-03-21
|
||||||
|
**상태**: Completed
|
||||||
|
**커밋**: e362329
|
||||||
|
|
||||||
|
## 원본 리뷰 (23건) → 재평가 결과
|
||||||
|
|
||||||
|
원래 23건의 코드 리뷰 항목 중 15건이 과잉 지적으로 판단되어 삭제, 5건은 Low로 하향, 3건만 Medium 유지. 이후 내부망 전용 환경 감안하여 #3 Health 503도 Low로 하향.
|
||||||
|
|
||||||
|
## 삭제 (15건)
|
||||||
|
|
||||||
|
| # | 이슈 | 삭제 사유 |
|
||||||
|
|---|------|----------|
|
||||||
|
| 1 | SQL Injection — reset_db | 테이블명 하드코딩 리스트, 코드 명백 |
|
||||||
|
| 4 | CORS wildcard + CSRF | X-API-Key 커스텀 헤더가 preflight 강제, CSRF 벡터 없음 |
|
||||||
|
| 5 | get_symbols 쿼리 비효율 | bot_status 수십 건, 최적화 불필요 |
|
||||||
|
| 6 | Signal handler sys.exit() | _shutdown=True → commit → close → exit 순서 이미 방어적 |
|
||||||
|
| 7 | SIGHUP DB 미초기화 | reset_db API가 5개 테이블 DELETE 후 SIGHUP, 설계상 올바름 |
|
||||||
|
| 8 | stale trade 삭제 f-string | parameterized query 패턴, 안전 |
|
||||||
|
| 9 | 파싱 순서 의존성 | 각 패턴이 서로 다른 한국어 키워드, 충돌 불가 |
|
||||||
|
| 10 | PID 파일 경쟁 조건 | Docker 단일 인스턴스 |
|
||||||
|
| 12 | 인라인 스타일 | S 객체로 변수화, 이 규모에서 CSS framework은 오버엔지니어링 |
|
||||||
|
| 15 | fmtTime 라벨 충돌 | 96캔들=24시간, 날짜 겹칠 일 없음 |
|
||||||
|
| 16 | prompt()/confirm() | 관리자 전용 드문 기능, 네이티브 dialog 적절 |
|
||||||
|
| 17 | 함수형 dataKey | 차트 2개 기준선, 성능 영향 0 |
|
||||||
|
| 20 | API Dockerfile requirements 미분리 | 패키지 2개, requirements.txt 오버헤드만 추가 |
|
||||||
|
| 21 | Nginx resolver 하드코딩 | Docker-only 환경 |
|
||||||
|
| 23 | private 메서드 직접 테스트 | 파서 핵심 로직 검증, 자주 리팩토링될 구조 아님 |
|
||||||
|
|
||||||
|
## Low로 하향 (5건, 수정 미진행)
|
||||||
|
|
||||||
|
| # | 이슈 | 하향 사유 |
|
||||||
|
|---|------|----------|
|
||||||
|
| 2 | DB PRAGMA 반복 | SQLite connect()는 파일 open 수준, 15초 폴링에서 병목 아님 |
|
||||||
|
| 11 | App.jsx 모놀리식 | 737줄이나 컴포넌트 파일 내 잘 분리, 추가 기능 계획 없으면 YAGNI |
|
||||||
|
| 13 | 부분 API 실패 | 이전 값 유지가 전체 초기화보다 나은 동작, 15초 후 자동 복구 |
|
||||||
|
| 18 | pos.id undefined | _handle_entry 중복 체크로 발생 확률 극히 낮음 |
|
||||||
|
| 22 | 테스트 환경변수 오염 | dashboard_api import하는 테스트 파일 하나뿐 |
|
||||||
|
|
||||||
|
## Low로 하향 (내부망 감안)
|
||||||
|
|
||||||
|
| # | 이슈 | 하향 사유 |
|
||||||
|
|---|------|----------|
|
||||||
|
| 3 | Health 에러 시 200→503 | 내부망 전용, 로드밸런서 health check 시나리오 없음 |
|
||||||
|
|
||||||
|
## 수정 완료 (2건)
|
||||||
|
|
||||||
|
### #14 Trades 페이지네이션 (Medium)
|
||||||
|
|
||||||
|
**문제**: API가 offset 파라미터를 지원하는데 프론트엔드에서 항상 `limit=50&offset=0`만 호출. tradesTotal > 50이면 나머지를 볼 수 없음.
|
||||||
|
|
||||||
|
**수정** (`dashboard/ui/src/App.jsx`):
|
||||||
|
- `tradesPage` state 추가
|
||||||
|
- fetchAll API 호출에 `offset=${tradesPage * 50}` 반영
|
||||||
|
- `useCallback` dependency에 `tradesPage` 추가
|
||||||
|
- 심볼 변경 시 `setTradesPage(0)` 리셋
|
||||||
|
- Trades 탭 하단에 이전/다음 페이지네이션 컨트롤 추가 (범위 표시: `1–50 / 총건수`)
|
||||||
|
|
||||||
|
### #19 package-lock.json + npm ci (Medium)
|
||||||
|
|
||||||
|
**문제**: `dashboard/ui/Dockerfile`에서 `COPY package.json .` + `npm install`만 사용. package-lock.json이 존재하는데 활용하지 않아 빌드 재현성 미보장.
|
||||||
|
|
||||||
|
**수정** (`dashboard/ui/Dockerfile`):
|
||||||
|
- `COPY package.json package-lock.json .`
|
||||||
|
- `RUN npm ci`
|
||||||
686
docs/plans/2026-03-21-ml-pipeline-fixes.md
Normal file
686
docs/plans/2026-03-21-ml-pipeline-fixes.md
Normal file
@@ -0,0 +1,686 @@
|
|||||||
|
# ML Pipeline Fixes Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** ML 파이프라인의 학습-서빙 불일치(SL/TP 배수, 언더샘플링, 정규화)와 백테스트 정확도 이슈를 수정하여 모델 평가 체계와 실전 환경을 일치시킨다.
|
||||||
|
|
||||||
|
**Architecture:** `dataset_builder.py`의 하드코딩 SL/TP 상수를 파라미터화하고, 모든 호출부(train_model, train_mlx_model, tune_hyperparams, backtester)가 동일한 값을 주입하도록 변경. MLX 학습의 이중 정규화 제거. 백테스터의 에퀴티 커브에 미실현 PnL 반영. MLFilter에 factory method 추가.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, LightGBM, MLX, pandas, numpy, pytest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| 파일 | 변경 유형 | 역할 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `src/dataset_builder.py` | Modify | SL/TP 상수 → 파라미터화 |
|
||||||
|
| `src/ml_filter.py` | Modify | `from_model()` factory method 추가 |
|
||||||
|
| `src/mlx_filter.py` | Modify | fit()에 `normalize` 파라미터 추가 |
|
||||||
|
| `src/backtester.py` | Modify | 에퀴티 미실현 PnL, MLFilter factory, initial_balance |
|
||||||
|
| `src/backtest_validator.py` | Modify | initial_balance 하드코딩 제거 |
|
||||||
|
| `scripts/train_model.py` | Modify | 레거시 상수 제거, SL/TP 전달 |
|
||||||
|
| `scripts/train_mlx_model.py` | Modify | 이중 정규화 제거, stratified_undersample 적용 |
|
||||||
|
| `scripts/tune_hyperparams.py` | Modify | SL/TP 전달 |
|
||||||
|
| `tests/test_dataset_builder.py` | Modify | SL/TP 파라미터 테스트 추가 |
|
||||||
|
| `tests/test_ml_pipeline_fixes.py` | Create | 신규 수정사항 전용 테스트 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: SL/TP 배수 파라미터화 — dataset_builder.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/dataset_builder.py:14-16, 322-383, 385-494`
|
||||||
|
- Test: `tests/test_dataset_builder.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 기존 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh -k "dataset_builder"`
|
||||||
|
Expected: 모든 테스트 PASS
|
||||||
|
|
||||||
|
- [ ] **Step 2: 파라미터화 테스트 작성**
|
||||||
|
|
||||||
|
`tests/test_ml_pipeline_fixes.py`에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
from src.dataset_builder import generate_dataset_vectorized, _calc_labels_vectorized
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def signal_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
|
||||||
|
return pd.DataFrame({
|
||||||
|
"open": close, "high": high, "low": low,
|
||||||
|
"close": close, "volume": volume,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_sltp_params_are_passed_through(signal_df):
|
||||||
|
"""SL/TP 배수가 generate_dataset_vectorized에 전달되어야 한다."""
|
||||||
|
r1 = generate_dataset_vectorized(
|
||||||
|
signal_df, atr_sl_mult=1.5, atr_tp_mult=2.0,
|
||||||
|
adx_threshold=0, volume_multiplier=1.5,
|
||||||
|
)
|
||||||
|
r2 = generate_dataset_vectorized(
|
||||||
|
signal_df, atr_sl_mult=2.0, atr_tp_mult=2.0,
|
||||||
|
adx_threshold=0, volume_multiplier=1.5,
|
||||||
|
)
|
||||||
|
# SL이 다르면 레이블 분포가 달라져야 한다
|
||||||
|
if len(r1) > 0 and len(r2) > 0:
|
||||||
|
# 정확히 같은 분포일 확률은 매우 낮음
|
||||||
|
assert not (r1["label"].values == r2["label"].values).all() or len(r1) != len(r2), \
|
||||||
|
"SL 배수가 다르면 레이블이 달라져야 한다"
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_sltp_backward_compatible(signal_df):
|
||||||
|
"""SL/TP 파라미터 미지정 시 기존 기본값(1.5, 2.0)으로 동작해야 한다."""
|
||||||
|
r_default = generate_dataset_vectorized(
|
||||||
|
signal_df, adx_threshold=0, volume_multiplier=1.5,
|
||||||
|
)
|
||||||
|
r_explicit = generate_dataset_vectorized(
|
||||||
|
signal_df, atr_sl_mult=1.5, atr_tp_mult=2.0,
|
||||||
|
adx_threshold=0, volume_multiplier=1.5,
|
||||||
|
)
|
||||||
|
if len(r_default) > 0:
|
||||||
|
assert len(r_default) == len(r_explicit)
|
||||||
|
assert (r_default["label"].values == r_explicit["label"].values).all()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 테스트 실패 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py -v`
|
||||||
|
Expected: FAIL — `generate_dataset_vectorized() got an unexpected keyword argument 'atr_sl_mult'`
|
||||||
|
|
||||||
|
- [ ] **Step 4: dataset_builder.py 수정**
|
||||||
|
|
||||||
|
`src/dataset_builder.py` 변경:
|
||||||
|
|
||||||
|
1. 모듈 상수 `ATR_SL_MULT`, `ATR_TP_MULT`는 기본값으로 유지 (하위 호환)
|
||||||
|
2. `_calc_labels_vectorized`에 `atr_sl_mult`, `atr_tp_mult` 파라미터 추가
|
||||||
|
3. `generate_dataset_vectorized`에 `atr_sl_mult`, `atr_tp_mult` 파라미터 추가하여 `_calc_labels_vectorized`에 전달
|
||||||
|
|
||||||
|
```python
|
||||||
|
# _calc_labels_vectorized 시그니처 변경:
|
||||||
|
def _calc_labels_vectorized(
|
||||||
|
d: pd.DataFrame,
|
||||||
|
feat: pd.DataFrame,
|
||||||
|
sig_idx: np.ndarray,
|
||||||
|
atr_sl_mult: float = ATR_SL_MULT,
|
||||||
|
atr_tp_mult: float = ATR_TP_MULT,
|
||||||
|
) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
|
||||||
|
# 함수 본문 (lines 350-355) 변경:
|
||||||
|
# 변경 전:
|
||||||
|
# sl = entry - atr * ATR_SL_MULT
|
||||||
|
# tp = entry + atr * ATR_TP_MULT
|
||||||
|
# 변경 후:
|
||||||
|
if signal == "LONG":
|
||||||
|
sl = entry - atr * atr_sl_mult
|
||||||
|
tp = entry + atr * atr_tp_mult
|
||||||
|
else:
|
||||||
|
sl = entry + atr * atr_sl_mult
|
||||||
|
tp = entry - atr * atr_tp_mult
|
||||||
|
|
||||||
|
# generate_dataset_vectorized 시그니처 변경:
|
||||||
|
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,
|
||||||
|
signal_threshold: int = 3,
|
||||||
|
adx_threshold: float = 25,
|
||||||
|
volume_multiplier: float = 2.5,
|
||||||
|
atr_sl_mult: float = ATR_SL_MULT, # 추가
|
||||||
|
atr_tp_mult: float = ATR_TP_MULT, # 추가
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
|
||||||
|
# _calc_labels_vectorized 호출 시 전달:
|
||||||
|
# labels, valid_mask = _calc_labels_vectorized(
|
||||||
|
# d, feat_all, sig_idx,
|
||||||
|
# atr_sl_mult=atr_sl_mult, atr_tp_mult=atr_tp_mult,
|
||||||
|
# )
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py tests/test_dataset_builder.py -v`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/dataset_builder.py tests/test_ml_pipeline_fixes.py
|
||||||
|
git commit -m "feat(ml): parameterize SL/TP multipliers in dataset_builder"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 호출부 SL/TP 전달 — train_model, train_mlx_model, tune_hyperparams, backtester
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/train_model.py:57-58, 217-221, 358-362, 448-452`
|
||||||
|
- Modify: `scripts/train_mlx_model.py:61, 179`
|
||||||
|
- Modify: `scripts/tune_hyperparams.py:67`
|
||||||
|
- Modify: `src/backtester.py:739-746`
|
||||||
|
|
||||||
|
- [ ] **Step 1: train_model.py 수정**
|
||||||
|
|
||||||
|
1. 레거시 모듈 상수 `ATR_SL_MULT=1.5`, `ATR_TP_MULT=3.0` (line 57-58)을 삭제
|
||||||
|
2. `main()`의 argparse에 `--sl-mult` (기본 2.0), `--tp-mult` (기본 2.0) CLI 인자 추가
|
||||||
|
3. `train()`, `walk_forward_auc()`, `compare()` 함수에 `atr_sl_mult`, `atr_tp_mult` 파라미터 추가하여 `generate_dataset_vectorized`에 전달
|
||||||
|
|
||||||
|
```python
|
||||||
|
# argparse에 추가:
|
||||||
|
parser.add_argument("--sl-mult", type=float, default=2.0, help="SL ATR 배수 (기본 2.0)")
|
||||||
|
parser.add_argument("--tp-mult", type=float, default=2.0, help="TP ATR 배수 (기본 2.0)")
|
||||||
|
|
||||||
|
# train() 시그니처:
|
||||||
|
def train(data_path, time_weight_decay=2.0, tuned_params_path=None,
|
||||||
|
atr_sl_mult=2.0, atr_tp_mult=2.0):
|
||||||
|
|
||||||
|
# train() 내:
|
||||||
|
dataset = generate_dataset_vectorized(
|
||||||
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
|
time_weight_decay=time_weight_decay,
|
||||||
|
negative_ratio=5,
|
||||||
|
atr_sl_mult=atr_sl_mult,
|
||||||
|
atr_tp_mult=atr_tp_mult,
|
||||||
|
)
|
||||||
|
|
||||||
|
# main()에서 호출:
|
||||||
|
train(args.data, ..., atr_sl_mult=args.sl_mult, atr_tp_mult=args.tp_mult)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: train_mlx_model.py 수정**
|
||||||
|
|
||||||
|
동일하게 `--sl-mult`, `--tp-mult` CLI 인자 추가. `train_mlx()`, `walk_forward_auc()` 함수에 파라미터 전달.
|
||||||
|
|
||||||
|
- [ ] **Step 3: tune_hyperparams.py 수정**
|
||||||
|
|
||||||
|
`--sl-mult`, `--tp-mult` CLI 인자 추가. `load_dataset()` 함수에 파라미터 전달.
|
||||||
|
|
||||||
|
- [ ] **Step 4: backtester.py WalkForward 수정**
|
||||||
|
|
||||||
|
`WalkForwardBacktester._train_model()` (line 739-746)에서 `generate_dataset_vectorized` 호출 시 `self.cfg.atr_sl_mult`, `self.cfg.atr_tp_mult` 전달:
|
||||||
|
|
||||||
|
```python
|
||||||
|
dataset = generate_dataset_vectorized(
|
||||||
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
|
time_weight_decay=self.cfg.time_weight_decay,
|
||||||
|
negative_ratio=self.cfg.negative_ratio,
|
||||||
|
signal_threshold=self.cfg.signal_threshold,
|
||||||
|
adx_threshold=self.cfg.adx_threshold,
|
||||||
|
volume_multiplier=self.cfg.volume_multiplier,
|
||||||
|
atr_sl_mult=self.cfg.atr_sl_mult,
|
||||||
|
atr_tp_mult=self.cfg.atr_tp_mult,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 전체 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/train_model.py scripts/train_mlx_model.py scripts/tune_hyperparams.py src/backtester.py
|
||||||
|
git commit -m "fix(ml): pass SL/TP multipliers to dataset generation — align train/serve"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: 백테스터 에퀴티 커브 미실현 PnL 반영
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/backtester.py:571-578`
|
||||||
|
- Test: `tests/test_ml_pipeline_fixes.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 테스트 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_equity_curve_includes_unrealized_pnl():
|
||||||
|
"""에퀴티 커브에 미실현 PnL이 반영되어야 한다."""
|
||||||
|
from src.backtester import Backtester, BacktestConfig, Position
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
cfg = BacktestConfig(symbols=["TEST"], initial_balance=1000.0)
|
||||||
|
bt = Backtester.__new__(Backtester)
|
||||||
|
bt.cfg = cfg
|
||||||
|
bt.balance = 1000.0
|
||||||
|
bt._peak_equity = 1000.0
|
||||||
|
bt.equity_curve = []
|
||||||
|
|
||||||
|
# LONG 포지션: 진입가 100, 현재가는 candle row로 전달
|
||||||
|
bt.positions = {"TEST": Position(
|
||||||
|
symbol="TEST", side="LONG", entry_price=100.0,
|
||||||
|
quantity=10.0, sl=95.0, tp=110.0,
|
||||||
|
entry_time=pd.Timestamp("2026-01-01"), entry_fee=0.4,
|
||||||
|
)}
|
||||||
|
|
||||||
|
# candle row에 close=105 → 미실현 PnL = (105-100)*10 = 50
|
||||||
|
row = pd.Series({"close": 105.0})
|
||||||
|
bt._record_equity(pd.Timestamp("2026-01-01 00:15:00"), current_prices={"TEST": 105.0})
|
||||||
|
|
||||||
|
last = bt.equity_curve[-1]
|
||||||
|
assert last["equity"] == 1050.0, f"Expected 1050.0 (1000+50), got {last['equity']}"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py::test_equity_curve_includes_unrealized_pnl -v`
|
||||||
|
Expected: FAIL
|
||||||
|
|
||||||
|
- [ ] **Step 3: _record_equity 수정**
|
||||||
|
|
||||||
|
`src/backtester.py`의 `_record_equity` 메서드를 수정:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _record_equity(self, ts: pd.Timestamp, current_prices: dict[str, float] | None = None):
|
||||||
|
unrealized = 0.0
|
||||||
|
for sym, pos in self.positions.items():
|
||||||
|
price = (current_prices or {}).get(sym)
|
||||||
|
if price is not None:
|
||||||
|
if pos.side == "LONG":
|
||||||
|
unrealized += (price - pos.entry_price) * pos.quantity
|
||||||
|
else:
|
||||||
|
unrealized += (pos.entry_price - price) * pos.quantity
|
||||||
|
equity = self.balance + unrealized
|
||||||
|
self.equity_curve.append({"timestamp": str(ts), "equity": round(equity, 4)})
|
||||||
|
if equity > self._peak_equity:
|
||||||
|
self._peak_equity = equity
|
||||||
|
```
|
||||||
|
|
||||||
|
메인 루프 호출부(`run()` 내 `_record_equity` 호출)도 수정:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# run() 메인 루프 내:
|
||||||
|
current_prices = {}
|
||||||
|
for sym in self.cfg.symbols:
|
||||||
|
idx = ... # 현재 캔들 인덱스
|
||||||
|
current_prices[sym] = float(all_indicators[sym].iloc[...]["close"])
|
||||||
|
self._record_equity(ts, current_prices=current_prices)
|
||||||
|
```
|
||||||
|
|
||||||
|
메인 루프의 이벤트는 `(ts, sym, candle_idx)` 튜플로, 타임스탬프별로 정렬되어 있다 (line 426: `events.sort(key=lambda x: (x[0], x[1]))`). 같은 타임스탬프에 여러 심볼 이벤트가 올 수 있다.
|
||||||
|
|
||||||
|
구현: 이벤트 루프 직전에 `latest_prices: dict[str, float] = {}` 초기화. 각 이벤트에서 `latest_prices[sym] = float(row["close"])` 업데이트. `_record_equity`는 **매 이벤트마다** 호출 (현재 동작 유지). `latest_prices`는 점진적으로 축적되므로, 첫 번째 심볼 이벤트 시점에 다른 심볼은 이전 캔들의 가격이 사용된다. 이는 15분봉 기반에서 미미한 차이이며, 타임스탬프 그룹핑을 도입하면 코드 복잡도가 불필요하게 증가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# run() 메인 루프 변경:
|
||||||
|
latest_prices: dict[str, float] = {}
|
||||||
|
|
||||||
|
for ts, sym, candle_idx in events:
|
||||||
|
# ... 기존 로직
|
||||||
|
row = df_ind.iloc[candle_idx]
|
||||||
|
latest_prices[sym] = float(row["close"])
|
||||||
|
|
||||||
|
self._record_equity(ts, current_prices=latest_prices)
|
||||||
|
# ... 나머지 기존 로직 (SL/TP 체크, 진입 등)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py -v`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/backtester.py tests/test_ml_pipeline_fixes.py
|
||||||
|
git commit -m "fix(backtest): include unrealized PnL in equity curve for accurate MDD"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: MLX 이중 정규화 제거
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/mlx_filter.py:139-155`
|
||||||
|
- Modify: `scripts/train_mlx_model.py:218-240`
|
||||||
|
- Test: `tests/test_ml_pipeline_fixes.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 테스트 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_mlx_no_double_normalization():
|
||||||
|
"""MLXFilter.fit()에 normalize=False를 전달하면 내부 정규화를 건너뛰어야 한다."""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from src.mlx_filter import MLXFilter
|
||||||
|
from src.ml_features import FEATURE_COLS
|
||||||
|
|
||||||
|
n_features = len(FEATURE_COLS)
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
X = pd.DataFrame(
|
||||||
|
rng.standard_normal((100, n_features)).astype(np.float32),
|
||||||
|
columns=FEATURE_COLS,
|
||||||
|
)
|
||||||
|
y = pd.Series(rng.integers(0, 2, 100).astype(np.float32))
|
||||||
|
|
||||||
|
model = MLXFilter(input_dim=n_features, hidden_dim=16, epochs=1, batch_size=32)
|
||||||
|
model.fit(X, y, normalize=False)
|
||||||
|
|
||||||
|
# normalize=False면 _mean=0, _std=1이어야 한다
|
||||||
|
assert np.allclose(model._mean, 0.0), "normalize=False시 mean은 0이어야 한다"
|
||||||
|
assert np.allclose(model._std, 1.0, atol=1e-7), "normalize=False시 std는 1이어야 한다"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py::test_mlx_no_double_normalization -v`
|
||||||
|
Expected: FAIL — `fit() got an unexpected keyword argument 'normalize'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: mlx_filter.py 수정**
|
||||||
|
|
||||||
|
`MLXFilter.fit()` 시그니처에 `normalize: bool = True` 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def fit(
|
||||||
|
self,
|
||||||
|
X: pd.DataFrame,
|
||||||
|
y: pd.Series,
|
||||||
|
sample_weight: np.ndarray | None = None,
|
||||||
|
normalize: bool = True,
|
||||||
|
) -> "MLXFilter":
|
||||||
|
X_np = X[FEATURE_COLS].values.astype(np.float32)
|
||||||
|
y_np = y.values.astype(np.float32)
|
||||||
|
|
||||||
|
if normalize:
|
||||||
|
mean_vals = np.nanmean(X_np, axis=0)
|
||||||
|
self._mean = np.nan_to_num(mean_vals, nan=0.0)
|
||||||
|
std_vals = np.nanstd(X_np, axis=0)
|
||||||
|
self._std = np.nan_to_num(std_vals, nan=1.0) + 1e-8
|
||||||
|
X_np = (X_np - self._mean) / self._std
|
||||||
|
X_np = np.nan_to_num(X_np, nan=0.0)
|
||||||
|
else:
|
||||||
|
self._mean = np.zeros(X_np.shape[1], dtype=np.float32)
|
||||||
|
self._std = np.ones(X_np.shape[1], dtype=np.float32)
|
||||||
|
X_np = np.nan_to_num(X_np, nan=0.0)
|
||||||
|
# ... 나머지 동일
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: train_mlx_model.py walk-forward 수정**
|
||||||
|
|
||||||
|
`walk_forward_auc()` (line 218-240)에서 이중 정규화 해킹을 제거:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 변경 전 (해킹):
|
||||||
|
# mean = X_tr_bal.mean(axis=0)
|
||||||
|
# std = X_tr_bal.std(axis=0) + 1e-8
|
||||||
|
# X_tr_norm = (X_tr_bal - mean) / std
|
||||||
|
# X_val_norm = (X_val_raw - mean) / std
|
||||||
|
# ...
|
||||||
|
# model.fit(X_tr_df, pd.Series(y_tr_bal), sample_weight=w_tr_bal)
|
||||||
|
# model._mean = np.zeros(...)
|
||||||
|
# model._std = np.ones(...)
|
||||||
|
|
||||||
|
# 변경 후 (깔끔):
|
||||||
|
X_tr_df = pd.DataFrame(X_tr_bal, columns=FEATURE_COLS)
|
||||||
|
X_val_df = pd.DataFrame(X_val_raw, columns=FEATURE_COLS)
|
||||||
|
|
||||||
|
model = MLXFilter(...)
|
||||||
|
model.fit(X_tr_df, pd.Series(y_tr_bal), sample_weight=w_tr_bal)
|
||||||
|
# fit() 내부에서 학습 데이터 기준으로 정규화
|
||||||
|
# predict_proba()에서 동일한 mean/std 적용
|
||||||
|
|
||||||
|
proba = model.predict_proba(X_val_df)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py -v`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/mlx_filter.py scripts/train_mlx_model.py tests/test_ml_pipeline_fixes.py
|
||||||
|
git commit -m "fix(mlx): remove double normalization in walk-forward validation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: MLX에 stratified_undersample 적용
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/train_mlx_model.py:88-104, 207-212`
|
||||||
|
|
||||||
|
- [ ] **Step 1: train_mlx_model.py train 함수 수정**
|
||||||
|
|
||||||
|
`train_mlx()` (line 88-104)의 단순 언더샘플링을 `stratified_undersample`로 교체:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 변경 전:
|
||||||
|
# pos_idx = np.where(y_train == 1)[0]
|
||||||
|
# neg_idx = np.where(y_train == 0)[0]
|
||||||
|
# if len(neg_idx) > len(pos_idx):
|
||||||
|
# np.random.seed(42)
|
||||||
|
# neg_idx = np.random.choice(neg_idx, size=len(pos_idx), replace=False)
|
||||||
|
# balanced_idx = np.concatenate([pos_idx, neg_idx])
|
||||||
|
# np.random.shuffle(balanced_idx)
|
||||||
|
|
||||||
|
# 변경 후:
|
||||||
|
from src.dataset_builder import stratified_undersample
|
||||||
|
|
||||||
|
source = dataset["source"].values if "source" in dataset.columns else np.full(len(dataset), "signal")
|
||||||
|
source_train = source[:split]
|
||||||
|
balanced_idx = stratified_undersample(y_train.values, source_train, seed=42)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: walk_forward_auc도 동일하게 수정**
|
||||||
|
|
||||||
|
`walk_forward_auc()` (line 207-212)도 `stratified_undersample`로 교체.
|
||||||
|
|
||||||
|
- [ ] **Step 3: negative_ratio 파라미터 추가**
|
||||||
|
|
||||||
|
`train_mlx()` 및 `walk_forward_auc()` 내 `generate_dataset_vectorized` 호출 모두에 `negative_ratio=5` 추가 (LightGBM과 동일):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# train_mlx() 내:
|
||||||
|
dataset = generate_dataset_vectorized(
|
||||||
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
|
time_weight_decay=time_weight_decay,
|
||||||
|
negative_ratio=5,
|
||||||
|
atr_sl_mult=2.0,
|
||||||
|
atr_tp_mult=2.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# walk_forward_auc() 내 (line 179-181):
|
||||||
|
dataset = generate_dataset_vectorized(
|
||||||
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
|
time_weight_decay=time_weight_decay,
|
||||||
|
negative_ratio=5,
|
||||||
|
atr_sl_mult=2.0,
|
||||||
|
atr_tp_mult=2.0,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 전체 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/train_mlx_model.py
|
||||||
|
git commit -m "fix(mlx): use stratified_undersample consistent with LightGBM"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: MLFilter factory method + backtest_validator initial_balance
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ml_filter.py`
|
||||||
|
- Modify: `src/backtester.py:320-329`
|
||||||
|
- Modify: `src/backtest_validator.py:123`
|
||||||
|
- Test: `tests/test_ml_pipeline_fixes.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: MLFilter factory method 테스트**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_ml_filter_from_model():
|
||||||
|
"""MLFilter.from_model()로 LightGBM 모델을 주입할 수 있어야 한다."""
|
||||||
|
from src.ml_filter import MLFilter
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
mock_model = MagicMock()
|
||||||
|
mock_model.predict_proba.return_value = [[0.3, 0.7]]
|
||||||
|
|
||||||
|
mf = MLFilter.from_model(mock_model, threshold=0.55)
|
||||||
|
assert mf.is_model_loaded()
|
||||||
|
assert mf.active_backend == "LightGBM"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py::test_ml_filter_from_model -v`
|
||||||
|
Expected: FAIL — `MLFilter has no attribute 'from_model'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: ml_filter.py에 from_model 추가**
|
||||||
|
|
||||||
|
```python
|
||||||
|
@classmethod
|
||||||
|
def from_model(cls, model, threshold: float = 0.55) -> "MLFilter":
|
||||||
|
"""외부에서 학습된 LightGBM 모델을 주입하여 MLFilter를 생성한다.
|
||||||
|
backtester walk-forward에서 사용."""
|
||||||
|
instance = cls.__new__(cls)
|
||||||
|
instance._disabled = False
|
||||||
|
instance._onnx_session = None
|
||||||
|
instance._lgbm_model = model
|
||||||
|
instance._threshold = threshold
|
||||||
|
instance._onnx_path = Path("/dev/null")
|
||||||
|
instance._lgbm_path = Path("/dev/null")
|
||||||
|
instance._loaded_onnx_mtime = 0.0
|
||||||
|
instance._loaded_lgbm_mtime = 0.0
|
||||||
|
return instance
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: backtester.py에서 factory method 사용**
|
||||||
|
|
||||||
|
`backtester.py:320-329`의 직접 조작 코드를 교체:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 변경 전:
|
||||||
|
# mf = MLFilter.__new__(MLFilter)
|
||||||
|
# mf._disabled = False
|
||||||
|
# mf._onnx_session = None
|
||||||
|
# mf._lgbm_model = ml_models[sym]
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# 변경 후:
|
||||||
|
mf = MLFilter.from_model(ml_models[sym], threshold=self.cfg.ml_threshold)
|
||||||
|
self.ml_filters[sym] = mf
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: backtest_validator.py initial_balance 수정**
|
||||||
|
|
||||||
|
`src/backtest_validator.py:123`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 변경 전:
|
||||||
|
# balance = 1000.0
|
||||||
|
|
||||||
|
# 변경 후 (cfg는 항상 BacktestConfig이므로 hasattr 불필요):
|
||||||
|
balance = cfg.initial_balance
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py -v && bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 7: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ml_filter.py src/backtester.py src/backtest_validator.py tests/test_ml_pipeline_fixes.py
|
||||||
|
git commit -m "refactor(ml): add MLFilter.from_model(), fix validator initial_balance"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: 레거시 코드 정리 + 최종 검증
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/train_model.py:56-103` (레거시 `_process_index`, `generate_dataset` 함수)
|
||||||
|
- Modify: `tests/test_dataset_builder.py:76-93` (레거시 비교 테스트)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 레거시 함수 사용 여부 확인**
|
||||||
|
|
||||||
|
`scripts/train_model.py`의 `_process_index()`, `generate_dataset()` 함수는 현재 `tests/test_dataset_builder.py:84`에서만 참조됨. 이 테스트는 레거시와 벡터화 버전의 샘플 수 비교인데, 두 버전의 SL/TP가 다르므로 (레거시 TP=3.0 vs 벡터화 TP=2.0) 비교 자체가 무의미.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 레거시 비교 테스트 제거**
|
||||||
|
|
||||||
|
`tests/test_dataset_builder.py`에서 `test_matches_original_generate_dataset` 함수를 삭제.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 레거시 함수에 deprecation 경고 추가**
|
||||||
|
|
||||||
|
`scripts/train_model.py`의 `generate_dataset()`, `_process_index()` 함수 상단에:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
def generate_dataset(df: pd.DataFrame, n_jobs: int | None = None) -> pd.DataFrame:
|
||||||
|
"""[Deprecated] generate_dataset_vectorized()를 사용할 것."""
|
||||||
|
warnings.warn(
|
||||||
|
"generate_dataset()는 deprecated. generate_dataset_vectorized()를 사용하세요.",
|
||||||
|
DeprecationWarning, stacklevel=2,
|
||||||
|
)
|
||||||
|
# ... 기존 코드
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 전체 테스트 실행**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/train_model.py tests/test_dataset_builder.py
|
||||||
|
git commit -m "chore: deprecate legacy dataset generation, remove stale comparison test"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: README/ARCHITECTURE 동기화 + CLAUDE.md 업데이트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md` (plan history table)
|
||||||
|
- Modify: `README.md` (필요시)
|
||||||
|
- Modify: `ARCHITECTURE.md` (필요시)
|
||||||
|
|
||||||
|
- [ ] **Step 1: CLAUDE.md plan history 업데이트**
|
||||||
|
|
||||||
|
`CLAUDE.md`의 plan history 테이블에 추가:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| 2026-03-21 | `ml-pipeline-fixes` (plan) | Completed |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 최종 전체 테스트**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs: update plan history with ml-pipeline-fixes"
|
||||||
|
```
|
||||||
303
docs/plans/2026-03-21-ml-validation-pipeline.md
Normal file
303
docs/plans/2026-03-21-ml-validation-pipeline.md
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
# ML Validation Pipeline Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** ML 필터의 실전 가치를 검증하는 `--compare-ml` CLI를 추가하여, 완화된 임계값에서 ML on/off Walk-Forward 백테스트를 자동 비교하고 PF/승률/MDD 개선폭을 리포트한다.
|
||||||
|
|
||||||
|
**Architecture:** `scripts/run_backtest.py`에 `--compare-ml` 플래그를 추가한다. 이 플래그가 활성화되면 WalkForwardBacktester를 `use_ml=True`와 `use_ml=False`로 각각 실행하고, 결과를 나란히 비교하는 리포트를 출력한다. 기존 `Backtester`/`WalkForwardBacktester` 코드는 변경하지 않는다.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, LightGBM, src/backtester.py (기존 모듈 재사용)
|
||||||
|
|
||||||
|
**선행 완료 항목 (이미 구현됨):**
|
||||||
|
- ✅ 학습 전용 상수 (TRAIN_SIGNAL_THRESHOLD=2, TRAIN_ADX_THRESHOLD=15, etc.)
|
||||||
|
- ✅ Purged gap (embargo=LOOKAHEAD) in all walk-forward functions
|
||||||
|
- ✅ Ablation A/B/C CLI (`--ablation`)
|
||||||
|
- ✅ `BacktestConfig.use_ml` 플래그
|
||||||
|
- ✅ `run_backtest.py --no-ml` 지원
|
||||||
|
|
||||||
|
**판단 기준 (합의됨):**
|
||||||
|
- ML on vs ML off의 **상대 PF 개선폭**으로 판단 (절대 기준 아님)
|
||||||
|
- PF 개선 + 승률 개선 + MDD 감소 → 투입 가치 있음
|
||||||
|
- PF 변화 미미 → ML 기여 낮음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| 파일 | 변경 유형 | 역할 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `scripts/run_backtest.py` | Modify | `--compare-ml` CLI + 비교 리포트 |
|
||||||
|
| `CLAUDE.md` | Modify | plan history 업데이트 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `--compare-ml` CLI 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/run_backtest.py:29-55, 151-211`
|
||||||
|
|
||||||
|
- [ ] **Step 1: argparse에 --compare-ml 추가**
|
||||||
|
|
||||||
|
`scripts/run_backtest.py`의 `parse_args()` 함수에:
|
||||||
|
|
||||||
|
```python
|
||||||
|
p.add_argument("--compare-ml", action="store_true",
|
||||||
|
help="ML on vs off Walk-Forward 비교 (--walk-forward 자동 활성화)")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: compare_ml 함수 작성**
|
||||||
|
|
||||||
|
`scripts/run_backtest.py`에 `compare_ml()` 함수 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def compare_ml(symbols: list[str], args):
|
||||||
|
"""ML on vs ML off Walk-Forward 백테스트 비교.
|
||||||
|
|
||||||
|
완화된 임계값(threshold=2)에서 ML 필터의 실질적 가치를 검증한다.
|
||||||
|
판단 기준: 상대 PF 개선폭 (절대 기준 아님).
|
||||||
|
"""
|
||||||
|
base_kwargs = dict(
|
||||||
|
symbols=symbols,
|
||||||
|
start=args.start,
|
||||||
|
end=args.end,
|
||||||
|
initial_balance=args.balance,
|
||||||
|
leverage=args.leverage,
|
||||||
|
fee_pct=args.fee,
|
||||||
|
slippage_pct=args.slippage,
|
||||||
|
ml_threshold=args.ml_threshold,
|
||||||
|
atr_sl_mult=args.sl_atr,
|
||||||
|
atr_tp_mult=args.tp_atr,
|
||||||
|
signal_threshold=args.signal_threshold,
|
||||||
|
adx_threshold=args.adx_threshold,
|
||||||
|
volume_multiplier=args.vol_multiplier,
|
||||||
|
train_months=args.train_months,
|
||||||
|
test_months=args.test_months,
|
||||||
|
)
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for label, use_ml in [("ML OFF", False), ("ML ON", True)]:
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" Walk-Forward 백테스트: {label}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
cfg = WalkForwardConfig(**base_kwargs, use_ml=use_ml)
|
||||||
|
wf = WalkForwardBacktester(cfg)
|
||||||
|
result = wf.run()
|
||||||
|
results[label] = result
|
||||||
|
print_summary(result["summary"], cfg, mode="walk_forward")
|
||||||
|
if result.get("folds"):
|
||||||
|
print_fold_table(result["folds"])
|
||||||
|
|
||||||
|
# 비교 리포트
|
||||||
|
_print_comparison(results, symbols)
|
||||||
|
|
||||||
|
# 결과 저장
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
if len(symbols) == 1:
|
||||||
|
out_dir = Path(f"results/{symbols[0].lower()}")
|
||||||
|
else:
|
||||||
|
out_dir = Path("results/combined")
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = out_dir / f"ml_comparison_{ts}.json"
|
||||||
|
|
||||||
|
comparison = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"symbols": symbols,
|
||||||
|
"ml_off": results["ML OFF"]["summary"],
|
||||||
|
"ml_on": results["ML ON"]["summary"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def sanitize(obj):
|
||||||
|
if isinstance(obj, bool):
|
||||||
|
return obj
|
||||||
|
if isinstance(obj, (int, float)):
|
||||||
|
if isinstance(obj, float) and obj == float("inf"):
|
||||||
|
return "Infinity"
|
||||||
|
return obj
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: sanitize(v) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [sanitize(v) for v in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(sanitize(comparison), f, indent=2, ensure_ascii=False)
|
||||||
|
print(f"\n비교 결과 저장: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_comparison(results: dict, symbols: list[str]):
|
||||||
|
"""ML on vs off 비교 리포트 출력."""
|
||||||
|
off = results["ML OFF"]["summary"]
|
||||||
|
on = results["ML ON"]["summary"]
|
||||||
|
|
||||||
|
print(f"\n{'='*64}")
|
||||||
|
print(f" ML ON vs OFF 비교 ({', '.join(symbols)})")
|
||||||
|
print(f"{'='*64}")
|
||||||
|
print(f" {'지표':<20} {'ML OFF':>12} {'ML ON':>12} {'Delta':>12}")
|
||||||
|
print(f"{'─'*64}")
|
||||||
|
|
||||||
|
metrics = [
|
||||||
|
("총 거래", "total_trades", "d"),
|
||||||
|
("총 PnL (USDT)", "total_pnl", ".2f"),
|
||||||
|
("수익률 (%)", "return_pct", ".2f"),
|
||||||
|
("승률 (%)", "win_rate", ".1f"),
|
||||||
|
("Profit Factor", "profit_factor", ".2f"),
|
||||||
|
("MDD (%)", "max_drawdown_pct", ".2f"),
|
||||||
|
("Sharpe", "sharpe_ratio", ".2f"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for label, key, fmt in metrics:
|
||||||
|
v_off = off.get(key, 0)
|
||||||
|
v_on = on.get(key, 0)
|
||||||
|
# inf 처리
|
||||||
|
if v_off == float("inf"):
|
||||||
|
v_off_str = "INF"
|
||||||
|
else:
|
||||||
|
v_off_str = f"{v_off:{fmt}}"
|
||||||
|
if v_on == float("inf"):
|
||||||
|
v_on_str = "INF"
|
||||||
|
else:
|
||||||
|
v_on_str = f"{v_on:{fmt}}"
|
||||||
|
|
||||||
|
if isinstance(v_off, (int, float)) and isinstance(v_on, (int, float)) \
|
||||||
|
and v_off != float("inf") and v_on != float("inf"):
|
||||||
|
delta = v_on - v_off
|
||||||
|
sign = "+" if delta > 0 else ""
|
||||||
|
delta_str = f"{sign}{delta:{fmt}}"
|
||||||
|
else:
|
||||||
|
delta_str = "N/A"
|
||||||
|
|
||||||
|
print(f" {label:<20} {v_off_str:>12} {v_on_str:>12} {delta_str:>12}")
|
||||||
|
|
||||||
|
# 판정
|
||||||
|
pf_off = off.get("profit_factor", 0)
|
||||||
|
pf_on = on.get("profit_factor", 0)
|
||||||
|
wr_off = off.get("win_rate", 0)
|
||||||
|
wr_on = on.get("win_rate", 0)
|
||||||
|
mdd_off = off.get("max_drawdown_pct", 0)
|
||||||
|
mdd_on = on.get("max_drawdown_pct", 0)
|
||||||
|
|
||||||
|
print(f"{'─'*64}")
|
||||||
|
|
||||||
|
if pf_off == float("inf") or pf_on == float("inf"):
|
||||||
|
print(f" 판정: PF=INF — 한쪽 모드에서 손실 거래 없음 (거래 수 부족 가능), 판단 보류")
|
||||||
|
elif pf_off == 0:
|
||||||
|
print(f" 판정: ML OFF PF=0 — baseline 거래 없음, 판단 불가")
|
||||||
|
else:
|
||||||
|
pf_improvement = pf_on - pf_off
|
||||||
|
wr_improvement = wr_on - wr_off
|
||||||
|
mdd_improvement = mdd_off - mdd_on # MDD는 낮을수록 좋음
|
||||||
|
|
||||||
|
# 판정 임계값 (초기값 — 실제 백테스트 결과를 보고 조정 가능)
|
||||||
|
improvements = []
|
||||||
|
if pf_improvement > 0.1:
|
||||||
|
improvements.append(f"PF +{pf_improvement:.2f}")
|
||||||
|
if wr_improvement > 2.0:
|
||||||
|
improvements.append(f"승률 +{wr_improvement:.1f}%p")
|
||||||
|
if mdd_improvement > 1.0:
|
||||||
|
improvements.append(f"MDD -{mdd_improvement:.1f}%p")
|
||||||
|
|
||||||
|
if len(improvements) >= 2:
|
||||||
|
verdict = f"✅ ML 필터 투입 가치 있음 ({', '.join(improvements)})"
|
||||||
|
elif len(improvements) == 1:
|
||||||
|
verdict = f"⚠️ ML 필터 조건부 투입 ({improvements[0]}, 다른 지표 변화 미미)"
|
||||||
|
else:
|
||||||
|
verdict = f"❌ ML 필터 기여 미미 (PF {pf_improvement:+.2f}, 승률 {wr_improvement:+.1f}%p)"
|
||||||
|
print(f" 판정: {verdict}")
|
||||||
|
|
||||||
|
print(f"{'='*64}\n")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: main()에 --compare-ml 분기 추가**
|
||||||
|
|
||||||
|
`scripts/run_backtest.py`의 `main()` 함수에서 `if args.walk_forward:` 블록 **앞에** 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if args.compare_ml:
|
||||||
|
if args.no_ml:
|
||||||
|
logger.warning("--no-ml is ignored when using --compare-ml")
|
||||||
|
compare_ml(symbols, args)
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 전체 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS (기존 테스트 영향 없음)
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/run_backtest.py
|
||||||
|
git commit -m "feat(backtest): add --compare-ml for ML on/off walk-forward comparison"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: CLAUDE.md 업데이트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: plan history 업데이트**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| 2026-03-21 | `ml-validation-pipeline` (plan) | Completed |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs: update plan history with ml-validation-pipeline"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 후 실행 가이드
|
||||||
|
|
||||||
|
### Phase 1: Ablation 진단 (이미 구현됨)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 심볼별 ablation 실행
|
||||||
|
python scripts/train_model.py --symbol XRPUSDT --ablation
|
||||||
|
python scripts/train_model.py --symbol SOLUSDT --ablation
|
||||||
|
python scripts/train_model.py --symbol DOGEUSDT --ablation
|
||||||
|
```
|
||||||
|
|
||||||
|
판단:
|
||||||
|
- A→C 드롭 ≤ 0.05 → Phase 2로 진행
|
||||||
|
- A→C 드롭 ≥ 0.10 → ML 재설계 필요 (중단)
|
||||||
|
|
||||||
|
### Phase 2: ML on/off 비교 (이 플랜에서 구현)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 완화된 임계값(threshold=2)로 ML 비교
|
||||||
|
python scripts/run_backtest.py --symbol XRPUSDT --compare-ml \
|
||||||
|
--signal-threshold 2 --adx-threshold 15 --vol-multiplier 1.5 --walk-forward
|
||||||
|
|
||||||
|
python scripts/run_backtest.py --symbol SOLUSDT --compare-ml \
|
||||||
|
--signal-threshold 2 --adx-threshold 15 --vol-multiplier 1.5 --walk-forward
|
||||||
|
|
||||||
|
python scripts/run_backtest.py --symbol DOGEUSDT --compare-ml \
|
||||||
|
--signal-threshold 2 --adx-threshold 15 --vol-multiplier 1.5 --walk-forward
|
||||||
|
```
|
||||||
|
|
||||||
|
판단: 상대 PF 개선폭으로 ML 가치 평가
|
||||||
|
|
||||||
|
### Phase 3: 실전 점진적 전환 (코드 변경 불필요)
|
||||||
|
|
||||||
|
Phase 1, 2 모두 긍정적이면 `.env`로 1심볼부터 적용:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env에 추가 (1심볼만 먼저)
|
||||||
|
SIGNAL_THRESHOLD_XRPUSDT=2
|
||||||
|
ADX_THRESHOLD_XRPUSDT=15
|
||||||
|
VOL_MULTIPLIER_XRPUSDT=1.5
|
||||||
|
|
||||||
|
# 나머지 심볼은 기존 값 유지
|
||||||
|
# SIGNAL_THRESHOLD_SOLUSDT=3 (기본값)
|
||||||
|
# SIGNAL_THRESHOLD_DOGEUSDT=3 (기본값)
|
||||||
|
```
|
||||||
|
|
||||||
|
1~2주 운영 후 kill switch 미발동 + PnL 양호하면 나머지 심볼도 전환.
|
||||||
399
docs/plans/2026-03-21-purged-gap-and-ablation.md
Normal file
399
docs/plans/2026-03-21-purged-gap-and-ablation.md
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
# Purged Gap + Feature Ablation Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Walk-Forward 검증에 purged gap(embargo)을 추가하여 레이블 누수를 제거하고, feature ablation으로 signal_strength/side 의존도를 진단하여 ML 필터의 실질적 예측력을 검증한다.
|
||||||
|
|
||||||
|
**Architecture:** 3개의 walk-forward 함수(train_model.py, train_mlx_model.py, tune_hyperparams.py)의 검증 시작 인덱스에 `LOOKAHEAD` 만큼의 embargo를 추가한다. `train_model.py`에 `--ablation` CLI 플래그를 추가하여 A/B/C 실험을 자동 실행하고 상대 드롭을 출력한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, LightGBM, numpy, sklearn, pytest
|
||||||
|
|
||||||
|
**판단 기준 (합의됨):**
|
||||||
|
- A→C 드롭 ≤ 0.05: ML 필터 가치 있음
|
||||||
|
- A→C 드롭 0.05~0.10: 조건부 투입
|
||||||
|
- A→C 드롭 ≥ 0.10: 재설계 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| 파일 | 변경 유형 | 역할 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `scripts/train_model.py` | Modify | purged gap + ablation CLI |
|
||||||
|
| `scripts/train_mlx_model.py` | Modify | purged gap |
|
||||||
|
| `scripts/tune_hyperparams.py` | Modify | purged gap |
|
||||||
|
| `tests/test_ml_pipeline_fixes.py` | Modify | purged gap 테스트 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: walk-forward에 purged gap(embargo) 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/train_model.py:389-396`
|
||||||
|
- Modify: `scripts/train_mlx_model.py:194-204`
|
||||||
|
- Modify: `scripts/tune_hyperparams.py:153-160`
|
||||||
|
- Test: `tests/test_ml_pipeline_fixes.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: purged gap 테스트 작성**
|
||||||
|
|
||||||
|
`tests/test_ml_pipeline_fixes.py`에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_walk_forward_purged_gap():
|
||||||
|
"""Walk-Forward 검증에서 학습/검증 사이에 LOOKAHEAD 만큼의 gap이 존재해야 한다."""
|
||||||
|
from src.dataset_builder import LOOKAHEAD
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# 시뮬레이션: n=1000, train_ratio=0.6, n_splits=5
|
||||||
|
n = 1000
|
||||||
|
train_ratio = 0.6
|
||||||
|
n_splits = 5
|
||||||
|
embargo = LOOKAHEAD # 24
|
||||||
|
|
||||||
|
step = max(1, int(n * (1 - train_ratio) / n_splits))
|
||||||
|
train_end_start = int(n * train_ratio)
|
||||||
|
|
||||||
|
for fold_idx in range(n_splits):
|
||||||
|
tr_end = train_end_start + fold_idx * step
|
||||||
|
val_start = tr_end + embargo # purged gap
|
||||||
|
val_end = val_start + step
|
||||||
|
if val_end > n:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 학습 마지막 인덱스와 검증 첫 인덱스 사이에 최소 embargo 캔들 gap
|
||||||
|
assert val_start - tr_end >= embargo, \
|
||||||
|
f"폴드 {fold_idx}: gap={val_start - tr_end} < embargo={embargo}"
|
||||||
|
# 검증 구간이 학습 구간과 겹치지 않아야 한다
|
||||||
|
assert val_start > tr_end, \
|
||||||
|
f"폴드 {fold_idx}: val_start={val_start} <= tr_end={tr_end}"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 통과 확인 (로직 테스트이므로 바로 PASS)**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py::test_walk_forward_purged_gap -v`
|
||||||
|
Expected: PASS (이 테스트는 로직만 검증하므로 코드 변경 없이도 통과)
|
||||||
|
|
||||||
|
- [ ] **Step 3: train_model.py walk_forward_auc() 수정**
|
||||||
|
|
||||||
|
`scripts/train_model.py`의 `walk_forward_auc()` 함수 내 폴드 루프(~line 389-396):
|
||||||
|
|
||||||
|
변경 전:
|
||||||
|
```python
|
||||||
|
for i in range(n_splits):
|
||||||
|
tr_end = train_end_start + i * step
|
||||||
|
val_end = tr_end + step
|
||||||
|
if val_end > n:
|
||||||
|
break
|
||||||
|
|
||||||
|
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]
|
||||||
|
```
|
||||||
|
|
||||||
|
변경 후:
|
||||||
|
```python
|
||||||
|
from src.dataset_builder import LOOKAHEAD
|
||||||
|
|
||||||
|
for i in range(n_splits):
|
||||||
|
tr_end = train_end_start + i * step
|
||||||
|
val_start = tr_end + LOOKAHEAD # purged gap: 레이블 누수 방지
|
||||||
|
val_end = val_start + step
|
||||||
|
if val_end > n:
|
||||||
|
break
|
||||||
|
|
||||||
|
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
|
||||||
|
X_val, y_val = X[val_start:val_end], y[val_start:val_end]
|
||||||
|
```
|
||||||
|
|
||||||
|
`source_tr`는 기존과 동일하게 `source[:tr_end]`.
|
||||||
|
|
||||||
|
출력 문자열도 업데이트:
|
||||||
|
```python
|
||||||
|
print(
|
||||||
|
f" 폴드 {i+1}/{n_splits}: 학습={tr_end}개, "
|
||||||
|
f"검증={val_start}~{val_end} ({step}개, embargo={LOOKAHEAD}), AUC={auc:.4f} | "
|
||||||
|
f"Thr={f_thr:.4f} Prec={f_prec:.3f} Rec={f_rec:.3f}"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: train_mlx_model.py walk_forward_auc() 동일 수정**
|
||||||
|
|
||||||
|
`scripts/train_mlx_model.py`의 `walk_forward_auc()` 폴드 루프(~line 194-204):
|
||||||
|
|
||||||
|
변경 전:
|
||||||
|
```python
|
||||||
|
X_val_raw = X_all[tr_end:val_end]
|
||||||
|
y_val = y_all[tr_end:val_end]
|
||||||
|
```
|
||||||
|
|
||||||
|
변경 후:
|
||||||
|
```python
|
||||||
|
from src.dataset_builder import LOOKAHEAD
|
||||||
|
|
||||||
|
val_start = tr_end + LOOKAHEAD
|
||||||
|
val_end = val_start + step
|
||||||
|
if val_end > n:
|
||||||
|
break
|
||||||
|
|
||||||
|
X_val_raw = X_all[val_start:val_end]
|
||||||
|
y_val = y_all[val_start:val_end]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: tune_hyperparams.py _walk_forward_cv() 동일 수정**
|
||||||
|
|
||||||
|
`scripts/tune_hyperparams.py`의 `_walk_forward_cv()` 폴드 루프(~line 153-160):
|
||||||
|
|
||||||
|
변경 전:
|
||||||
|
```python
|
||||||
|
X_val, y_val = X[tr_end:val_end], y[tr_end:val_end]
|
||||||
|
```
|
||||||
|
|
||||||
|
변경 후:
|
||||||
|
```python
|
||||||
|
from src.dataset_builder import LOOKAHEAD
|
||||||
|
|
||||||
|
val_start = tr_end + LOOKAHEAD
|
||||||
|
val_end = val_start + step
|
||||||
|
if val_end > n:
|
||||||
|
break
|
||||||
|
|
||||||
|
X_val, y_val = X[val_start:val_end], y[val_start:val_end]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: 전체 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 7: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/train_model.py scripts/train_mlx_model.py scripts/tune_hyperparams.py tests/test_ml_pipeline_fixes.py
|
||||||
|
git commit -m "fix(ml): add purged gap (embargo=LOOKAHEAD) to walk-forward validation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Feature ablation 실험 CLI 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/train_model.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: ablation 함수 추가**
|
||||||
|
|
||||||
|
`scripts/train_model.py`에 `ablation()` 함수를 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def ablation(
|
||||||
|
data_path: str,
|
||||||
|
time_weight_decay: float = 2.0,
|
||||||
|
n_splits: int = 5,
|
||||||
|
train_ratio: float = 0.6,
|
||||||
|
tuned_params_path: str | None = None,
|
||||||
|
atr_sl_mult: float = 2.0,
|
||||||
|
atr_tp_mult: float = 2.0,
|
||||||
|
) -> None:
|
||||||
|
"""Feature ablation 실험: signal_strength/side 의존도 진단.
|
||||||
|
|
||||||
|
실험 A: 전체 피처 (baseline)
|
||||||
|
실험 B: signal_strength 제거
|
||||||
|
실험 C: signal_strength + side 제거
|
||||||
|
|
||||||
|
판단 기준 (절대 AUC 차이):
|
||||||
|
A→C ≤ 0.05: ML 필터 가치 있음 (다른 피처가 충분히 기여)
|
||||||
|
A→C 0.05~0.10: 조건부 투입 (signal_strength 의존도 높지만 다른 피처도 기여)
|
||||||
|
A→C ≥ 0.10: 재설계 필요 (사실상 점수 재확인기)
|
||||||
|
"""
|
||||||
|
import warnings
|
||||||
|
from src.dataset_builder import LOOKAHEAD
|
||||||
|
|
||||||
|
print(f"\n{'='*64}")
|
||||||
|
print(f" Feature Ablation 실험 ({n_splits}폴드 Walk-Forward, embargo={LOOKAHEAD})")
|
||||||
|
print(f"{'='*64}")
|
||||||
|
|
||||||
|
df_raw = pd.read_parquet(data_path)
|
||||||
|
base_cols = ["open", "high", "low", "close", "volume"]
|
||||||
|
btc_df = eth_df = None
|
||||||
|
if "close_btc" in df_raw.columns:
|
||||||
|
btc_df = df_raw[[c + "_btc" for c in base_cols]].copy()
|
||||||
|
btc_df.columns = base_cols
|
||||||
|
if "close_eth" in df_raw.columns:
|
||||||
|
eth_df = df_raw[[c + "_eth" for c in base_cols]].copy()
|
||||||
|
eth_df.columns = base_cols
|
||||||
|
df = df_raw[base_cols].copy()
|
||||||
|
|
||||||
|
dataset = generate_dataset_vectorized(
|
||||||
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
|
time_weight_decay=time_weight_decay,
|
||||||
|
atr_sl_mult=atr_sl_mult,
|
||||||
|
atr_tp_mult=atr_tp_mult,
|
||||||
|
)
|
||||||
|
actual_feature_cols = [c for c in FEATURE_COLS if c in dataset.columns]
|
||||||
|
y = dataset["label"].values
|
||||||
|
w = dataset["sample_weight"].values
|
||||||
|
n = len(dataset)
|
||||||
|
source = dataset["source"].values if "source" in dataset.columns else np.full(n, "signal")
|
||||||
|
|
||||||
|
lgbm_params, weight_scale = _load_lgbm_params(tuned_params_path)
|
||||||
|
w = (w * weight_scale).astype(np.float32)
|
||||||
|
|
||||||
|
# 실험 정의
|
||||||
|
experiments = {
|
||||||
|
"A (전체 피처)": actual_feature_cols,
|
||||||
|
"B (-signal_strength)": [c for c in actual_feature_cols if c != "signal_strength"],
|
||||||
|
"C (-signal_strength, -side)": [c for c in actual_feature_cols if c not in ("signal_strength", "side")],
|
||||||
|
}
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for exp_name, cols in experiments.items():
|
||||||
|
X = dataset[cols].values
|
||||||
|
step = max(1, int(n * (1 - train_ratio) / n_splits))
|
||||||
|
train_end_start = int(n * train_ratio)
|
||||||
|
|
||||||
|
fold_aucs = []
|
||||||
|
fold_importances = []
|
||||||
|
for fold_idx in range(n_splits):
|
||||||
|
tr_end = train_end_start + fold_idx * step
|
||||||
|
val_start = tr_end + LOOKAHEAD
|
||||||
|
val_end = val_start + step
|
||||||
|
if val_end > n:
|
||||||
|
break
|
||||||
|
|
||||||
|
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
|
||||||
|
X_val, y_val = X[val_start:val_end], y[val_start:val_end]
|
||||||
|
|
||||||
|
source_tr = source[:tr_end]
|
||||||
|
idx = stratified_undersample(y_tr, source_tr, seed=42)
|
||||||
|
|
||||||
|
model = lgb.LGBMClassifier(**lgbm_params, random_state=42, verbose=-1)
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore")
|
||||||
|
model.fit(X_tr[idx], y_tr[idx], sample_weight=w_tr[idx])
|
||||||
|
|
||||||
|
proba = model.predict_proba(X_val)[:, 1]
|
||||||
|
auc = roc_auc_score(y_val, proba) if len(np.unique(y_val)) > 1 else 0.5
|
||||||
|
fold_aucs.append(auc)
|
||||||
|
fold_importances.append(dict(zip(cols, model.feature_importances_)))
|
||||||
|
|
||||||
|
mean_auc = float(np.mean(fold_aucs))
|
||||||
|
std_auc = float(np.std(fold_aucs))
|
||||||
|
results[exp_name] = {
|
||||||
|
"mean_auc": mean_auc,
|
||||||
|
"std_auc": std_auc,
|
||||||
|
"fold_aucs": fold_aucs,
|
||||||
|
"importances": fold_importances,
|
||||||
|
}
|
||||||
|
print(f"\n {exp_name}: AUC={mean_auc:.4f} ± {std_auc:.4f}")
|
||||||
|
print(f" 폴드별: {[round(a, 4) for a in fold_aucs]}")
|
||||||
|
|
||||||
|
# 실험 A에서만 feature importance top 10 출력
|
||||||
|
if exp_name.startswith("A"):
|
||||||
|
avg_imp = {}
|
||||||
|
for imp in fold_importances:
|
||||||
|
for k, v in imp.items():
|
||||||
|
avg_imp[k] = avg_imp.get(k, 0) + v / len(fold_importances)
|
||||||
|
top10 = sorted(avg_imp.items(), key=lambda x: x[1], reverse=True)[:10]
|
||||||
|
print(f" Feature Importance Top 10:")
|
||||||
|
for feat_name, imp_val in top10:
|
||||||
|
marker = " ← 주의" if feat_name in ("signal_strength", "side") else ""
|
||||||
|
print(f" {feat_name:<25} {imp_val:>8.1f}{marker}")
|
||||||
|
|
||||||
|
# 드롭 분석
|
||||||
|
auc_a = results["A (전체 피처)"]["mean_auc"]
|
||||||
|
auc_b = results["B (-signal_strength)"]["mean_auc"]
|
||||||
|
auc_c = results["C (-signal_strength, -side)"]["mean_auc"]
|
||||||
|
drop_ab = auc_a - auc_b
|
||||||
|
drop_ac = auc_a - auc_c
|
||||||
|
|
||||||
|
print(f"\n{'='*64}")
|
||||||
|
print(f" 드롭 분석")
|
||||||
|
print(f"{'='*64}")
|
||||||
|
print(f" A → B (signal_strength 제거): {drop_ab:+.4f}")
|
||||||
|
print(f" A → C (signal_strength + side 제거): {drop_ac:+.4f}")
|
||||||
|
print(f"{'─'*64}")
|
||||||
|
|
||||||
|
if drop_ac <= 0.05:
|
||||||
|
verdict = "✅ ML 필터 가치 있음 (다른 피처가 충분히 기여)"
|
||||||
|
elif drop_ac <= 0.10:
|
||||||
|
verdict = "⚠️ 조건부 투입 (signal_strength 의존도 높지만 다른 피처도 기여)"
|
||||||
|
else:
|
||||||
|
verdict = "❌ 재설계 필요 (사실상 점수 재확인기)"
|
||||||
|
print(f" 판정: {verdict}")
|
||||||
|
print(f"{'='*64}\n")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: CLI에 --ablation 플래그 추가**
|
||||||
|
|
||||||
|
`scripts/train_model.py`의 `main()` 내 argparse에:
|
||||||
|
|
||||||
|
```python
|
||||||
|
parser.add_argument("--ablation", action="store_true",
|
||||||
|
help="Feature ablation 실험 (signal_strength/side 의존도 진단)")
|
||||||
|
```
|
||||||
|
|
||||||
|
main() 분기에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if args.ablation:
|
||||||
|
ablation(
|
||||||
|
args.data, time_weight_decay=args.decay,
|
||||||
|
tuned_params_path=args.tuned_params,
|
||||||
|
atr_sl_mult=args.sl_mult, atr_tp_mult=args.tp_mult,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 `elif args.compare:` 앞에 배치.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 전체 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/train_model.py
|
||||||
|
git commit -m "feat(ml): add --ablation CLI for signal_strength/side dependency diagnosis"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: CLAUDE.md 업데이트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: plan history 업데이트**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| 2026-03-21 | `purged-gap-and-ablation` (plan) | Completed |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs: update plan history with purged-gap-and-ablation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 후 실행 가이드
|
||||||
|
|
||||||
|
구현 완료 후 다음 순서로 실행:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Purged gap 적용된 Walk-Forward (심볼별)
|
||||||
|
python scripts/train_model.py --symbol XRPUSDT --wf
|
||||||
|
python scripts/train_model.py --symbol SOLUSDT --wf
|
||||||
|
python scripts/train_model.py --symbol DOGEUSDT --wf
|
||||||
|
|
||||||
|
# 2. Ablation 실험 (심볼별)
|
||||||
|
python scripts/train_model.py --symbol XRPUSDT --ablation
|
||||||
|
python scripts/train_model.py --symbol SOLUSDT --ablation
|
||||||
|
python scripts/train_model.py --symbol DOGEUSDT --ablation
|
||||||
|
```
|
||||||
|
|
||||||
|
결과를 보고 판단:
|
||||||
|
- Purged AUC가 0.85+ 유지되면 모델 유효
|
||||||
|
- A→C 드롭이 0.05 이내면 ML 필터 실전 투입 가치 있음
|
||||||
|
- 두 조건 모두 충족 시 PF 계산(Task 미포함, 별도 판단 후 추가)으로 진행
|
||||||
254
docs/plans/2026-03-21-training-threshold-relaxation.md
Normal file
254
docs/plans/2026-03-21-training-threshold-relaxation.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# Training Threshold Relaxation Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** ML 학습용 신호 임계값을 완화하여 학습 샘플을 5~10배 증가시키고, 모델이 의미 있는 패턴을 학습할 수 있도록 한다.
|
||||||
|
|
||||||
|
**Architecture:** `dataset_builder.py`에 학습 전용 상수 블록(`TRAIN_*`)을 추가하고, `generate_dataset_vectorized()`의 기본값을 이 상수로 변경한다. 모든 호출부(train_model, train_mlx_model, tune_hyperparams)는 기본값을 따르므로 호출부 코드 변경 없이 적용된다. 실전 봇(`bot.py`)과 백테스터 시뮬레이션(`Backtester.run`)은 `config.py`의 엄격한 임계값을 별도로 사용하므로 영향 없다.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, pandas, numpy, LightGBM, pytest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| 파일 | 변경 유형 | 역할 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `src/dataset_builder.py` | Modify | 학습 전용 상수 추가 + 기본값 변경 |
|
||||||
|
| `scripts/train_model.py` | Modify | 하드코딩된 `negative_ratio=5` → 기본값 사용으로 전환 |
|
||||||
|
| `scripts/train_mlx_model.py` | Modify | 동일 |
|
||||||
|
| `scripts/tune_hyperparams.py` | Modify | 동일 |
|
||||||
|
| `src/backtester.py` | Modify | `WalkForwardConfig.negative_ratio` 기본값 변경 |
|
||||||
|
| `tests/test_dataset_builder.py` | Modify | 완화된 기본값 반영 |
|
||||||
|
| `tests/test_ml_pipeline_fixes.py` | Modify | 새 기본값 검증 테스트 추가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: dataset_builder.py에 학습 전용 상수 추가 + 기본값 변경
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/dataset_builder.py:14-17, 387-397`
|
||||||
|
- Test: `tests/test_ml_pipeline_fixes.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 테스트 작성**
|
||||||
|
|
||||||
|
`tests/test_ml_pipeline_fixes.py`에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_training_defaults_are_relaxed(signal_df):
|
||||||
|
"""generate_dataset_vectorized의 기본 임계값이 학습용 완화 값이어야 한다."""
|
||||||
|
from src.dataset_builder import (
|
||||||
|
TRAIN_SIGNAL_THRESHOLD, TRAIN_ADX_THRESHOLD,
|
||||||
|
TRAIN_VOLUME_MULTIPLIER, TRAIN_NEGATIVE_RATIO,
|
||||||
|
)
|
||||||
|
assert TRAIN_SIGNAL_THRESHOLD == 2
|
||||||
|
assert TRAIN_ADX_THRESHOLD == 15.0
|
||||||
|
assert TRAIN_VOLUME_MULTIPLIER == 1.5
|
||||||
|
assert TRAIN_NEGATIVE_RATIO == 3
|
||||||
|
|
||||||
|
# 완화된 기본값으로 샘플이 더 많이 생성되는지 검증
|
||||||
|
r_relaxed = generate_dataset_vectorized(signal_df)
|
||||||
|
r_strict = generate_dataset_vectorized(
|
||||||
|
signal_df, signal_threshold=3, adx_threshold=25, volume_multiplier=2.5,
|
||||||
|
)
|
||||||
|
assert len(r_relaxed) >= len(r_strict), \
|
||||||
|
f"완화된 임계값이 더 많은 샘플을 생성해야 한다: relaxed={len(r_relaxed)}, strict={len(r_strict)}"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py::test_training_defaults_are_relaxed -v`
|
||||||
|
Expected: FAIL — `ImportError: cannot import name 'TRAIN_SIGNAL_THRESHOLD'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: dataset_builder.py 수정**
|
||||||
|
|
||||||
|
`src/dataset_builder.py` 상단 상수 블록(line 14-17)을 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 뷰
|
||||||
|
ATR_SL_MULT = 2.0 # config.py 기본값과 동일 (서빙 환경 일치)
|
||||||
|
ATR_TP_MULT = 2.0
|
||||||
|
WARMUP = 60 # 15분봉 기준 60캔들 = 15시간 (지표 안정화 충분)
|
||||||
|
|
||||||
|
# ── 학습 전용 기본값 ──────────────────────────────────────────────
|
||||||
|
# 실전 봇(config.py)보다 완화된 임계값으로 더 많은 신호를 수집한다.
|
||||||
|
# ML 모델이 약한 신호 중에서 좋은 기회를 구분하는 법을 학습한다.
|
||||||
|
# 실전 진입은 bot.py의 엄격한 5단 게이트 + ML 필터가 최종 판단.
|
||||||
|
TRAIN_SIGNAL_THRESHOLD = 2 # 실전: 3 (config.py)
|
||||||
|
TRAIN_ADX_THRESHOLD = 15.0 # 실전: 25.0
|
||||||
|
TRAIN_VOLUME_MULTIPLIER = 1.5 # 실전: 2.5
|
||||||
|
TRAIN_NEGATIVE_RATIO = 3 # HOLD 네거티브 비율 (기존: 5)
|
||||||
|
```
|
||||||
|
|
||||||
|
`generate_dataset_vectorized()` 시그니처(line 387-397)의 기본값을 변경:
|
||||||
|
|
||||||
|
```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 = TRAIN_NEGATIVE_RATIO, # 변경: 0 → 3
|
||||||
|
signal_threshold: int = TRAIN_SIGNAL_THRESHOLD, # 변경: 3 → 2
|
||||||
|
adx_threshold: float = TRAIN_ADX_THRESHOLD, # 변경: 25 → 15
|
||||||
|
volume_multiplier: float = TRAIN_VOLUME_MULTIPLIER, # 변경: 2.5 → 1.5
|
||||||
|
atr_sl_mult: float = ATR_SL_MULT,
|
||||||
|
atr_tp_mult: float = ATR_TP_MULT,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
```
|
||||||
|
|
||||||
|
또한 `_calc_signals()`(line 57-61)의 기본값도 학습 상수로 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _calc_signals(
|
||||||
|
d: pd.DataFrame,
|
||||||
|
signal_threshold: int = TRAIN_SIGNAL_THRESHOLD, # 변경: 3 → 2
|
||||||
|
adx_threshold: float = TRAIN_ADX_THRESHOLD, # 변경: 25 → 15
|
||||||
|
volume_multiplier: float = TRAIN_VOLUME_MULTIPLIER, # 변경: 2.5 → 1.5
|
||||||
|
) -> np.ndarray:
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py tests/test_dataset_builder.py -v`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/dataset_builder.py tests/test_ml_pipeline_fixes.py
|
||||||
|
git commit -m "feat(ml): add TRAIN_* constants with relaxed thresholds for more training samples"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 호출부에서 하드코딩된 값 제거
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/train_model.py`
|
||||||
|
- Modify: `scripts/train_mlx_model.py`
|
||||||
|
- Modify: `scripts/tune_hyperparams.py`
|
||||||
|
- Modify: `src/backtester.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: train_model.py — 하드코딩 negative_ratio=5 제거**
|
||||||
|
|
||||||
|
`train()`, `walk_forward_auc()`, `compare()` 내 `generate_dataset_vectorized()` 호출에서 `negative_ratio=5`를 삭제하여 기본값(`TRAIN_NEGATIVE_RATIO=3`)을 사용하도록 변경.
|
||||||
|
|
||||||
|
변경 전 (3곳):
|
||||||
|
```python
|
||||||
|
dataset = generate_dataset_vectorized(
|
||||||
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
|
time_weight_decay=time_weight_decay,
|
||||||
|
negative_ratio=5,
|
||||||
|
atr_sl_mult=atr_sl_mult,
|
||||||
|
atr_tp_mult=atr_tp_mult,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
변경 후:
|
||||||
|
```python
|
||||||
|
dataset = generate_dataset_vectorized(
|
||||||
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
|
time_weight_decay=time_weight_decay,
|
||||||
|
atr_sl_mult=atr_sl_mult,
|
||||||
|
atr_tp_mult=atr_tp_mult,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: train_mlx_model.py — 동일 변경**
|
||||||
|
|
||||||
|
`train_mlx()`와 `walk_forward_auc()` 내 `negative_ratio=5` 삭제 (2곳).
|
||||||
|
|
||||||
|
- [ ] **Step 3: tune_hyperparams.py — 동일 변경**
|
||||||
|
|
||||||
|
`load_dataset()` 내 `negative_ratio=5` 삭제 (1곳).
|
||||||
|
|
||||||
|
- [ ] **Step 4: backtester.py — WalkForwardConfig 기본값 변경**
|
||||||
|
|
||||||
|
`WalkForwardConfig` 데이터클래스(~line 601)에서:
|
||||||
|
|
||||||
|
변경 전:
|
||||||
|
```python
|
||||||
|
negative_ratio: int = 5
|
||||||
|
```
|
||||||
|
|
||||||
|
변경 후:
|
||||||
|
```python
|
||||||
|
negative_ratio: int = 3
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 전체 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/train_model.py scripts/train_mlx_model.py scripts/tune_hyperparams.py src/backtester.py
|
||||||
|
git commit -m "refactor(ml): remove hardcoded negative_ratio=5, use dataset_builder defaults"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: 기존 테스트 기본값 정합성 확인 + 수정
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/test_dataset_builder.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 기존 테스트가 기본값 변경에 영향받는지 확인**
|
||||||
|
|
||||||
|
`tests/test_dataset_builder.py`의 기존 테스트 중 `generate_dataset_vectorized(sample_df)` 처럼 기본값에 의존하는 호출이 있음. 기본값이 완화되었으므로:
|
||||||
|
- `signal_threshold=2`에서 더 많은 신호가 발생 → 기존 테스트의 assertion이 깨질 수 있음
|
||||||
|
- `negative_ratio=3`이 기본값이 되므로, 기본 호출 시 HOLD 네거티브가 포함됨
|
||||||
|
|
||||||
|
기존 테스트가 실패하면, **원래 의도를 유지하면서** 명시적 파라미터를 추가:
|
||||||
|
|
||||||
|
예: `test_returns_dataframe`이 기본 호출로 충분한 결과를 기대한다면 그대로 동작할 가능성이 높음. 하지만 `test_has_required_columns`에서 "source" 컬럼 유무가 달라질 수 있음 (negative_ratio=3 → source 컬럼 존재).
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실행 및 실패 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_dataset_builder.py -v`
|
||||||
|
|
||||||
|
실패하는 테스트를 파악하고, 각각 수정:
|
||||||
|
- 기본값에 의존하는 테스트에 명시적 파라미터 추가 (기존 동작 테스트 시 `signal_threshold=3, adx_threshold=25, volume_multiplier=2.5, negative_ratio=0` 명시)
|
||||||
|
- 또는 새 기본값에서도 assertion이 유효하면 그대로 둠
|
||||||
|
|
||||||
|
- [ ] **Step 3: 전체 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/test_dataset_builder.py
|
||||||
|
git commit -m "test: update dataset_builder tests for relaxed training defaults"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: CLAUDE.md 업데이트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: CLAUDE.md plan history 업데이트**
|
||||||
|
|
||||||
|
plan history 테이블에 추가:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| 2026-03-21 | `training-threshold-relaxation` (plan) | Completed |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 최종 전체 테스트**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs: update plan history with training-threshold-relaxation"
|
||||||
|
```
|
||||||
192
docs/plans/2026-03-22-backtest-market-context-design.md
Normal file
192
docs/plans/2026-03-22-backtest-market-context-design.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# 백테스트 시장 컨텍스트 리포트 설계
|
||||||
|
|
||||||
|
**일자**: 2026-03-22
|
||||||
|
**상태**: 설계 완료, 구현 대기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
Walk-Forward 백테스트 결과를 해석할 때, 각 폴드 기간의 시장 상황(BTC/ETH 추세, L/S ratio)을 함께 보여준다. **"왜 이 폴드에서 졌는가"**를 구조적으로 이해하기 위한 참조 데이터이며, 트레이딩 시그널이나 ML 피처로는 사용하지 않는다.
|
||||||
|
|
||||||
|
## 접근 방식
|
||||||
|
|
||||||
|
Walk-Forward 폴드 테이블 출력 직후에 시장 컨텍스트 테이블 2개(Market Regime + L/S Ratio)를 추가한다. 기존 `scripts/run_backtest.py`만 수정하며, 별도 CLI 명령어는 만들지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 소스
|
||||||
|
|
||||||
|
### 1. BTC/ETH 가격 데이터 (Market Regime)
|
||||||
|
|
||||||
|
- **소스**: XRP의 `data/xrpusdt/combined_15m.parquet`에 임베딩된 `close_btc`, `high_btc`, `low_btc`, `close_eth`, `high_eth`, `low_eth` 컬럼
|
||||||
|
- 별도 `data/btcusdt/combined_15m.parquet` 파일은 로컬/프로덕션 모두 **존재하지 않음**
|
||||||
|
- 백테스터가 이미 이 임베딩 컬럼을 로딩하므로 추가 데이터 fetch 불필요
|
||||||
|
- 폴드 기간별로 슬라이싱하여 수익률, ADX 계산
|
||||||
|
|
||||||
|
### 2. L/S Ratio 데이터
|
||||||
|
|
||||||
|
- **소스**: `data/{symbol}/ls_ratio_15m.parquet` (로컬 파일)
|
||||||
|
- **심볼**: XRPUSDT, BTCUSDT, ETHUSDT
|
||||||
|
- **주기**: 15m
|
||||||
|
- **컬럼**: `timestamp` (datetime64[ms, UTC]), `top_acct_ls_ratio` (float64), `global_ls_ratio` (float64)
|
||||||
|
|
||||||
|
#### 현재 데이터 상태
|
||||||
|
|
||||||
|
- L/S ratio collector는 운영 LXC(`10.1.10.24`)에서 가동 중 (commit `e2b0454`, 2026-03-22~)
|
||||||
|
- **프로덕션**: XRP/BTC/ETH 각 3건 (2026-03-22 13:15 ~ 13:45 UTC), 계속 축적 중
|
||||||
|
- **로컬**: XRP 2건, BTC 2건, ETH 2건 (로컬 collector 테스트 시 생성된 데이터)
|
||||||
|
- 과거 폴드(2025-06, 2025-09, 2025-12)에 대한 L/S ratio 데이터는 **존재하지 않음**
|
||||||
|
- Binance API는 최근 30일만 historical 제공 → 과거 데이터 복구 불가능
|
||||||
|
|
||||||
|
#### 데이터 동기화
|
||||||
|
|
||||||
|
구현 전 프로덕션 LXC에서 L/S ratio parquet 파일을 로컬로 복사해야 한다:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp root@10.1.10.24:/root/cointrader/data/xrpusdt/ls_ratio_15m.parquet data/xrpusdt/
|
||||||
|
scp root@10.1.10.24:/root/cointrader/data/btcusdt/ls_ratio_15m.parquet data/btcusdt/
|
||||||
|
scp root@10.1.10.24:/root/cointrader/data/ethusdt/ls_ratio_15m.parquet data/ethusdt/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Fallback 전략
|
||||||
|
|
||||||
|
1. **로컬 parquet 우선**: `data/{symbol}/ls_ratio_15m.parquet`에서 폴드 기간 데이터 조회
|
||||||
|
2. **파일 없거나 해당 기간 데이터 없으면 `N/A`**: 폴드의 L/S ratio 셀을 `N/A`로 표시
|
||||||
|
3. **전체 폴드가 N/A이면 L/S ratio 테이블 자체를 생략**: 불필요한 N/A 테이블을 출력하지 않음
|
||||||
|
4. **Binance API에서 실시간 fetch하지 않음**: 백테스트는 오프라인 재현 가능해야 함
|
||||||
|
5. **시간이 지나면 해결됨**: collector가 계속 수집하므로, 데이터 축적 후 백테스트에 자연스럽게 반영
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Market Regime 분류 기준
|
||||||
|
|
||||||
|
BTC ADX와 수익률 기반으로 **코드에 명확히 정의**하여 주관적 해석을 방지한다:
|
||||||
|
|
||||||
|
| 조건 | 라벨 |
|
||||||
|
|------|------|
|
||||||
|
| ADX ≥ 25 and return > 0 | 상승 추세 |
|
||||||
|
| ADX ≥ 25 and return < 0 | 하락 추세 |
|
||||||
|
| ADX < 25 | 횡보 |
|
||||||
|
|
||||||
|
- ADX는 폴드 기간 내 BTC 15m 캔들(`high_btc`, `low_btc`, `close_btc`)로 계산한 **기간 평균 ADX** (`pandas_ta.adx(length=14)` 사용)
|
||||||
|
- return은 폴드 시작가 대비 종료가의 **단순 수익률** (`close_btc`)
|
||||||
|
- 라벨 뒤에 `(BTC ADX {값:.0f})` 형태로 실제 수치 병기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 출력 형식
|
||||||
|
|
||||||
|
기존 폴드 테이블 바로 아래에 출력:
|
||||||
|
|
||||||
|
```
|
||||||
|
📊 Market Context per Fold
|
||||||
|
┌──────┬──────────────┬──────────────┬─────────────────────────────────┐
|
||||||
|
│ Fold │ BTC Return │ ETH Return │ Market Regime │
|
||||||
|
├──────┼──────────────┼──────────────┼─────────────────────────────────┤
|
||||||
|
│ 1 │ +12.3% │ +8.7% │ 상승 추세 (BTC ADX 32) │
|
||||||
|
│ 2 │ -2.1% │ -5.4% │ 횡보 (BTC ADX 18) │
|
||||||
|
│ 3 │ +25.6% │ +18.2% │ 상승 추세 (BTC ADX 41) │
|
||||||
|
└──────┴──────────────┴──────────────┴─────────────────────────────────┘
|
||||||
|
|
||||||
|
📊 L/S Ratio Context per Fold (period avg)
|
||||||
|
┌──────┬──────────────────┬──────────────────┬──────────────────┐
|
||||||
|
│ Fold │ XRP Top/Global │ BTC Top/Global │ ETH Top/Global │
|
||||||
|
├──────┼──────────────────┼──────────────────┼──────────────────┤
|
||||||
|
│ 1 │ N/A │ N/A │ N/A │
|
||||||
|
│ 2 │ N/A │ N/A │ N/A │
|
||||||
|
│ 3 │ 1.15 / 0.98 │ 0.95 / 1.02 │ 1.08 / 1.05 │
|
||||||
|
└──────┴──────────────────┴──────────────────┴──────────────────┘
|
||||||
|
→ Fold 1~2: L/S ratio 데이터 없음 (collector 가동 전)
|
||||||
|
→ Fold 3: 데이터 가용
|
||||||
|
```
|
||||||
|
|
||||||
|
**전체 폴드가 N/A인 경우** (현재 상태에서 과거 데이터만으로 백테스트하면):
|
||||||
|
|
||||||
|
```
|
||||||
|
📊 Market Context per Fold
|
||||||
|
┌──────┬──────────────┬──────────────┬─────────────────────────────────┐
|
||||||
|
│ Fold │ BTC Return │ ETH Return │ Market Regime │
|
||||||
|
├──────┼──────────────┼──────────────┼─────────────────────────────────┤
|
||||||
|
│ 1 │ +12.3% │ +8.7% │ 상승 추세 (BTC ADX 32) │
|
||||||
|
│ 2 │ -2.1% │ -5.4% │ 횡보 (BTC ADX 18) │
|
||||||
|
│ 3 │ +25.6% │ +18.2% │ 상승 추세 (BTC ADX 41) │
|
||||||
|
└──────┴──────────────┴──────────────┴─────────────────────────────────┘
|
||||||
|
ℹ️ L/S ratio 데이터 없음 — collector 데이터 축적 후 표시됩니다
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON 출력
|
||||||
|
|
||||||
|
walk-forward 결과 JSON에도 `market_context` 필드 추가:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"folds": [
|
||||||
|
{
|
||||||
|
"fold": 1,
|
||||||
|
"test_period": "2025-06-07 ~ 2025-07-06",
|
||||||
|
"test_start": "2025-06-07T00:00:00",
|
||||||
|
"test_end": "2025-07-06T00:00:00",
|
||||||
|
"summary": { "..." : "..." },
|
||||||
|
"market_context": {
|
||||||
|
"btc_return_pct": 12.3,
|
||||||
|
"eth_return_pct": 8.7,
|
||||||
|
"btc_avg_adx": 32.1,
|
||||||
|
"market_regime": "상승 추세",
|
||||||
|
"ls_ratio": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 3,
|
||||||
|
"test_period": "2026-03-01 ~ 2026-04-01",
|
||||||
|
"test_start": "2026-03-01T00:00:00",
|
||||||
|
"test_end": "2026-04-01T00:00:00",
|
||||||
|
"summary": { "..." : "..." },
|
||||||
|
"market_context": {
|
||||||
|
"btc_return_pct": 5.2,
|
||||||
|
"eth_return_pct": 3.1,
|
||||||
|
"btc_avg_adx": 28.5,
|
||||||
|
"market_regime": "상승 추세",
|
||||||
|
"ls_ratio": {
|
||||||
|
"xrp": { "top_acct_avg": 1.15, "global_avg": 0.98 },
|
||||||
|
"btc": { "top_acct_avg": 0.95, "global_avg": 1.02 },
|
||||||
|
"eth": { "top_acct_avg": 1.08, "global_avg": 1.05 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 수정 대상 파일
|
||||||
|
|
||||||
|
| 파일 | 변경 유형 | 역할 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `scripts/run_backtest.py` | Modify | 시장 컨텍스트 계산 + 출력 함수 추가 |
|
||||||
|
| `src/backtester.py` | Modify (최소) | 폴드 결과에 `test_start`/`test_end`를 timestamp로 노출 (현재는 문자열 `test_period`만 있음) |
|
||||||
|
|
||||||
|
### 변경하지 않는 것
|
||||||
|
|
||||||
|
- `src/indicators.py` — ADX 계산은 `run_backtest.py` 내에서 `pandas_ta.adx()` 직접 사용
|
||||||
|
- `scripts/collect_ls_ratio.py` — 기존 collector 로직 변경 없음
|
||||||
|
- `src/ml_filter.py`, `src/ml_features.py` — ML 피처와 무관
|
||||||
|
- `scripts/fetch_history.py` — BTC/ETH 별도 fetch 불필요 (XRP parquet에 임베딩됨)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 전 선행 작업
|
||||||
|
|
||||||
|
1. ~~BTC/ETH 히스토리 데이터 fetch~~ → **불필요** (XRP parquet에 `close_btc`, `close_eth` 등 임베딩됨)
|
||||||
|
2. `backtester.py`에서 `test_start`/`test_end`를 timestamp로 노출하도록 수정
|
||||||
|
3. 프로덕션 LXC에서 L/S ratio parquet 파일 로컬 동기화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 범위 제한
|
||||||
|
|
||||||
|
- **참조 전용**: 시장 컨텍스트는 출력/리포트에만 사용. 트레이딩 로직에 영향 없음
|
||||||
|
- **오프라인 우선**: Binance API 호출 없음. 로컬 데이터만 사용
|
||||||
|
- **기존 테스트 영향 없음**: 출력 함수 추가이므로 기존 백테스트 로직 불변
|
||||||
|
- **L/S ratio 테이블 조건부 출력**: 전체 N/A이면 테이블 생략, 한 줄 안내 메시지만 출력
|
||||||
242
docs/plans/2026-03-22-testnet-uds-verification-design.md
Normal file
242
docs/plans/2026-03-22-testnet-uds-verification-design.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
# Testnet UDS 검증 설계
|
||||||
|
|
||||||
|
**일자**: 2026-03-22
|
||||||
|
**상태**: 설계 완료, 구현 대기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
Binance Futures Testnet에서 User Data Stream(UDS)의 reconnect 동작을 검증한다. 현재 프로덕션 15분봉 설정 그대로 testnet에 연결하여, UDS 연결 → ~30분 후 reconnect → ORDER_TRADE_UPDATE 수신까지 전체 경로가 정상 작동하는지 확인한다.
|
||||||
|
|
||||||
|
**이것은 UDS 검증 전용이다.** 1분봉 전환, 125x 레버리지, ML 파이프라인 변경은 포함하지 않는다. 기존 설계(`2026-03-03-testnet-1m-125x`)는 ML OFF 확정 후 전제가 바뀌었으므로 별도 취급한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 접근 방식
|
||||||
|
|
||||||
|
python-binance 1.0.35에서 `testnet=True` 파라미터가 REST API와 WebSocket(kline + User Data Stream) 모두 자동 라우팅한다. 별도 URL 오버라이드 불필요.
|
||||||
|
|
||||||
|
**검증된 라우팅 경로 (python-binance 소스 확인):**
|
||||||
|
- REST API: `https://testnet.binancefuture.com`
|
||||||
|
- Kline WebSocket: `wss://stream.binancefuture.com/` (`BinanceSocketManager._get_futures_socket()`에서 `self.testnet` 체크)
|
||||||
|
- User Data Stream WebSocket: `wss://stream.binancefuture.com/` (`futures_user_socket()`에서 `self.testnet` 체크)
|
||||||
|
|
||||||
|
`AsyncClient.create(testnet=True)` → `BinanceSocketManager(client)` → `client.testnet` 플래그가 자동 전파.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 수정 대상 파일
|
||||||
|
|
||||||
|
| 파일 | 변경 내용 |
|
||||||
|
|------|----------|
|
||||||
|
| `src/config.py` | `testnet: bool` 필드 추가, `BINANCE_TESTNET` env var 파싱, testnet이면 testnet API key 사용 |
|
||||||
|
| `src/exchange.py` | `Client(..., testnet=config.testnet)` 전달 |
|
||||||
|
| `src/user_data_stream.py` | `AsyncClient.create(..., testnet=testnet)` 전달 |
|
||||||
|
| `src/data_stream.py` | `AsyncClient.create(..., testnet=testnet)` 전달 (KlineStream + MultiSymbolStream) |
|
||||||
|
| `src/notifier.py` | testnet일 때 Discord 메시지에 `[TESTNET]` 접두사 추가 |
|
||||||
|
| `src/bot.py` | testnet 플래그를 각 스트림/notifier에 전달 + trade_history 경로 분리 + 시작 시 TESTNET 경고 로그 |
|
||||||
|
|
||||||
|
### 변경하지 않는 것
|
||||||
|
|
||||||
|
- 지표 계산 (`src/indicators.py`) — 그대로
|
||||||
|
- ML 필터 (`src/ml_filter.py`) — NO_ML_FILTER=true 상태 그대로
|
||||||
|
- 학습 파이프라인 — 변경 없음
|
||||||
|
- 리스크 매니저 — 그대로
|
||||||
|
- Discord 알림 — testnet일 때 메시지에 `[TESTNET]` 접두사 추가 (아래 상세 변경 참조)
|
||||||
|
- `.env` 프로덕션 설정 — 변경 없음 (BINANCE_TESTNET 추가만)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 상세 변경
|
||||||
|
|
||||||
|
### 1. Config (`src/config.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 필드 추가
|
||||||
|
testnet: bool = False
|
||||||
|
|
||||||
|
# __post_init__에서:
|
||||||
|
self.testnet = os.getenv("BINANCE_TESTNET", "").lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
if self.testnet:
|
||||||
|
self.api_key = os.getenv("BINANCE_TESTNET_API_KEY", "")
|
||||||
|
self.api_secret = os.getenv("BINANCE_TESTNET_API_SECRET", "")
|
||||||
|
else:
|
||||||
|
self.api_key = os.getenv("BINANCE_API_KEY", "")
|
||||||
|
self.api_secret = os.getenv("BINANCE_API_SECRET", "")
|
||||||
|
```
|
||||||
|
|
||||||
|
- testnet이면 `BINANCE_TESTNET_API_KEY/SECRET` 사용
|
||||||
|
- 나머지 설정(SYMBOLS, LEVERAGE 등)은 동일하게 적용
|
||||||
|
|
||||||
|
### 2. Exchange (`src/exchange.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 현재:
|
||||||
|
self.client = Client(
|
||||||
|
api_key=config.api_key,
|
||||||
|
api_secret=config.api_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 변경:
|
||||||
|
self.client = Client(
|
||||||
|
api_key=config.api_key,
|
||||||
|
api_secret=config.api_secret,
|
||||||
|
testnet=config.testnet,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. UserDataStream (`src/user_data_stream.py`)
|
||||||
|
|
||||||
|
`_run_loop()` 시그니처에 `testnet` 파라미터 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# start()에 testnet 파라미터 추가
|
||||||
|
async def start(self, api_key: str, api_secret: str, testnet: bool = False) -> None:
|
||||||
|
...
|
||||||
|
await self._run_loop(api_key, api_secret, testnet)
|
||||||
|
|
||||||
|
# _run_loop()에서 AsyncClient.create에 전달
|
||||||
|
async def _run_loop(self, api_key: str, api_secret: str, testnet: bool = False) -> None:
|
||||||
|
while True:
|
||||||
|
client = await AsyncClient.create(
|
||||||
|
api_key=api_key,
|
||||||
|
api_secret=api_secret,
|
||||||
|
testnet=testnet,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. DataStream (`src/data_stream.py`)
|
||||||
|
|
||||||
|
KlineStream.start()과 MultiSymbolStream.start() 모두 동일 패턴:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def start(self, api_key: str, api_secret: str, testnet: bool = False):
|
||||||
|
client = await AsyncClient.create(
|
||||||
|
api_key=api_key,
|
||||||
|
api_secret=api_secret,
|
||||||
|
testnet=testnet,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
MultiSymbolStream._run_loop()에서도 reconnect 시 AsyncClient.create에 testnet 전달.
|
||||||
|
|
||||||
|
### 5. Notifier (`src/notifier.py`)
|
||||||
|
|
||||||
|
testnet일 때 Discord 메시지에 `[TESTNET]` 접두사를 추가하여 프로덕션 알림과 구분:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Notifier.__init__()에 testnet 파라미터 추가
|
||||||
|
def __init__(self, webhook_url: str, testnet: bool = False):
|
||||||
|
self.webhook_url = webhook_url
|
||||||
|
self.testnet = testnet
|
||||||
|
|
||||||
|
# 메시지 전송 시 접두사 추가
|
||||||
|
async def _send(self, content: str):
|
||||||
|
if self.testnet:
|
||||||
|
content = f"[TESTNET] {content}"
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Bot에서 Notifier 생성 시 `testnet=self.config.testnet` 전달.
|
||||||
|
|
||||||
|
### 6. Bot (`src/bot.py`)
|
||||||
|
|
||||||
|
**시작 로그에 TESTNET 명시 (warning 레벨):**
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def run(self):
|
||||||
|
if self.config.testnet:
|
||||||
|
logger.warning("⚠️ TESTNET MODE ENABLED — 실제 자금이 아닌 테스트넷에서 실행 중")
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**stream.start()와 user_stream.start()에 testnet 전달:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
await asyncio.gather(
|
||||||
|
self.stream.start(
|
||||||
|
api_key=self.config.api_key,
|
||||||
|
api_secret=self.config.api_secret,
|
||||||
|
testnet=self.config.testnet,
|
||||||
|
),
|
||||||
|
user_stream.start(
|
||||||
|
api_key=self.config.api_key,
|
||||||
|
api_secret=self.config.api_secret,
|
||||||
|
testnet=self.config.testnet,
|
||||||
|
),
|
||||||
|
self._position_monitor(),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**trade_history 경로 분리:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 현재 (line 24):
|
||||||
|
_TRADE_HISTORY_DIR = Path("data/trade_history")
|
||||||
|
|
||||||
|
# 변경 — _trade_history_path() 메서드에서 분기:
|
||||||
|
def _trade_history_path(self) -> Path:
|
||||||
|
base = Path("data/trade_history")
|
||||||
|
if self.config.testnet:
|
||||||
|
base = base / "testnet"
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
return base / f"{self.symbol.lower()}.jsonl"
|
||||||
|
```
|
||||||
|
|
||||||
|
- testnet: `data/trade_history/testnet/xrpusdt.jsonl`
|
||||||
|
- production: `data/trade_history/xrpusdt.jsonl` (기존과 동일)
|
||||||
|
- Kill Switch 판정이 testnet 트레이드로 오염되지 않음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## .env 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 기존 프로덕션 설정 유지 + 아래 추가
|
||||||
|
BINANCE_TESTNET=true # testnet 모드 활성화
|
||||||
|
BINANCE_TESTNET_API_KEY=xxx # testnet.binancefuture.com에서 발급
|
||||||
|
BINANCE_TESTNET_API_SECRET=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
- `BINANCE_TESTNET=true`를 설정하면 testnet 모드로 전환
|
||||||
|
- 프로덕션 복귀 시 `BINANCE_TESTNET=false` 또는 줄 삭제
|
||||||
|
|
||||||
|
**주의**: .env에 이미 `BINANCE_TESTNET_API_KEY`/`BINANCE_TESTNET_API_SECRET` 자리가 마련되어 있음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 절차
|
||||||
|
|
||||||
|
### 1단계: Testnet API 키 발급
|
||||||
|
|
||||||
|
- `testnet.binancefuture.com` 접속 → API 키 발급
|
||||||
|
- `.env`에 설정
|
||||||
|
|
||||||
|
### 2단계: 봇 실행 + UDS 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env에 BINANCE_TESTNET=true 설정 후
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
확인 사항:
|
||||||
|
1. 시작 로그에 testnet 표시 확인
|
||||||
|
2. User Data Stream 연결 로그 확인
|
||||||
|
3. ~30분 대기 → reconnect 발생하는지 확인
|
||||||
|
4. reconnect 후 ORDER_TRADE_UPDATE 수신되는지 확인
|
||||||
|
5. trade_history가 `data/trade_history/testnet/` 에 기록되는지 확인
|
||||||
|
|
||||||
|
### 3단계: Kill Switch 경로 확인
|
||||||
|
|
||||||
|
- testnet 트레이드가 `data/trade_history/testnet/xrpusdt.jsonl`에만 기록되는지 확인
|
||||||
|
- 프로덕션 `data/trade_history/xrpusdt.jsonl`이 변경되지 않았는지 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 주의사항
|
||||||
|
|
||||||
|
- **테스트넷 가격은 실제 시장과 다름**: 전략 성과 판단 불가, UDS 동작 검증만 목적
|
||||||
|
- **trade_history 분리 필수**: testnet 트레이드가 프로덕션 Kill Switch를 오염시키면 안 됨
|
||||||
|
- **프로덕션 배포 시 BINANCE_TESTNET 제거 확인**: `.env`에 `BINANCE_TESTNET=true`가 남아있으면 프로덕션이 testnet으로 연결됨
|
||||||
132
docs/plans/2026-03-23-algo-order-fix-design.md
Normal file
132
docs/plans/2026-03-23-algo-order-fix-design.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Algo Order 호환성 수정 설계
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
실전 바이낸스 API 검증 결과, 조건부 주문(STOP_MARKET, TAKE_PROFIT_MARKET)이 Algo Order로 처리되며 테스트넷과 동작이 다름이 확인됨.
|
||||||
|
|
||||||
|
### 검증 결과 요약
|
||||||
|
|
||||||
|
| 항목 | 테스트넷 | 실전 |
|
||||||
|
|------|---------|------|
|
||||||
|
| SL/TP 응답 | `orderId` 반환 | `algoId`만 반환, orderId=None |
|
||||||
|
| SL 트리거 UDS | `ot=STOP_MARKET` | `ot=MARKET` |
|
||||||
|
| SL 후 TP 자동만료 | EXPIRED 이벤트 수신 | 만료 안 됨 → 고아주문 |
|
||||||
|
| `get_open_orders()` | algo 주문 조회됨 | algo 주문 조회 안 됨 |
|
||||||
|
| `cancel_all_orders()` | algo 주문 취소됨 | algo 주문 취소 안 됨 |
|
||||||
|
| UDS `i` 필드 vs 배치 ID | 동일 | `i` ≠ `algoId` (서로 다른 값) |
|
||||||
|
|
||||||
|
## 수정 대상 파일
|
||||||
|
|
||||||
|
1. `src/exchange.py` — algo API 병행 호출
|
||||||
|
2. `src/bot.py` — SL/TP 가격 저장, close_reason 판별, 복구 로직
|
||||||
|
3. `src/user_data_stream.py` — 가격 기반 close_reason 판별
|
||||||
|
4. `tests/` — 변경사항 반영
|
||||||
|
|
||||||
|
## 설계
|
||||||
|
|
||||||
|
### 1. exchange.py: Algo API 병행
|
||||||
|
|
||||||
|
**`cancel_all_orders()`**: 일반 주문 취소 + algo 주문 전체 취소를 모두 호출.
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def cancel_all_orders(self):
|
||||||
|
await self._run_api(
|
||||||
|
lambda: self.client.futures_cancel_all_open_orders(symbol=self.symbol)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self._run_api(
|
||||||
|
lambda: self.client.futures_cancel_all_algo_open_orders(symbol=self.symbol)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # algo 주문 없으면 실패 가능 — 무시
|
||||||
|
```
|
||||||
|
|
||||||
|
**`cancel_order()`**: ID 크기나 타입으로 분기하지 않고, 일반 취소 시도 → 실패 시 algo 취소 (현재와 동일, 이미 올바른 구조).
|
||||||
|
|
||||||
|
**`get_open_orders()`**: 일반 주문 + algo 주문을 병합 반환. algo 주문 응답의 필드명이 다르므로 정규화 필요.
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_open_orders(self) -> list[dict]:
|
||||||
|
orders = await self._run_api(
|
||||||
|
lambda: self.client.futures_get_open_orders(symbol=self.symbol)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
algo_orders = await self._run_api(
|
||||||
|
lambda: self.client.futures_get_algo_open_orders(symbol=self.symbol)
|
||||||
|
)
|
||||||
|
for ao in algo_orders.get("orders", []):
|
||||||
|
orders.append({
|
||||||
|
"orderId": ao.get("algoId"),
|
||||||
|
"type": ao.get("orderType"), # STOP_MARKET / TAKE_PROFIT_MARKET
|
||||||
|
"stopPrice": ao.get("triggerPrice"),
|
||||||
|
"side": ao.get("side"),
|
||||||
|
"status": ao.get("algoStatus"),
|
||||||
|
"_is_algo": True,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return orders
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. bot.py: SL/TP 가격 저장 + close_reason 판별
|
||||||
|
|
||||||
|
**새 필드 추가** (`__init__`):
|
||||||
|
```python
|
||||||
|
self._sl_price: float | None = None
|
||||||
|
self._tp_price: float | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
**`_open_position()`**: SL/TP 배치 후 가격 저장.
|
||||||
|
```python
|
||||||
|
# _place_sl_tp_with_retry 호출 전에 이미 stop_loss, take_profit 계산됨
|
||||||
|
self._sl_price = stop_loss
|
||||||
|
self._tp_price = take_profit
|
||||||
|
```
|
||||||
|
|
||||||
|
**`_ensure_sl_tp_orders()` (복구)**: 오픈 주문에서 SL/TP 가격 복원.
|
||||||
|
```python
|
||||||
|
for o in open_orders:
|
||||||
|
otype = o.get("type", "")
|
||||||
|
if otype == "STOP_MARKET":
|
||||||
|
self._sl_price = float(o.get("stopPrice", 0))
|
||||||
|
elif otype == "TAKE_PROFIT_MARKET":
|
||||||
|
self._tp_price = float(o.get("stopPrice", 0))
|
||||||
|
```
|
||||||
|
|
||||||
|
**`_on_position_closed()`**: close_reason이 "MANUAL"일 때 가격 비교로 재판별.
|
||||||
|
```python
|
||||||
|
if close_reason == "MANUAL" and self._sl_price and self._tp_price:
|
||||||
|
sl_dist = abs(exit_price - self._sl_price)
|
||||||
|
tp_dist = abs(exit_price - self._tp_price)
|
||||||
|
if sl_dist < tp_dist:
|
||||||
|
close_reason = "SL"
|
||||||
|
else:
|
||||||
|
close_reason = "TP"
|
||||||
|
```
|
||||||
|
|
||||||
|
**상태 초기화**: `_on_position_closed()` 및 `_close_and_reenter()`에서 포지션 Flat 전환 시:
|
||||||
|
```python
|
||||||
|
self._sl_price = None
|
||||||
|
self._tp_price = None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. user_data_stream.py: close_reason을 콜백에 위임
|
||||||
|
|
||||||
|
UDS의 close_reason 판별 로직은 유지하되, 콜백 시그니처에 `exit_price`가 이미 전달되므로 bot.py에서 재판별 가능. UDS 자체는 변경 최소화.
|
||||||
|
|
||||||
|
현재 UDS에서 `ot`로 판별 → 실전에서 `ot=MARKET` → `close_reason="MANUAL"` → bot.py에서 가격 비교로 SL/TP 재판별. 이 흐름이 테스트넷에서도 안전 (테스트넷은 `ot=STOP_MARKET`이 오므로 재판별 자체가 불필요).
|
||||||
|
|
||||||
|
### 4. 포지션 모니터 SYNC 경로 — 이미 구현됨
|
||||||
|
|
||||||
|
`_position_monitor()`의 SYNC 폴백에서 잔여주문 취소는 **이미 구현되어 있음**. 추가 수정 불필요.
|
||||||
|
|
||||||
|
> **참고**: `_place_sl_tp_with_retry()`의 algoId 저장도 이미 구현됨 (bot.py line 539, 550).
|
||||||
|
|
||||||
|
### 5. 테스트 계획
|
||||||
|
|
||||||
|
- 테스트넷에서 SL 트리거 → TP 고아주문 자동 취소 확인
|
||||||
|
- 테스트넷에서 TP 트리거 → SL 고아주문 자동 취소 확인
|
||||||
|
- 테스트넷에서 역방향 재진입 → 기존 SL/TP 취소 확인
|
||||||
|
- 봇 재시작 → SL/TP 가격 복원 확인
|
||||||
|
- close_reason이 SL/TP로 정확히 분류되는지 확인
|
||||||
|
- 위 모든 항목 통과 후 실전 배포
|
||||||
507
docs/plans/CoinTrader_종합검토보고서.md
Normal file
507
docs/plans/CoinTrader_종합검토보고서.md
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
# CoinTrader 프로젝트 종합 검토 보고서
|
||||||
|
|
||||||
|
**검토 일자**: 2026년 3월 7일
|
||||||
|
**검토자**: Claude AI
|
||||||
|
**대상**: CoinTrader — Binance Futures 자동매매 봇 (47개 설계/계획 문서 + README + ARCHITECTURE)
|
||||||
|
**기준**: 객관성, 내용의 정확성, 아키텍처 일관성, 코드 품질
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 요약
|
||||||
|
|
||||||
|
**프로젝트 상태**: 초기 단계 + 활발한 개발 중
|
||||||
|
|
||||||
|
CoinTrader는 Binance Futures에서 15분 봉의 기술 지표와 ML 필터를 결합하여 XRP, TRX, DOGE 등 다중 심볼을 동시 거래하는 자동매매 봇입니다. **5-레이어 아키텍처**(Data → Signal → ML Filter → Execution & Risk → Event/Alert)로 구성되어 있으며, 136개의 단위 테스트와 완전한 MLOps 파이프라인을 갖추고 있습니다.
|
||||||
|
|
||||||
|
그러나 **즉시 수정이 필요한 버그 2개**(OI division by zero, 누적 트레이드 계산 오류)와 **이번 주 중 해결해야 할 문제 4~5개**(API retry, Parquet 중복, async Lock, exit_price 방어)가 존재합니다. 또한 ML 필터가 현재 비활성화되어 있으며, 그 이유(학습 데이터 부족)가 타당해 보입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 아키텍처 분석
|
||||||
|
|
||||||
|
### 1.1 전체 구조의 강점
|
||||||
|
|
||||||
|
**✓ 명확한 5-레이어 분리**
|
||||||
|
- Layer 1 (Data): WebSocket 캔들 수신, Parquet 버퍼
|
||||||
|
- Layer 2 (Signal): 기술 지표 + 가중치 신호 생성
|
||||||
|
- Layer 3 (ML Filter): ONNX/LightGBM 선택적 활성화
|
||||||
|
- Layer 4 (Execution & Risk): 주문 실행 + 공유 RiskManager
|
||||||
|
- Layer 5 (Event/Alert): User Data Stream TP/SL 감지 + Discord
|
||||||
|
|
||||||
|
각 레이어가 단일 책임을 가지고 있으며, 의존성 방향이 명확함.
|
||||||
|
|
||||||
|
**✓ 멀티심볼 동시 거래의 실제 구현**
|
||||||
|
- 심볼별 **독립 TradingBot 인스턴스** → 각자 `Exchange`, `MLFilter`, `DataStream` 소유
|
||||||
|
- **공유 RiskManager (싱글턴)** → asyncio.Lock으로 일일 손실 한도, 동일 방향 제한 관리
|
||||||
|
- `asyncio.gather()`로 병렬 실행 → 심볼 간 간섭 없음
|
||||||
|
|
||||||
|
이는 멀티심볼 거래에서 흔한 함정(단일 데이터 경로의 병목, 공유 상태의 경쟁 조건)을 잘 피함.
|
||||||
|
|
||||||
|
**✓ 완전한 MLOps 파이프라인**
|
||||||
|
- 과거 데이터 수집 → 벡터화 데이터셋 생성 → LightGBM/MLX 학습
|
||||||
|
- Walk-Forward 5폴드 검증 → Optuna 하이퍼파라미터 튜닝
|
||||||
|
- 모델 핫리로드 (변경 감지 후 자동 로드)
|
||||||
|
- 주간 백테스트 리포트 + Discord 자동 알림
|
||||||
|
|
||||||
|
### 1.2 설계 결정의 타당성
|
||||||
|
|
||||||
|
**기술 지표 선택 (RSI, MACD, 볼린저, EMA, StochRSI, ADX)**
|
||||||
|
- 각 지표의 역할이 명확함 (과매수/과매도, 추세 전환, 가격 이탈, 추세 강도)
|
||||||
|
- 가중치 합산 시스템 (ADX ≥ 25 필터가 가장 효과적이라고 문서에서 언급)
|
||||||
|
- 전략 파라미터 스윕 결과: ADX=25 + Vol=2.5 조합에서 PF 1.57~2.39 달성
|
||||||
|
|
||||||
|
**ML 필터 현재 비활성화 (NO_ML_FILTER=true)**
|
||||||
|
- 이유: Walk-Forward 검증에서 각 폴드 학습 세트에 유효 신호가 ~27건으로 부족
|
||||||
|
- ADX + 거래량 배수만으로도 PF 1.5 이상 → ML 없이 운영하겠다는 판단은 보수적이고 합리적
|
||||||
|
- "충분한 거래 데이터(150건 이상) 축적 후 재활성화" 기준도 명확함
|
||||||
|
|
||||||
|
**동적 증증금 비율**
|
||||||
|
- 잔고가 늘어날수록 비율 감소 (과노출 방지)
|
||||||
|
- `MARGIN_MIN_RATIO=0.20`, `MARGIN_MAX_RATIO=0.50`, `DECAY_RATE=0.0006`
|
||||||
|
- 수식: `margin_ratio = MAX - (balance_growth) × DECAY_RATE` (선형 감소)
|
||||||
|
|
||||||
|
### 1.3 아키텍처의 약점
|
||||||
|
|
||||||
|
**△ User Data Stream TP/SL 감지 미테스트**
|
||||||
|
- 코드는 구현되어 있으나, WebSocket 의존성 때문에 테스트가 없음 (COVERAGE 6.3 표참조)
|
||||||
|
- 실제 운영 중 `ORDER_TRADE_UPDATE` 이벤트 처리에 버그가 있을 수 있음
|
||||||
|
- 특히 `exit_price = 0.0` 기본값 문제 (#8 이슈)가 이를 증명
|
||||||
|
|
||||||
|
**△ 반대 시그널 재진입의 경쟁 조건 가능성**
|
||||||
|
- `_is_reentering` 플래그로 보호하고 있으나, 극단적인 타이밍에서는 여전히 버그 가능
|
||||||
|
- 멀티심볼에서 각 심볼의 캔들 마감 시점이 다르면, 한 심볼의 청산 콜백이 다른 심볼의 신호 처리와 겹칠 수 있음
|
||||||
|
|
||||||
|
**△ Parquet Upsert 시 타임존 처리**
|
||||||
|
- `tz_localize("UTC")` 호출이 기존 데이터가 실제 UTC인지 검증하지 않음 (#13 이슈)
|
||||||
|
- OI/펀딩비 데이터가 다른 타임존이면 시계열 병합이 어긋남
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 코드 품질 분석
|
||||||
|
|
||||||
|
### 2.1 즉시 수정이 필요한 버그 (Critical)
|
||||||
|
|
||||||
|
#### 버그 #1: OI 변화율 계산 시 Division by Zero
|
||||||
|
**파일**: `src/bot.py:120`
|
||||||
|
**심각도**: 높음 (봇 크래시 가능)
|
||||||
|
**원인**: `_prev_oi == 0.0`일 때 `(current_oi - self._prev_oi) / self._prev_oi` 계산
|
||||||
|
**영향**: `get_open_interest()` API 실패 시 0.0 반환 → ZeroDivisionError 발생
|
||||||
|
**수정**: `if self._prev_oi == 0.0: oi_change = 0.0 else: oi_change = ...`
|
||||||
|
**예상 수정 시간**: 5분
|
||||||
|
|
||||||
|
#### 버그 #2: 누적 트레이드 수 계산 로직 오류
|
||||||
|
**파일**: `scripts/weekly_report.py:415-423`
|
||||||
|
**심각도**: 높음 (ML 재학습 트리거 오작동)
|
||||||
|
**원인**: `max(cumulative, prev_count)`로 최대값만 취함 → 누적이 아님
|
||||||
|
**영향**: ML 재학습 조건 "≥ 150 누적 거래" 판단 오류
|
||||||
|
**수정**: `cumulative += prev.get("live_trades", {}).get("count", 0)` (합산)
|
||||||
|
**예상 수정 시간**: 5분
|
||||||
|
|
||||||
|
### 2.2 이번 주 중 수정 권장 (Important)
|
||||||
|
|
||||||
|
#### 이슈 #3: Training-Serving Skew (OI/펀딩비 피처)
|
||||||
|
**파일**: `src/dataset_builder.py` vs `src/ml_features.py`
|
||||||
|
**심각도**: 중간 (ML 재활성화 시에만 영향)
|
||||||
|
**문제**:
|
||||||
|
- 학습: OI=0 구간 → NaN으로 마스킹 후 z-score 정규화
|
||||||
|
- 서빙: OI 값 → NaN으로 직접 설정
|
||||||
|
- 결과: 피처 분포 불일치 (학습/서빙 간 스큐)
|
||||||
|
|
||||||
|
**현재 상태**: ML OFF이므로 당장은 무영향
|
||||||
|
**필요 시점**: ML 재활성화 전 반드시 해결
|
||||||
|
**예상 수정 시간**: 30분
|
||||||
|
|
||||||
|
#### 이슈 #4: fetch_history.py — API 실패/Rate Limit 미처리
|
||||||
|
**파일**: `scripts/fetch_history.py:46-61`
|
||||||
|
**심각도**: 중간 (데이터 수집 중단, 주간 리포트 행)
|
||||||
|
**문제**: `futures_klines()` 호출에 retry 로직 없음
|
||||||
|
**영향**: Rate limit(429) 발생 시 크래시 → subprocess 무한 대기
|
||||||
|
**수정**: `tenacity` 라이브러리 또는 수동 retry (최대 3회, exponential backoff)
|
||||||
|
**예상 수정 시간**: 30분
|
||||||
|
|
||||||
|
#### 이슈 #5: Parquet Upsert 시 중복 타임스탬프 미제거
|
||||||
|
**파일**: `scripts/fetch_history.py:314`
|
||||||
|
**심각도**: 중간 (지표 이중 계산)
|
||||||
|
**문제**: `sort_index()`만 하고 `drop_duplicates()` 미수행
|
||||||
|
**영향**: API 응답에 중복 타임스탬프 있으면 RSI/MACD 등이 이중 계산됨
|
||||||
|
**수정**: `df[~df.index.duplicated(keep='last')]` 추가
|
||||||
|
**예상 수정 시간**: 5분
|
||||||
|
|
||||||
|
#### 이슈 #6: record_pnl()에 asyncio.Lock 미사용
|
||||||
|
**파일**: `src/risk_manager.py:55`
|
||||||
|
**심각도**: 중간 (멀티심볼에서 일일 손실 한도 부정확)
|
||||||
|
**문제**: `record_pnl()`이 `self.daily_pnl` 수정하지만 Lock 미사용
|
||||||
|
**영향**: 멀티심볼 동시 호출 시 경쟁 조건 → 일일 손실 한도 체크 오류
|
||||||
|
**수정**: `async def record_pnl()` + `async with self._lock:` 추가
|
||||||
|
**예상 수정 시간**: 5분
|
||||||
|
|
||||||
|
#### 이슈 #8: User Data Stream — exit_price 기본값 0.0
|
||||||
|
**파일**: `src/user_data_stream.py:95`
|
||||||
|
**심각도**: 중간 (PnL 오계산)
|
||||||
|
**문제**: `order.get("ap", "0")` → exit_price=0.0 (필드 누락 시)
|
||||||
|
**영향**: 청산가가 0이면 PnL 계산 완전 오류
|
||||||
|
**수정**: `if exit_price == 0.0: return; logger.warning(...)`
|
||||||
|
**예상 수정 시간**: 10분
|
||||||
|
|
||||||
|
### 2.3 다음 스프린트 (Minor)
|
||||||
|
|
||||||
|
#### 이슈 #7: 백테스터 Equity Curve 미구현
|
||||||
|
**파일**: `src/backtester.py:509-510`
|
||||||
|
**문제**: `_record_equity()`가 `pass`로 비어 있음
|
||||||
|
**영향**: MDD 계산이 실현 PnL만 기준 → 미실현 PnL 무시 → MDD 과소평가
|
||||||
|
**수정**: 포트폴리오 가치(equity) = 초기 자본 + 누적 PnL 계산
|
||||||
|
**예상 수정 시간**: 1시간
|
||||||
|
|
||||||
|
#### 이슈 #9: 거래량 급증 진입 조건 의도 불일치
|
||||||
|
**파일**: `src/indicators.py:115-118`
|
||||||
|
**문제**: `(vol_surge or long_signals >= threshold + 1)` — OR 조건
|
||||||
|
**의도 추측**: "강한 신호(threshold+1) + 거래량 급증" = AND
|
||||||
|
**현재**: 거래량 급증만으로도 진입 허용 = OR
|
||||||
|
**현재 상태**: 전략 스윕(ADX=25, Vol=2.5)에서는 큰 문제 없음
|
||||||
|
**필요**: 의도 확인 후 조건 정리
|
||||||
|
**예상 수정 시간**: 10분 (확인 후)
|
||||||
|
|
||||||
|
#### 이슈 #10: ML 모델 피처 불일치 시 Silent Failure
|
||||||
|
**파일**: `src/ml_filter.py:152`
|
||||||
|
**문제**: ONNX와 FEATURE_COLS 불일치 → 예외 잡고 `False` 반환 (모든 신호 차단)
|
||||||
|
**영향**: 사용자가 원인을 알 수 없음 (디버깅 어려움)
|
||||||
|
**수정**: ERROR 로깅 + Discord 초회 알림
|
||||||
|
**예상 수정 시간**: 15분
|
||||||
|
|
||||||
|
#### 이슈 #11-13: 기타 (데이터셋 검증, AsyncClient retry, 타임존 처리)
|
||||||
|
**총 예상 시간**: 각 10-30분
|
||||||
|
|
||||||
|
### 2.4 테스트 커버리지
|
||||||
|
|
||||||
|
**전체 테스트**: 15개 파일, 136개 케이스
|
||||||
|
|
||||||
|
**커버되는 항목**:
|
||||||
|
- ✅ 기술 지표 계산 (RSI 범위, MACD 컬럼, 볼린저 부등식)
|
||||||
|
- ✅ ADX 횡보장 필터 (ADX < 25 시 신호 차단)
|
||||||
|
- ✅ ML 피처 추출 (26개 피처, RS 분모 0 처리, NaN 없음)
|
||||||
|
- ✅ 동적 증거금 비율 계산
|
||||||
|
- ✅ 동일 방향 포지션 제한
|
||||||
|
- ✅ 일일 손실 한도 (5%)
|
||||||
|
- ✅ 반대 시그널 재진입
|
||||||
|
- ✅ Parquet Upsert + OI=0 처리
|
||||||
|
- ✅ 주간 리포트 (백테스트, 대시보드 API, 추이 분석)
|
||||||
|
|
||||||
|
**커버되지 않는 항목**:
|
||||||
|
- ❌ User Data Stream TP/SL (WebSocket 의존)
|
||||||
|
- ❌ Discord 알림 전송 (외부 서비스)
|
||||||
|
|
||||||
|
**평가**: 핵심 로직은 잘 테스트되어 있으나, WebSocket 기반 실시간 이벤트 처리는 미테스트. 이는 실제 운영에서 버그의 원천이 될 수 있음 (#8 이슈 예시).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 설계 문서 분석 (47개 파일)
|
||||||
|
|
||||||
|
### 3.1 문서 조직과 진행 상황
|
||||||
|
|
||||||
|
**초기 단계 (2026-03-01~02)**
|
||||||
|
- `2026-03-01-xrp-futures-autotrader.md` (1325줄): 프로젝트 전체 초기 계획
|
||||||
|
- `2026-03-01-ml-filter-design.md`: ML 필터 설계 (최소한)
|
||||||
|
- `2026-03-01-*-design/plan`: 15개 주요 기능별 설계+계획 쌍
|
||||||
|
|
||||||
|
**중기 개발 (2026-03-03~04)**
|
||||||
|
- ADX ML 피처 마이그레이션
|
||||||
|
- Optuna 하이퍼파라미터 튜닝
|
||||||
|
- OI 파생 피처 설계
|
||||||
|
|
||||||
|
**최근 (2026-03-05~07)**
|
||||||
|
- 멀티심볼 거래 설계 + 구현 (정상 작동 중)
|
||||||
|
- 다중심볼 대시보드 설계 + 계획
|
||||||
|
- 전략 파라미터 스윕 계획 (실행됨, PF 1.57~2.39 달성)
|
||||||
|
- **코드 리뷰 개선사항** (2026-03-07): 13개 이슈 정리
|
||||||
|
|
||||||
|
### 3.2 문서 품질
|
||||||
|
|
||||||
|
**강점**:
|
||||||
|
- **명확한 설계 의도**: 각 문서가 "목적 → 선택이유 → 기각된 대안 → 구현"의 구조
|
||||||
|
- **예시 코드 포함**: 설계를 검증할 구체적 코드 샘플 제시
|
||||||
|
- **트레이드오프 분석**: 멀티심볼 거래 시 "단일 Bot + 라우팅 vs 독립 Bot 인스턴스" 비교
|
||||||
|
|
||||||
|
**약점**:
|
||||||
|
- **기술 부채 시각화 미흡**: 47개 문서가 있지만, "전체 진행률/리스크/미해결 항목"을 한눈에 보는 대시보드 없음
|
||||||
|
- **의사결정 추적성 부족**: "왜 ADX=25 필터를 선택했는가?" 같은 근거가 전략 스윕 이후에 추가됨 (역순 설계)
|
||||||
|
- **문서 간 중복**: ML 필터 설계, 피처 설계, 데이터셋 빌더 등이 서로 겹침
|
||||||
|
|
||||||
|
### 3.3 설계의 실제 반영도
|
||||||
|
|
||||||
|
**잘 반영된 항목**:
|
||||||
|
- ✅ 5-레이어 아키텍처 (README + ARCHITECTURE 명시, 코드 구조 일치)
|
||||||
|
- ✅ 멀티심볼 독립 Bot + 공유 RiskManager (설계 문서 + 실제 코드 일치)
|
||||||
|
- ✅ ML 필터 선택적 활성화 (`NO_ML_FILTER=true` 기본값, 근거 문서 확보)
|
||||||
|
- ✅ Walk-Forward 검증 (백테스트 엔진 구현, 주간 리포트에 적용)
|
||||||
|
- ✅ Discord 알림 (설계 문서 + 구현 + 테스트)
|
||||||
|
|
||||||
|
**부분적으로 반영된 항목**:
|
||||||
|
- △ 전략 파라미터 자동 스윕 (설계 문서는 있으나, "파라미터 자동 적용"은 수동 검토 단계)
|
||||||
|
- △ 하이퍼파라미터 튜닝 (Optuna 설계 문서 있으나, 실제 사용 현황 불명)
|
||||||
|
|
||||||
|
**미반영 항목**:
|
||||||
|
- ❌ Equity curve (문서는 설계되었으나, 코드는 `pass`)
|
||||||
|
- ❌ Testnet 자동 검증 (2026-03-03 문서는 1m/125x 테스트넷 계획, 현재 상태 불명)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 운영 안정성 평가
|
||||||
|
|
||||||
|
### 4.1 리스크 관리 메커니즘
|
||||||
|
|
||||||
|
| 기능 | 구현 | 테스트 | 평가 |
|
||||||
|
|------|:----:|:-----:|------|
|
||||||
|
| 일일 손실 한도 (5%) | ✅ | ✅ | 명확함 |
|
||||||
|
| 동적 증거금 비율 | ✅ | ✅ | 선형 감소 로직 검증됨 |
|
||||||
|
| 동일 방향 제한 (2개) | ✅ | ✅ | asyncio.Lock 필요 (#6) |
|
||||||
|
| 포지션 복구 (봇 재시작) | ✅ | △ | 코드는 있으나 테스트 미흡 |
|
||||||
|
| TP/SL 자동 청산 | ✅ | ❌ | WebSocket 미테스트 (#8 버그 증명) |
|
||||||
|
| 반대 시그널 재진입 | ✅ | △ | 경쟁 조건 가능성 |
|
||||||
|
|
||||||
|
### 4.2 외부 의존성
|
||||||
|
|
||||||
|
| 서비스 | 용도 | Retry 로직 | 평가 |
|
||||||
|
|--------|------|:----------:|------|
|
||||||
|
| Binance Futures REST API | 주문, 잔고, OI, 펀딩비 | △ (부분) | #4 이슈: fetch_history retry 없음 |
|
||||||
|
| Binance WebSocket | 캔들, User Data | △ | #12 이슈: AsyncClient 생성 실패 시 전체 크래시 |
|
||||||
|
| Discord Webhook | 알림 | ❌ | 실패 시 봇 중단될 수 있음 (현황 불명) |
|
||||||
|
|
||||||
|
### 4.3 운영 자동화
|
||||||
|
|
||||||
|
**진행 중인 자동화**:
|
||||||
|
- ✅ 매주 일요일 3시 KST: `weekly_report.py` (크론탭)
|
||||||
|
- 데이터 수집 → Walk-Forward 백테스트 → 실전 통계 조회 → 추이 분석 → Discord 알림
|
||||||
|
- ✅ 모델 핫리로드: mtime 변경 감지 후 자동 리로드 (15분마다)
|
||||||
|
- ✅ CI/CD (Jenkins + Gitea Registry): `main` 푸시 → 빌드 → 레지스트리 푸시 → 운영 배포
|
||||||
|
|
||||||
|
**자동화 부족**:
|
||||||
|
- ❌ 에러 자동 복구 (AsyncClient 생성 실패 시 5회 retry 필요)
|
||||||
|
- ❌ API Rate Limit 자동 처리 (exponential backoff 필요)
|
||||||
|
- ❌ Parquet 데이터 검증 (타임존, 중복 타임스탬프)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 성능 및 검증 기준
|
||||||
|
|
||||||
|
### 5.1 전략 파라미터 스윕 결과
|
||||||
|
|
||||||
|
**테스트 기간**: 과거 데이터 (Walk-Forward 방식)
|
||||||
|
**테스트 심볼**: XRPUSDT
|
||||||
|
**조합 수**: 324개 (5 파라미터 × 3~4 값 각각)
|
||||||
|
|
||||||
|
| 파라미터 | 범위 | 최적값 | 영향도 |
|
||||||
|
|---------|------|--------|--------|
|
||||||
|
| ADX_THRESHOLD | 0, 20, 25, 30 | 25 | ⭐⭐⭐ (가장 중요) |
|
||||||
|
| ATR_SL_MULT | 1.0, 1.5, 2.0 | 2.0 | ⭐⭐ |
|
||||||
|
| ATR_TP_MULT | 2.0, 3.0, 4.0 | 2.0 | ⭐⭐ |
|
||||||
|
| SIGNAL_THRESHOLD | 3, 4, 5 | 3 | ⭐ |
|
||||||
|
| VOL_MULTIPLIER | 1.5, 2.0, 2.5 | 2.5 | ⭐⭐ |
|
||||||
|
|
||||||
|
**결과**: **PF 1.57 ~ 2.39** (심볼·조합에 따라 변동)
|
||||||
|
|
||||||
|
**평가**:
|
||||||
|
- ADX ≥ 25 필터가 가장 효과적 (횡보장 노이즈 신호 제거)
|
||||||
|
- 전략 파라미터가 타당한 범위에서 탐색됨
|
||||||
|
- 그러나 **과거 데이터 기반** → 현재 시장에서도 동일 성능 보장 불가
|
||||||
|
- **실전 거래 통계**는 README에 없음 (운영 대시보드 API 조회만 가능)
|
||||||
|
|
||||||
|
### 5.2 ML 모델 평가
|
||||||
|
|
||||||
|
**현재 상태**: 비활성화 (`NO_ML_FILTER=true`)
|
||||||
|
|
||||||
|
**근거**:
|
||||||
|
- Walk-Forward 5폴드 검증에서 각 폴드 학습 세트 ~27건 유효 신호
|
||||||
|
- LightGBM이 의미 있는 패턴을 학습하기에는 표본 부족
|
||||||
|
- ADX + 거래량만으로 PF 1.5 이상 달성 → ML 추가 필요성 낮음
|
||||||
|
|
||||||
|
**재활성화 조건**:
|
||||||
|
- 누적 거래 ≥ 150건 (현재: 불명, 버그 #2로 인해 계산 오류)
|
||||||
|
- PF < 1.0 또는 PF 3주 연속 하락
|
||||||
|
|
||||||
|
**평가**: 보수적이고 합리적 판단. 다만 150건 기준이 실제 달성되는지 확인 필요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 개발 프로세스 평가
|
||||||
|
|
||||||
|
### 6.1 설계-구현 프로세스
|
||||||
|
|
||||||
|
**강점**:
|
||||||
|
- 기능별로 `*-design.md` + `*-plan.md` 쌍 작성 (설계 의도 기록)
|
||||||
|
- ARCHITECTURE.md에 5-레이어 구조와 동작 시나리오 상세 기술
|
||||||
|
- 코드 리뷰 문서(2026-03-07)로 이슈 우선순위 정리
|
||||||
|
|
||||||
|
**약점**:
|
||||||
|
- 47개 문서 중 많은 부분이 "과거 설계 기록" (실제 구현과 시차)
|
||||||
|
- "설계 → 검증(테스트) → 문서화"의 역순 진행 보임 (특히 전략 파라미터 스윕은 후행 검증)
|
||||||
|
- 마이그레이션/리팩토링 문서가 많음 (ADX 마이그레이션, OI 피처 마이그레이션) → 초기 설계에 미흡했음을 시사
|
||||||
|
|
||||||
|
### 6.2 코드 리뷰 프로세스
|
||||||
|
|
||||||
|
**현황**:
|
||||||
|
- 2026-03-07 코드 리뷰에서 **13개 이슈 발견 및 우선순위 정리**
|
||||||
|
- Critical 2개: 즉시 수정 필요
|
||||||
|
- Important 6개: 이번 주 수정 권장
|
||||||
|
- Minor 5개: 다음 스프린트
|
||||||
|
|
||||||
|
**평가**:
|
||||||
|
- ✅ 이슈를 체계적으로 정리하고 우선순위 명시
|
||||||
|
- ✅ 각 이슈에 대해 파일명, 라인 수, 영향도, 수정 시간 제시
|
||||||
|
- ❌ 어느 이슈가 실제로 수정되었는지 추적이 없음 (상태: "부분 완료")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 문제점 및 개선 제안
|
||||||
|
|
||||||
|
### 7.1 즉시 조치 (오늘~내일)
|
||||||
|
|
||||||
|
| 번호 | 이슈 | 영향 | 수정 시간 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| #1 | OI division by zero | 봇 크래시 | 5분 |
|
||||||
|
| #2 | 누적 트레이드 계산 오류 | ML 재학습 트리거 오작동 | 5분 |
|
||||||
|
|
||||||
|
**조치 없을 시 리스크**:
|
||||||
|
- #1: 당일 운영 중 봇 크래시 가능
|
||||||
|
- #2: ML 재활성화 시점 오판
|
||||||
|
|
||||||
|
### 7.2 이번 주 조치
|
||||||
|
|
||||||
|
| 번호 | 이슈 | 우선도 | 수정 시간 |
|
||||||
|
|------|------|--------|---------|
|
||||||
|
| #4 | fetch_history retry | 높음 | 30분 |
|
||||||
|
| #5 | Parquet 중복 제거 | 중간 | 5분 |
|
||||||
|
| #6 | record_pnl Lock | 높음 | 5분 |
|
||||||
|
| #8 | exit_price=0 방어 | 높음 | 10분 |
|
||||||
|
|
||||||
|
**조치 없을 시 리스크**:
|
||||||
|
- #4: 주간 데이터 수집 실패 → 주간 리포트 미생성
|
||||||
|
- #6: 멀티심볼 운영 시 일일 손실 한도 부정확 (위험)
|
||||||
|
- #8: TP/SL 체결 시 PnL 오계산 (통계 왜곡)
|
||||||
|
|
||||||
|
### 7.3 ML 재활성화 전 (필수)
|
||||||
|
|
||||||
|
| 번호 | 이슈 | 수정 시간 |
|
||||||
|
|------|------|---------|
|
||||||
|
| #3 | Training-Serving Skew (OI/펀딩비 피처) | 30분 |
|
||||||
|
|
||||||
|
### 7.4 구조적 개선 제안
|
||||||
|
|
||||||
|
#### 제안 1: 설계 의도 문서화
|
||||||
|
**현황**: 47개 문서가 분산되어 있어 "현재 상태 파악"이 어려움
|
||||||
|
**개선**:
|
||||||
|
- `IMPLEMENTATION_STATUS.md` 추가
|
||||||
|
- 각 기능별 설계 → 구현 → 테스트 → 배포 상태 추적
|
||||||
|
- 마지막 수정 날짜 + 담당자 명시
|
||||||
|
|
||||||
|
#### 제안 2: WebSocket 기반 이벤트 테스트
|
||||||
|
**현황**: User Data Stream TP/SL 감지가 미테스트
|
||||||
|
**개선**:
|
||||||
|
- `test_user_data_stream_integration.py` 추가
|
||||||
|
- 모의 WebSocket 메시지 시뮬레이션 (pytest-asyncio)
|
||||||
|
- 특히 `exit_price=0.0` 엣지 케이스 테스트
|
||||||
|
|
||||||
|
#### 제안 3: 멀티심볼 동시성 테스트
|
||||||
|
**현황**: 단위 테스트는 있으나, "N개 심볼 동시 거래 시 경쟁 조건" 미테스트
|
||||||
|
**개선**:
|
||||||
|
- `test_multisymbol_concurrent.py` 추가
|
||||||
|
- 각 심볼이 동시에 포지션 진입/청산 시뮬레이션
|
||||||
|
- asyncio.Lock이 제대로 작동하는지 검증
|
||||||
|
|
||||||
|
#### 제안 4: API Retry 정책 통일
|
||||||
|
**현황**: fetch_history.py에만 retry 없음 → 다른 모듈도 검토 필요
|
||||||
|
**개선**:
|
||||||
|
- `src/binance_client.py` (또는 exchange.py)에 retry decorator 추가
|
||||||
|
- `tenacity` 라이브러리 사용 (exponential backoff + jitter)
|
||||||
|
- Rate limit(429) 감지 → 최대 5회 재시도
|
||||||
|
|
||||||
|
#### 제안 5: 실전 성능 대시보드 추가
|
||||||
|
**현황**: 백테스트 성능(PF 1.57~2.39)은 있으나, 실전 거래 성능 미기록
|
||||||
|
**개선**:
|
||||||
|
- `scripts/extract_live_stats.py` 추가
|
||||||
|
- 운영 대시보드 API(`GET /api/trades`, `GET /api/stats`) 조회 후 JSON 저장
|
||||||
|
- README에 "실전 거래 성능" 섹션 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 결론
|
||||||
|
|
||||||
|
### 8.1 종합 평가
|
||||||
|
|
||||||
|
| 항목 | 평가 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| 아키텍처 설계 | ⭐⭐⭐⭐ (90/100) | 5-레이어 분리 명확, 멀티심볼 구현 양호 |
|
||||||
|
| 코드 품질 | ⭐⭐⭐ (75/100) | 핵심 로직은 건실하나, 엣지 케이스 미흡 |
|
||||||
|
| 테스트 커버리지 | ⭐⭐⭐ (75/100) | 136개 케이스, 단위 테스트 양호 / WebSocket 미테스트 |
|
||||||
|
| 설계 문서 | ⭐⭐⭐ (80/100) | 47개 파일로 상세하나, 진행 상황 추적 미흡 |
|
||||||
|
| 운영 자동화 | ⭐⭐⭐ (80/100) | 주간 리포트 + CI/CD 갖춤 / 에러 자동 복구 부족 |
|
||||||
|
| **종합** | **⭐⭐⭐⭐ (80/100)** | **초기 단계 프로젝트로는 양호, 즉시 수정 필요 항목 2개** |
|
||||||
|
|
||||||
|
### 8.2 가동 여부 판단
|
||||||
|
|
||||||
|
**현재 가동 가능**: 예, 그러나 위험 요소 있음
|
||||||
|
|
||||||
|
**조건**:
|
||||||
|
1. **즉시**: 버그 #1, #2 수정 (합계 10분)
|
||||||
|
2. **당일**: 이슈 #4, #6, #8 수정 (합계 45분)
|
||||||
|
3. **이번 주**: 이슈 #5, #3(ML 활성화 계획 있으면) 수정
|
||||||
|
|
||||||
|
**위험 요소**:
|
||||||
|
- ❌ User Data Stream TP/SL이 미테스트 → 실제 청산이 작동하지 않을 가능성
|
||||||
|
- ❌ 멀티심볼 동시성: `record_pnl()` Lock 미사용 → 리스크 한도 부정확 가능
|
||||||
|
- ❌ 데이터 품질: Parquet 중복/타임존 미처리 → 지표 계산 오류 가능
|
||||||
|
|
||||||
|
### 8.3 다음 단계
|
||||||
|
|
||||||
|
**즉시 (오늘 중)**:
|
||||||
|
- [x] 버그 #1 수정: OI division by zero _(commit 60510c0)_
|
||||||
|
- [x] 버그 #2 수정: 누적 트레이드 계산 _(commit 60510c0)_
|
||||||
|
|
||||||
|
**당일 야간**:
|
||||||
|
- [x] 이슈 #4 수정: fetch_history retry 로직
|
||||||
|
- [x] 이슈 #6 수정: record_pnl asyncio.Lock _(commit 60510c0)_
|
||||||
|
- [x] 이슈 #8 수정: exit_price=0.0 방어 _(commit 60510c0)_
|
||||||
|
|
||||||
|
**이번 주**:
|
||||||
|
- [x] 이슈 #5 수정: Parquet 중복 제거 _(commit 60510c0)_
|
||||||
|
- [ ] 이슈 #13 수정: 타임존 처리
|
||||||
|
- [ ] 이슈 #3 분석: Training-Serving Skew (ML 재활성화 계획이면)
|
||||||
|
|
||||||
|
**다음 2주**:
|
||||||
|
- [ ] IMPLEMENTATION_STATUS.md 작성 (설계→구현→테스트→배포 추적)
|
||||||
|
- [ ] WebSocket 통합 테스트 작성
|
||||||
|
- [ ] 멀티심볼 동시성 테스트 작성
|
||||||
|
|
||||||
|
### 8.4 최종 의견
|
||||||
|
|
||||||
|
CoinTrader는 **아키텍처가 건실하고 설계 의도가 명확한 프로젝트**입니다. 5-레이어 분리, 멀티심볼 동시 거래, 완전한 MLOps 파이프라인 등은 초기 자동매매 봇 프로젝트 치고는 수준이 높습니다.
|
||||||
|
|
||||||
|
그러나 **즉시 수정이 필요한 버그 2개**(Division by Zero, 누적 계산 오류)와 **엣지 케이스 미흡**(WebSocket 미테스트, asyncio 경쟁 조건, API retry 부족)이 있어서, 실제 운영 환경에 투입하기 전에 최소 1주일의 안정화 기간이 필요합니다.
|
||||||
|
|
||||||
|
특히 **User Data Stream TP/SL 감지**가 미테스트되어 있다는 점이 가장 우려스럽습니다. 이 부분이 작동하지 않으면 포지션이 영구히 열려 있을 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: 검토 범위
|
||||||
|
|
||||||
|
**검토 대상**:
|
||||||
|
- `README.md` — 사용자 가이드
|
||||||
|
- `ARCHITECTURE.md` — 기술 아키텍처
|
||||||
|
- 47개 설계/계획 문서 (2026-03-01 ~ 2026-03-07)
|
||||||
|
- 코드 리뷰 개선사항 (2026-03-07-code-review-improvements.md)
|
||||||
|
|
||||||
|
**검토 제외**:
|
||||||
|
- 실제 소스 코드 (src/, scripts/, tests/)
|
||||||
|
- 운영 로그 및 실전 거래 데이터
|
||||||
|
- Docker 설정 및 CI/CD 파이프라인 상세
|
||||||
|
|
||||||
|
**검토 방식**:
|
||||||
|
- 문서 정합성 검증
|
||||||
|
- 설계 결정 타당성 분석
|
||||||
|
- 버그 및 이슈 우선순위 검토
|
||||||
|
- 아키텍처 강점/약점 평가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**보고서 작성**: 2026-03-07
|
||||||
|
**담당자**: Claude AI
|
||||||
|
**버전**: 1.0
|
||||||
253
docs/plans/code-review-2026-03-16.md
Normal file
253
docs/plans/code-review-2026-03-16.md
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
# CoinTrader 코드 점검 보고서
|
||||||
|
|
||||||
|
> 작성일: 2026-03-16
|
||||||
|
> 대상: CoinTrader 전체 소스 코드 (bot.py, exchange.py, risk_manager.py, data_stream.py, user_data_stream.py, ml_filter.py, ml_features.py, config.py)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 요약
|
||||||
|
|
||||||
|
| 심각도 | 건수 |
|
||||||
|
|--------|------|
|
||||||
|
| 🔴 심각 (버그 / 실제 자금 손실 위험) | 4 (✅ 전부 수정 완료) |
|
||||||
|
| 🟡 경고 (논리 오류 / 운영 리스크) | 6 (✅ 전부 수정 완료) |
|
||||||
|
| 🔵 개선 (코드 품질 / 유지보수) | 5 |
|
||||||
|
|
||||||
|
아키텍처 설계 자체(멀티심볼 독립 인스턴스, 공유 RiskManager)는 합리적이다. 문제는 멀티심볼 확장 과정에서 공유 상태(`RiskManager`)에 대한 동시성 처리가 불완전하고, 자금 관련 계산 로직(마진 비율, PnL 폴백)에 실제 버그가 존재한다는 점이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 심각 — 버그 / 실제 자금 손실 위험
|
||||||
|
|
||||||
|
### 1. 마진 비율 계산 불일치 (`bot.py` L190-196)
|
||||||
|
|
||||||
|
**문제:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
per_symbol_balance = balance / num_symbols # 심볼별로 나눔
|
||||||
|
margin_ratio = self.risk.get_dynamic_margin_ratio(balance) # 전체 잔고 기준
|
||||||
|
quantity = self.exchange.calculate_quantity(
|
||||||
|
balance=per_symbol_balance, # 나눈 값
|
||||||
|
margin_ratio=margin_ratio # 전체 기준 비율 → 불일치
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`margin_ratio`는 전체 잔고 기준으로 계산되었는데, `per_symbol_balance`(나눈 값)에 곱해진다. 결과적으로 마진 비율 감소 효과가 의도한 것의 `num_symbols`배로 증폭된다.
|
||||||
|
|
||||||
|
**수정 방향:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
per_symbol_balance = balance / num_symbols
|
||||||
|
margin_ratio = self.risk.get_dynamic_margin_ratio(per_symbol_balance) # 나눈 값 기준
|
||||||
|
```
|
||||||
|
|
||||||
|
또는 전체 잔고로 수량을 계산하고 나중에 심볼 수로 나누는 방식으로 통일해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `_place_algo_order`의 `algoType="CONDITIONAL"` 하드코딩 (`exchange.py` L149)
|
||||||
|
|
||||||
|
**문제:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
params = dict(
|
||||||
|
symbol=self.symbol,
|
||||||
|
side=side,
|
||||||
|
algoType="CONDITIONAL", # 하드코딩
|
||||||
|
type=order_type,
|
||||||
|
...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Binance FAPI `/fapi/v1/algoOrder`의 `algoType`은 `VP`, `TWAP` 등 실행 알고리즘용이다. `STOP_MARKET` / `TAKE_PROFIT_MARKET` 같은 조건부 주문은 `/fapi/v1/order`에 `reduceOnly=true`로 전송해야 한다. 이 경로가 실제로 동작하지 않으면 SL/TP 주문이 아예 등록되지 않아 무한 손실 가능.
|
||||||
|
|
||||||
|
**수정 방향:** 테스트넷에서 즉시 검증. 실패 시 일반 `place_order` 경로로 대체하고 `_place_algo_order` 삭제.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 폴백 PnL 계산 오류 (`bot.py` L328-334)
|
||||||
|
|
||||||
|
**문제:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
pnl_rows, comm_rows = await self.exchange.get_recent_income(limit=5)
|
||||||
|
if pnl_rows:
|
||||||
|
realized_pnl = float(pnl_rows[-1].get("income", "0")) # 마지막 1건만 사용
|
||||||
|
```
|
||||||
|
|
||||||
|
멀티심볼 환경에서 `limit=5` 조회 시 다른 심볼의 PnL이 섞일 수 있다. 마지막 항목 하나만 쓰는 것은 다중 체결 건이 있을 때 틀린 값을 기록한다. SYNC 청산에서 잘못된 PnL이 기록되면 `daily_pnl`이 오염되어 손실 한도 체크 자체가 무의미해진다.
|
||||||
|
|
||||||
|
**수정 방향:** 조회 시 `symbol` 파라미터로 필터링하고, 해당 포지션의 거래 ID 범위를 기준으로 합산해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `_is_reentering` 타이밍 레이스 컨디션 (`bot.py` L401, L421)
|
||||||
|
|
||||||
|
**문제:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._is_reentering = True
|
||||||
|
try:
|
||||||
|
await self._close_position(position) # 청산 주문 전송
|
||||||
|
# ← 이 시점에 User Data Stream 콜백 도착 가능
|
||||||
|
await self._open_position(signal, df) # 신규 진입
|
||||||
|
finally:
|
||||||
|
self._is_reentering = False
|
||||||
|
```
|
||||||
|
|
||||||
|
청산 주문 전송 직후 User Data Stream 콜백이 도착하면, `_is_reentering = True`인 상태에서 `risk.close_position`이 호출된다. 그 직후 `_open_position`이 `risk.register_position`을 호출하며 상태가 겹친다. `asyncio`의 단일 스레드 특성 덕분에 `await` 사이에는 안전하지만, 콜백 순서와 타이밍에 따라 포지션 카운트가 틀어질 수 있다.
|
||||||
|
|
||||||
|
**수정 방향:** `_close_and_reenter` 내에서 포지션 상태 전환을 명시적으로 관리하고, `_on_position_closed`에서 `_is_reentering` 플래그를 확인하는 것 외에도 명시적인 상태 머신 전환을 추가한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 경고 — 논리 오류 / 운영 리스크
|
||||||
|
|
||||||
|
### 5. `reset_daily()` 자동 호출 없음 (`risk_manager.py`)
|
||||||
|
|
||||||
|
메서드는 정의되어 있으나 어디서도 호출되지 않는다. 봇이 며칠 연속 실행되면 `daily_pnl`이 계속 누적되어 일일 손실 한도 체크가 무의미해진다.
|
||||||
|
|
||||||
|
**수정 방향:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# main.py 또는 bot.run() 내에서
|
||||||
|
async def _daily_reset_loop(risk: RiskManager):
|
||||||
|
while True:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
next_midnight = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0)
|
||||||
|
await asyncio.sleep((next_midnight - now).total_seconds())
|
||||||
|
risk.reset_daily()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. 공유 `RiskManager`에서 `set_base_balance` 경쟁 조건 (`bot.py` L429)
|
||||||
|
|
||||||
|
`asyncio.gather`로 3개 봇이 거의 동시에 `run()`을 실행하면 각자 `set_base_balance(balance)`를 호출한다. 마지막으로 호출한 봇의 잔고로 덮어씌워지며, Lock이 없어 순서도 보장되지 않는다.
|
||||||
|
|
||||||
|
**수정 방향:** `initial_balance` 설정을 `main.py`에서 한 번만 수행하고 공유 RiskManager에 주입하거나, 설정 시 Lock으로 보호한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. 진입 주문이 청산으로 잘못 판별 가능 (`user_data_stream.py` L89)
|
||||||
|
|
||||||
|
```python
|
||||||
|
is_close = is_reduce or order_type in _CLOSE_ORDER_TYPES or realized_pnl != 0
|
||||||
|
```
|
||||||
|
|
||||||
|
일부 상황에서 진입 주문 체결 시 소액의 `rp`(실현 손익)가 붙는 경우가 있다. `realized_pnl != 0` 단독 조건이 너무 넓어 진입 주문이 청산으로 잘못 처리될 수 있다.
|
||||||
|
|
||||||
|
**수정 방향:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
is_close = is_reduce or order_type in _CLOSE_ORDER_TYPES
|
||||||
|
# realized_pnl != 0 조건 제거
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. 피처 컬럼명이 XRP에 하드코딩 (`ml_features.py` L10-11)
|
||||||
|
|
||||||
|
```python
|
||||||
|
FEATURE_COLS = [
|
||||||
|
...
|
||||||
|
"xrp_btc_rs", "xrp_eth_rs", # XRP 하드코딩
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
TRX/DOGE 봇도 동일한 피처명을 사용한다. 학습과 추론 간 컬럼명 불일치는 없지만, 의미가 잘못되어 있고 심볼별 모델 학습 시 혼란을 유발한다.
|
||||||
|
|
||||||
|
**수정 방향:** `build_features_aligned` 함수에서 심볼명을 동적으로 포함하거나, 컬럼명을 `primary_btc_rs`, `primary_eth_rs`로 범용화한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. `asyncio.get_event_loop()` deprecated 패턴 (`exchange.py` 전반)
|
||||||
|
|
||||||
|
Python 3.10+에서 실행 중인 루프가 없을 때 `get_event_loop()`은 `DeprecationWarning`을 발생시킨다.
|
||||||
|
|
||||||
|
**수정 방향:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Before
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, lambda: ...)
|
||||||
|
|
||||||
|
# After
|
||||||
|
await asyncio.to_thread(lambda: ...)
|
||||||
|
# 또는
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
await loop.run_in_executor(None, lambda: ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. 프리로드가 순차적으로 처리됨 (`data_stream.py` L164-183)
|
||||||
|
|
||||||
|
```python
|
||||||
|
for symbol in self.symbols: # 순차 처리
|
||||||
|
klines = await client.futures_klines(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
심볼 3개를 순차 REST 조회하면 시작 시간이 약 3배 길어진다.
|
||||||
|
|
||||||
|
**수정 방향:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _preload_one(client, symbol):
|
||||||
|
...
|
||||||
|
|
||||||
|
await asyncio.gather(*[_preload_one(client, s) for s in self.symbols])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔵 개선 — 코드 품질 / 유지보수
|
||||||
|
|
||||||
|
### 11. `config.py` 데드 필드
|
||||||
|
|
||||||
|
`stop_loss_pct`, `take_profit_pct`, `trailing_stop_pct`가 dataclass 기본값으로만 존재하고 `__post_init__`에서 환경변수로 로드되지 않는다. `atr_sl_mult`/`atr_tp_mult`로 대체되었으나 정리되지 않았다. 혼란을 줄이기 위해 삭제하거나 환경변수 로드를 추가해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. 매 캔들마다 불필요한 REST API 조회 (`bot.py` L158)
|
||||||
|
|
||||||
|
```python
|
||||||
|
position = await self.exchange.get_position() # 15분마다 호출
|
||||||
|
```
|
||||||
|
|
||||||
|
`current_trade_side`로 로컬 상태를 이미 관리하고 있다. User Data Stream 콜백과 `_position_monitor` 폴백이 있으므로, `process_candle`에서는 로컬 상태만 확인하면 충분하다. 불필요한 API rate limit을 소비하고 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 13. `main.py` 파일 없음
|
||||||
|
|
||||||
|
README와 ARCHITECTURE.md에 진입점으로 언급되지만 실제 파일이 없다. 배포 시 어떻게 봇을 실행하는지 코드로 확인할 수 없다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 14. `MIN_NOTIONAL = 5.0` 하드코딩 (`exchange.py` L20)
|
||||||
|
|
||||||
|
Binance의 최소 명목금액은 심볼마다 다르고 정책 변경이 가능하다. `exchange_info`의 `filters`에서 `MIN_NOTIONAL` 또는 `NOTIONAL` 필터를 읽어야 정확하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 15. ML 필터 예측 오류 시 무조건 진입 차단 (`ml_filter.py` L153)
|
||||||
|
|
||||||
|
```python
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"ML 필터 예측 오류 (진입 차단): {e}")
|
||||||
|
return False # 모든 거래 차단
|
||||||
|
```
|
||||||
|
|
||||||
|
모델에 버그가 생기면 거래가 전면 중단된다. 오류 유형에 따라 `True`(폴백 허용)를 반환할지 `False`(차단)를 반환할지 구분하고, 오류 횟수를 카운팅하여 Discord 알림을 보내는 것이 바람직하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 우선 처리 권장 순서
|
||||||
|
|
||||||
|
1. **즉시**: `_place_algo_order` API 경로 테스트넷 검증 (#2)
|
||||||
|
2. **즉시**: 마진 비율 계산 불일치 수정 (#1)
|
||||||
|
3. **이번 주**: `reset_daily()` 자동 호출 추가 (#5)
|
||||||
|
4. **이번 주**: `set_base_balance` 경쟁 조건 수정 (#6)
|
||||||
|
5. **이번 주**: 폴백 PnL 조회 로직 개선 (#3)
|
||||||
|
6. **다음 배포 전**: `is_close` 판별 조건 수정 (#7), `asyncio.get_event_loop` 교체 (#9), 프리로드 병렬화 (#10)
|
||||||
79
main.py
79
main.py
@@ -1,17 +1,92 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import signal
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from loguru import logger
|
||||||
from src.config import Config
|
from src.config import Config
|
||||||
from src.bot import TradingBot
|
from src.bot import TradingBot
|
||||||
|
from src.risk_manager import RiskManager
|
||||||
from src.logger_setup import setup_logger
|
from src.logger_setup import setup_logger
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
async def _daily_reset_loop(risk: RiskManager):
|
||||||
|
"""매일 UTC 자정에 daily_pnl을 초기화한다."""
|
||||||
|
while True:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
next_midnight = (now + timedelta(days=1)).replace(
|
||||||
|
hour=0, minute=0, second=0, microsecond=0,
|
||||||
|
)
|
||||||
|
await asyncio.sleep((next_midnight - now).total_seconds())
|
||||||
|
await risk.reset_daily()
|
||||||
|
|
||||||
|
|
||||||
|
async def _graceful_shutdown(bots: list[TradingBot], tasks: list[asyncio.Task]):
|
||||||
|
"""모든 봇의 오픈 주문 취소 후 태스크를 정리한다."""
|
||||||
|
logger.info("Graceful shutdown 시작 — 오픈 주문 취소 중...")
|
||||||
|
for bot in bots:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(bot.exchange.cancel_all_orders(), timeout=5)
|
||||||
|
logger.info(f"[{bot.symbol}] 오픈 주문 취소 완료")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[{bot.symbol}] 오픈 주문 취소 실패 (무시): {e}")
|
||||||
|
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
for r in results:
|
||||||
|
if isinstance(r, Exception) and not isinstance(r, asyncio.CancelledError):
|
||||||
|
logger.warning(f"태스크 종료 중 예외: {r}")
|
||||||
|
logger.info("Graceful shutdown 완료")
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
setup_logger(log_level="INFO")
|
setup_logger(log_level="INFO")
|
||||||
config = Config()
|
config = Config()
|
||||||
bot = TradingBot(config)
|
risk = RiskManager(config)
|
||||||
await bot.run()
|
|
||||||
|
# 기준 잔고를 main에서 한 번만 설정 (경쟁 조건 방지)
|
||||||
|
from src.exchange import BinanceFuturesClient
|
||||||
|
exchange = BinanceFuturesClient(config, symbol=config.symbols[0])
|
||||||
|
balance = await exchange.get_balance()
|
||||||
|
risk.set_base_balance(balance)
|
||||||
|
logger.info(f"기준 잔고 설정: {balance:.2f} USDT")
|
||||||
|
|
||||||
|
bots = []
|
||||||
|
for symbol in config.symbols:
|
||||||
|
bot = TradingBot(config, symbol=symbol, risk=risk)
|
||||||
|
bots.append(bot)
|
||||||
|
|
||||||
|
logger.info(f"멀티심볼 봇 시작: {config.symbols} ({len(bots)}개 인스턴스)")
|
||||||
|
|
||||||
|
# 시그널 핸들러 등록
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
shutdown_event = asyncio.Event()
|
||||||
|
|
||||||
|
def _signal_handler():
|
||||||
|
logger.warning("종료 시그널 수신 (SIGTERM/SIGINT)")
|
||||||
|
shutdown_event.set()
|
||||||
|
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
loop.add_signal_handler(sig, _signal_handler)
|
||||||
|
|
||||||
|
tasks = [
|
||||||
|
asyncio.create_task(bot.run(), name=f"bot-{bot.symbol}")
|
||||||
|
for bot in bots
|
||||||
|
]
|
||||||
|
tasks.append(asyncio.create_task(_daily_reset_loop(risk), name="daily-reset"))
|
||||||
|
|
||||||
|
# 종료 시그널 대기 vs 태스크 완료 (먼저 발생하는 쪽)
|
||||||
|
shutdown_task = asyncio.create_task(shutdown_event.wait(), name="shutdown-wait")
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
tasks + [shutdown_task],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 시그널이든 태스크 종료든 graceful shutdown 수행
|
||||||
|
shutdown_task.cancel()
|
||||||
|
await _graceful_shutdown(bots, tasks)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
44
main_mtf.py
Normal file
44
main_mtf.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""MTF Pullback Bot — OOS Dry-run Entry Point."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import signal as sig
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from loguru import logger
|
||||||
|
from src.mtf_bot import MTFPullbackBot
|
||||||
|
from src.logger_setup import setup_logger
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
setup_logger(log_level="INFO")
|
||||||
|
logger.info("MTF Pullback Bot 시작 (Dry-run OOS 모드)")
|
||||||
|
|
||||||
|
bot = MTFPullbackBot(symbol="XRP/USDT:USDT")
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
shutdown = asyncio.Event()
|
||||||
|
|
||||||
|
def _on_signal():
|
||||||
|
logger.warning("종료 시그널 수신 (SIGTERM/SIGINT)")
|
||||||
|
shutdown.set()
|
||||||
|
|
||||||
|
for s in (sig.SIGTERM, sig.SIGINT):
|
||||||
|
loop.add_signal_handler(s, _on_signal)
|
||||||
|
|
||||||
|
bot_task = asyncio.create_task(bot.run(), name="mtf-bot")
|
||||||
|
shutdown_task = asyncio.create_task(shutdown.wait(), name="shutdown-wait")
|
||||||
|
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
[bot_task, shutdown_task],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
bot_task.cancel()
|
||||||
|
shutdown_task.cancel()
|
||||||
|
await asyncio.gather(bot_task, shutdown_task, return_exceptions=True)
|
||||||
|
logger.info("MTF Pullback Bot 종료")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
1550
models/active_lgbm_params.json
Normal file
1550
models/active_lgbm_params.json
Normal file
File diff suppressed because it is too large
Load Diff
0
models/dogeusdt/.gitkeep
Normal file
0
models/dogeusdt/.gitkeep
Normal file
BIN
models/dogeusdt/lgbm_filter.pkl
Normal file
BIN
models/dogeusdt/lgbm_filter.pkl
Normal file
Binary file not shown.
BIN
models/dogeusdt/lgbm_filter_prev.pkl
Normal file
BIN
models/dogeusdt/lgbm_filter_prev.pkl
Normal file
Binary file not shown.
102
models/dogeusdt/training_log.json
Normal file
102
models/dogeusdt/training_log.json
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"date": "2026-03-05T23:54:51.517734",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.9565,
|
||||||
|
"best_threshold": 0.3318,
|
||||||
|
"best_precision": 0.548,
|
||||||
|
"best_recall": 0.489,
|
||||||
|
"samples": 3330,
|
||||||
|
"features": 26,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/dogeusdt/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-06T02:00:56.287381",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.9555,
|
||||||
|
"best_threshold": 0.4012,
|
||||||
|
"best_precision": 0.577,
|
||||||
|
"best_recall": 0.319,
|
||||||
|
"samples": 3330,
|
||||||
|
"features": 26,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/dogeusdt/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-06T22:37:26.751875",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.9565,
|
||||||
|
"best_threshold": 0.4047,
|
||||||
|
"best_precision": 0.65,
|
||||||
|
"best_recall": 0.277,
|
||||||
|
"samples": 3336,
|
||||||
|
"features": 26,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/dogeusdt/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-06T23:35:19.306197",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.9552,
|
||||||
|
"best_threshold": 0.8009,
|
||||||
|
"best_precision": 0.75,
|
||||||
|
"best_recall": 0.2,
|
||||||
|
"samples": 744,
|
||||||
|
"features": 26,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/dogeusdt/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
|
||||||
|
}
|
||||||
|
]
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -216,5 +216,290 @@
|
|||||||
"train_sec": 0.1,
|
"train_sec": 0.1,
|
||||||
"time_weight_decay": 2.0,
|
"time_weight_decay": 2.0,
|
||||||
"model_path": "models/mlx_filter.weights"
|
"model_path": "models/mlx_filter.weights"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-03-01T23:59:27.956019",
|
||||||
|
"backend": "mlx",
|
||||||
|
"auc": 0.5595,
|
||||||
|
"best_threshold": 0.9538,
|
||||||
|
"best_precision": 0.462,
|
||||||
|
"best_recall": 0.171,
|
||||||
|
"samples": 533,
|
||||||
|
"train_sec": 0.2,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/mlx_filter.weights"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-03-02T00:40:15.931055",
|
||||||
|
"backend": "mlx",
|
||||||
|
"auc": 0.5829,
|
||||||
|
"best_threshold": 0.9609,
|
||||||
|
"best_precision": 0.6,
|
||||||
|
"best_recall": 0.171,
|
||||||
|
"samples": 534,
|
||||||
|
"train_sec": 0.2,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/mlx_filter.weights"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-03-02T00:54:32.264425",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.5607,
|
||||||
|
"best_threshold": 0.6532,
|
||||||
|
"best_precision": 0.467,
|
||||||
|
"best_recall": 0.2,
|
||||||
|
"samples": 533,
|
||||||
|
"features": 23,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/lgbm_filter.pkl"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-03-02T01:07:30.690959",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.5579,
|
||||||
|
"best_threshold": 0.6511,
|
||||||
|
"best_precision": 0.4,
|
||||||
|
"best_recall": 0.171,
|
||||||
|
"samples": 533,
|
||||||
|
"features": 23,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/lgbm_filter.pkl"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-03-02T02:00:45.931227",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.5752,
|
||||||
|
"best_threshold": 0.6307,
|
||||||
|
"best_precision": 0.471,
|
||||||
|
"best_recall": 0.229,
|
||||||
|
"samples": 533,
|
||||||
|
"features": 23,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/lgbm_filter.pkl"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-03-02T14:51:09.101738",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.5361,
|
||||||
|
"best_threshold": 0.5308,
|
||||||
|
"best_precision": 0.406,
|
||||||
|
"best_recall": 0.371,
|
||||||
|
"samples": 533,
|
||||||
|
"features": 23,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/lgbm_filter.pkl",
|
||||||
|
"tuned_params_path": "models/tune_results_20260302_144749.json",
|
||||||
|
"lgbm_params": {
|
||||||
|
"n_estimators": 434,
|
||||||
|
"learning_rate": 0.123659,
|
||||||
|
"num_leaves": 14,
|
||||||
|
"min_child_samples": 10,
|
||||||
|
"subsample": 0.929062,
|
||||||
|
"colsample_bytree": 0.94633,
|
||||||
|
"reg_alpha": 0.573971,
|
||||||
|
"reg_lambda": 0.000157,
|
||||||
|
"max_depth": 6
|
||||||
|
},
|
||||||
|
"weight_scale": 1.783105
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-03-02T18:10:27.584046",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.5466,
|
||||||
|
"best_threshold": 0.6424,
|
||||||
|
"best_precision": 0.426,
|
||||||
|
"best_recall": 0.556,
|
||||||
|
"samples": 535,
|
||||||
|
"features": 23,
|
||||||
|
"time_weight_decay": 0.5,
|
||||||
|
"model_path": "models/lgbm_filter.pkl",
|
||||||
|
"tuned_params_path": null,
|
||||||
|
"lgbm_params": {
|
||||||
|
"n_estimators": 434,
|
||||||
|
"learning_rate": 0.123659,
|
||||||
|
"max_depth": 6,
|
||||||
|
"num_leaves": 14,
|
||||||
|
"min_child_samples": 10,
|
||||||
|
"subsample": 0.929062,
|
||||||
|
"colsample_bytree": 0.94633,
|
||||||
|
"reg_alpha": 0.573971,
|
||||||
|
"reg_lambda": 0.000157
|
||||||
|
},
|
||||||
|
"weight_scale": 1.783105
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-03-03T00:12:17.351458",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.949,
|
||||||
|
"best_threshold": 0.42,
|
||||||
|
"best_precision": 0.56,
|
||||||
|
"best_recall": 0.538,
|
||||||
|
"samples": 1524,
|
||||||
|
"features": 23,
|
||||||
|
"time_weight_decay": 0.5,
|
||||||
|
"model_path": "models/lgbm_filter.pkl",
|
||||||
|
"tuned_params_path": null,
|
||||||
|
"lgbm_params": {
|
||||||
|
"n_estimators": 434,
|
||||||
|
"learning_rate": 0.123659,
|
||||||
|
"max_depth": 6,
|
||||||
|
"num_leaves": 14,
|
||||||
|
"min_child_samples": 10,
|
||||||
|
"subsample": 0.929062,
|
||||||
|
"colsample_bytree": 0.94633,
|
||||||
|
"reg_alpha": 0.573971,
|
||||||
|
"reg_lambda": 0.000157
|
||||||
|
},
|
||||||
|
"weight_scale": 1.783105
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-03-03T00:13:56.456518",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.9439,
|
||||||
|
"best_threshold": 0.6558,
|
||||||
|
"best_precision": 0.667,
|
||||||
|
"best_recall": 0.154,
|
||||||
|
"samples": 1524,
|
||||||
|
"features": 23,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/lgbm_filter.pkl",
|
||||||
|
"tuned_params_path": null,
|
||||||
|
"lgbm_params": {
|
||||||
|
"n_estimators": 434,
|
||||||
|
"learning_rate": 0.123659,
|
||||||
|
"max_depth": 6,
|
||||||
|
"num_leaves": 14,
|
||||||
|
"min_child_samples": 10,
|
||||||
|
"subsample": 0.929062,
|
||||||
|
"colsample_bytree": 0.94633,
|
||||||
|
"reg_alpha": 0.573971,
|
||||||
|
"reg_lambda": 0.000157
|
||||||
|
},
|
||||||
|
"weight_scale": 1.783105
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-03-03T00:20:43.712971",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.9473,
|
||||||
|
"best_threshold": 0.3015,
|
||||||
|
"best_precision": 0.465,
|
||||||
|
"best_recall": 0.769,
|
||||||
|
"samples": 1524,
|
||||||
|
"features": 23,
|
||||||
|
"time_weight_decay": 0.5,
|
||||||
|
"model_path": "models/lgbm_filter.pkl",
|
||||||
|
"tuned_params_path": "models/active_lgbm_params.json",
|
||||||
|
"lgbm_params": {
|
||||||
|
"n_estimators": 195,
|
||||||
|
"learning_rate": 0.033934,
|
||||||
|
"max_depth": 3,
|
||||||
|
"num_leaves": 7,
|
||||||
|
"min_child_samples": 11,
|
||||||
|
"subsample": 0.998659,
|
||||||
|
"colsample_bytree": 0.837233,
|
||||||
|
"reg_alpha": 0.007008,
|
||||||
|
"reg_lambda": 0.80039
|
||||||
|
},
|
||||||
|
"weight_scale": 0.718348
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-03-03T00:39:05.427160",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.9436,
|
||||||
|
"best_threshold": 0.3041,
|
||||||
|
"best_precision": 0.467,
|
||||||
|
"best_recall": 0.269,
|
||||||
|
"samples": 1524,
|
||||||
|
"features": 23,
|
||||||
|
"time_weight_decay": 0.5,
|
||||||
|
"model_path": "models/lgbm_filter.pkl",
|
||||||
|
"tuned_params_path": "models/active_lgbm_params.json",
|
||||||
|
"lgbm_params": {
|
||||||
|
"n_estimators": 221,
|
||||||
|
"learning_rate": 0.031072,
|
||||||
|
"max_depth": 5,
|
||||||
|
"num_leaves": 20,
|
||||||
|
"min_child_samples": 39,
|
||||||
|
"subsample": 0.83244,
|
||||||
|
"colsample_bytree": 0.526349,
|
||||||
|
"reg_alpha": 0.062177,
|
||||||
|
"reg_lambda": 0.082872
|
||||||
|
},
|
||||||
|
"weight_scale": 1.431662
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-03-03T21:21:48.047541",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.9494,
|
||||||
|
"best_threshold": 0.4622,
|
||||||
|
"best_precision": 0.556,
|
||||||
|
"best_recall": 0.25,
|
||||||
|
"samples": 3222,
|
||||||
|
"features": 24,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/lgbm_filter.pkl",
|
||||||
|
"tuned_params_path": null,
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-03-04T02:00:20.379884",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.9437,
|
||||||
|
"best_threshold": 0.3576,
|
||||||
|
"best_precision": 0.632,
|
||||||
|
"best_recall": 0.3,
|
||||||
|
"samples": 3228,
|
||||||
|
"features": 24,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/lgbm_filter.pkl",
|
||||||
|
"tuned_params_path": null,
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-03-05T02:00:24.489871",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.9448,
|
||||||
|
"best_threshold": 0.3075,
|
||||||
|
"best_precision": 0.452,
|
||||||
|
"best_recall": 0.463,
|
||||||
|
"samples": 3246,
|
||||||
|
"features": 26,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/lgbm_filter.pkl",
|
||||||
|
"tuned_params_path": null,
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
0
models/trxusdt/.gitkeep
Normal file
0
models/trxusdt/.gitkeep
Normal file
BIN
models/trxusdt/lgbm_filter.pkl
Normal file
BIN
models/trxusdt/lgbm_filter.pkl
Normal file
Binary file not shown.
BIN
models/trxusdt/lgbm_filter_prev.pkl
Normal file
BIN
models/trxusdt/lgbm_filter_prev.pkl
Normal file
Binary file not shown.
102
models/trxusdt/training_log.json
Normal file
102
models/trxusdt/training_log.json
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"date": "2026-03-05T23:54:05.625978",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.947,
|
||||||
|
"best_threshold": 0.2822,
|
||||||
|
"best_precision": 0.446,
|
||||||
|
"best_recall": 0.763,
|
||||||
|
"samples": 2940,
|
||||||
|
"features": 26,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/trxusdt/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-06T02:00:40.471987",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.9433,
|
||||||
|
"best_threshold": 0.2433,
|
||||||
|
"best_precision": 0.439,
|
||||||
|
"best_recall": 0.947,
|
||||||
|
"samples": 2940,
|
||||||
|
"features": 26,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/trxusdt/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-06T22:37:17.762061",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.9493,
|
||||||
|
"best_threshold": 0.2613,
|
||||||
|
"best_precision": 0.448,
|
||||||
|
"best_recall": 0.975,
|
||||||
|
"samples": 2952,
|
||||||
|
"features": 26,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/trxusdt/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-06T23:35:11.188338",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.96,
|
||||||
|
"best_threshold": 0.6121,
|
||||||
|
"best_precision": 0.75,
|
||||||
|
"best_recall": 0.6,
|
||||||
|
"samples": 648,
|
||||||
|
"features": 26,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/trxusdt/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
|
||||||
|
}
|
||||||
|
]
|
||||||
0
models/xrpusdt/.gitkeep
Normal file
0
models/xrpusdt/.gitkeep
Normal file
BIN
models/xrpusdt/lgbm_filter.pkl
Normal file
BIN
models/xrpusdt/lgbm_filter.pkl
Normal file
Binary file not shown.
BIN
models/xrpusdt/lgbm_filter_prev.pkl
Normal file
BIN
models/xrpusdt/lgbm_filter_prev.pkl
Normal file
Binary file not shown.
102
models/xrpusdt/training_log.json
Normal file
102
models/xrpusdt/training_log.json
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"date": "2026-03-05T23:53:20.451588",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.9428,
|
||||||
|
"best_threshold": 0.8486,
|
||||||
|
"best_precision": 0.583,
|
||||||
|
"best_recall": 0.171,
|
||||||
|
"samples": 3222,
|
||||||
|
"features": 26,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/xrpusdt/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-06T02:00:24.712465",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.9456,
|
||||||
|
"best_threshold": 0.7213,
|
||||||
|
"best_precision": 0.6,
|
||||||
|
"best_recall": 0.22,
|
||||||
|
"samples": 3222,
|
||||||
|
"features": 26,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/xrpusdt/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-06T22:37:08.529734",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.9448,
|
||||||
|
"best_threshold": 0.7881,
|
||||||
|
"best_precision": 0.538,
|
||||||
|
"best_recall": 0.167,
|
||||||
|
"samples": 3234,
|
||||||
|
"features": 26,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/xrpusdt/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-06T23:35:02.930027",
|
||||||
|
"backend": "lgbm",
|
||||||
|
"auc": 0.9598,
|
||||||
|
"best_threshold": 0.4674,
|
||||||
|
"best_precision": 1.0,
|
||||||
|
"best_recall": 0.182,
|
||||||
|
"samples": 618,
|
||||||
|
"features": 26,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"model_path": "models/xrpusdt/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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
python-binance==1.0.19
|
python-binance>=1.0.28
|
||||||
pandas>=2.3.2
|
pandas>=2.3.2
|
||||||
pandas-ta==0.4.71b0
|
pandas-ta==0.4.71b0
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
pytest>=8.1.0
|
pytest>=8.1.0
|
||||||
pytest-asyncio>=0.24.0
|
pytest-asyncio>=0.24.0
|
||||||
aiohttp==3.9.3
|
aiohttp>=3.10.11
|
||||||
websockets==12.0
|
websockets==12.0
|
||||||
loguru==0.7.2
|
loguru==0.7.2
|
||||||
lightgbm>=4.3.0
|
lightgbm>=4.3.0
|
||||||
@@ -13,3 +13,6 @@ scikit-learn>=1.4.0
|
|||||||
joblib>=1.3.0
|
joblib>=1.3.0
|
||||||
pyarrow>=15.0.0
|
pyarrow>=15.0.0
|
||||||
onnxruntime>=1.18.0
|
onnxruntime>=1.18.0
|
||||||
|
optuna>=3.6.0
|
||||||
|
quantstats>=0.0.81
|
||||||
|
ccxt>=4.5.0
|
||||||
|
|||||||
31436
results/combined/backtest_20260306_222250.json
Normal file
31436
results/combined/backtest_20260306_222250.json
Normal file
File diff suppressed because it is too large
Load Diff
455
results/combined/backtest_20260306_222611.json
Normal file
455
results/combined/backtest_20260306_222611.json
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"symbols": [
|
||||||
|
"XRPUSDT",
|
||||||
|
"TRXUSDT",
|
||||||
|
"DOGEUSDT"
|
||||||
|
],
|
||||||
|
"start": null,
|
||||||
|
"end": null,
|
||||||
|
"initial_balance": 1000.0,
|
||||||
|
"leverage": 10,
|
||||||
|
"fee_pct": 0.04,
|
||||||
|
"slippage_pct": 0.01,
|
||||||
|
"use_ml": true,
|
||||||
|
"ml_threshold": 0.55,
|
||||||
|
"max_daily_loss_pct": 0.05,
|
||||||
|
"max_positions": 3,
|
||||||
|
"max_same_direction": 2,
|
||||||
|
"margin_max_ratio": 0.5,
|
||||||
|
"margin_min_ratio": 0.2,
|
||||||
|
"margin_decay_rate": 0.0006,
|
||||||
|
"atr_sl_mult": 1.5,
|
||||||
|
"atr_tp_mult": 3.0,
|
||||||
|
"min_notional": 5.0
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 15,
|
||||||
|
"total_pnl": 198.4051,
|
||||||
|
"return_pct": 19.84,
|
||||||
|
"win_rate": 53.33,
|
||||||
|
"avg_win": 32.5332,
|
||||||
|
"avg_loss": -8.8372,
|
||||||
|
"profit_factor": 4.21,
|
||||||
|
"max_drawdown_pct": 2.24,
|
||||||
|
"sharpe_ratio": 79.77,
|
||||||
|
"total_fees": 18.8564,
|
||||||
|
"close_reasons": {
|
||||||
|
"TAKE_PROFIT": 7,
|
||||||
|
"REVERSE_SIGNAL": 3,
|
||||||
|
"STOP_LOSS": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trades": [
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-05-23 02:15:00+00:00",
|
||||||
|
"exit_time": "2025-05-23 04:30:00+00:00",
|
||||||
|
"entry_price": 2.470853,
|
||||||
|
"exit_price": 2.438268,
|
||||||
|
"quantity": 674.5,
|
||||||
|
"sl": 2.487145,
|
||||||
|
"tp": 2.438268,
|
||||||
|
"gross_pnl": 21.978348,
|
||||||
|
"entry_fee": 0.666636,
|
||||||
|
"exit_fee": 0.657845,
|
||||||
|
"net_pnl": 20.653867,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.5847,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 75.08406565689027,
|
||||||
|
"macd_hist": 0.004905452274126379,
|
||||||
|
"atr": 0.010861550575088958,
|
||||||
|
"adx": 21.704459542796908
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-05-30 00:45:00+00:00",
|
||||||
|
"exit_time": "2025-05-30 02:15:00+00:00",
|
||||||
|
"entry_price": 2.155015,
|
||||||
|
"exit_price": 2.207692,
|
||||||
|
"quantity": 770.0,
|
||||||
|
"sl": 2.128677,
|
||||||
|
"tp": 2.207692,
|
||||||
|
"gross_pnl": 40.56076,
|
||||||
|
"entry_fee": 0.663745,
|
||||||
|
"exit_fee": 0.679969,
|
||||||
|
"net_pnl": 39.217046,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.7602,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 13.158390769693794,
|
||||||
|
"macd_hist": -0.00797002840932291,
|
||||||
|
"atr": 0.01755877038478085,
|
||||||
|
"adx": 31.17699185815243
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-06-14 04:15:00+00:00",
|
||||||
|
"exit_time": "2025-06-14 08:15:00+00:00",
|
||||||
|
"entry_price": 2.164584,
|
||||||
|
"exit_price": 2.169417,
|
||||||
|
"quantity": 757.7,
|
||||||
|
"sl": 2.175701,
|
||||||
|
"tp": 2.142349,
|
||||||
|
"gross_pnl": -3.662267,
|
||||||
|
"entry_fee": 0.656042,
|
||||||
|
"exit_fee": 0.657507,
|
||||||
|
"net_pnl": -4.975816,
|
||||||
|
"close_reason": "REVERSE_SIGNAL",
|
||||||
|
"ml_proba": 0.6115,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 69.92512937431012,
|
||||||
|
"macd_hist": 0.0026939087409630215,
|
||||||
|
"atr": 0.007411409293121909,
|
||||||
|
"adx": 20.278562659091943
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-08-14 12:30:00+00:00",
|
||||||
|
"exit_time": "2025-08-14 21:30:00+00:00",
|
||||||
|
"entry_price": 3.132487,
|
||||||
|
"exit_price": 3.035326,
|
||||||
|
"quantity": 524.6,
|
||||||
|
"sl": 3.181067,
|
||||||
|
"tp": 3.035326,
|
||||||
|
"gross_pnl": 50.970515,
|
||||||
|
"entry_fee": 0.657321,
|
||||||
|
"exit_fee": 0.636933,
|
||||||
|
"net_pnl": 49.676261,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.8857,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 20.976757311144258,
|
||||||
|
"macd_hist": -0.0032317207367513617,
|
||||||
|
"atr": 0.032386907216145205,
|
||||||
|
"adx": 38.70665879423988
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-09-01 00:15:00+00:00",
|
||||||
|
"exit_time": "2025-09-01 02:45:00+00:00",
|
||||||
|
"entry_price": 2.750075,
|
||||||
|
"exit_price": 2.73304,
|
||||||
|
"quantity": 586.2,
|
||||||
|
"sl": 2.73304,
|
||||||
|
"tp": 2.784144,
|
||||||
|
"gross_pnl": -9.985662,
|
||||||
|
"entry_fee": 0.644838,
|
||||||
|
"exit_fee": 0.640843,
|
||||||
|
"net_pnl": -11.271343,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.5754,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 20.77769666120342,
|
||||||
|
"macd_hist": -0.0054454742314916215,
|
||||||
|
"atr": 0.01135637668410908,
|
||||||
|
"adx": 32.97685850211662
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-10-05 19:00:00+00:00",
|
||||||
|
"exit_time": "2025-10-06 02:45:00+00:00",
|
||||||
|
"entry_price": 2.953995,
|
||||||
|
"exit_price": 2.990353,
|
||||||
|
"quantity": 548.6,
|
||||||
|
"sl": 2.935817,
|
||||||
|
"tp": 2.990353,
|
||||||
|
"gross_pnl": 19.945579,
|
||||||
|
"entry_fee": 0.648225,
|
||||||
|
"exit_fee": 0.656203,
|
||||||
|
"net_pnl": 18.641151,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.6037,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 22.68978567945751,
|
||||||
|
"macd_hist": -0.003579814992577557,
|
||||||
|
"atr": 0.012119078271027253,
|
||||||
|
"adx": 40.35268005132035
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-10-10 20:45:00+00:00",
|
||||||
|
"exit_time": "2025-10-10 21:00:00+00:00",
|
||||||
|
"entry_price": 2.49595,
|
||||||
|
"exit_price": 2.373231,
|
||||||
|
"quantity": 643.9,
|
||||||
|
"sl": 2.55731,
|
||||||
|
"tp": 2.373231,
|
||||||
|
"gross_pnl": 79.019141,
|
||||||
|
"entry_fee": 0.642857,
|
||||||
|
"exit_fee": 0.611249,
|
||||||
|
"net_pnl": 77.765034,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.8864,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 17.950089434981262,
|
||||||
|
"macd_hist": -0.010381022790605134,
|
||||||
|
"atr": 0.0409065283069771,
|
||||||
|
"adx": 56.13982003832872
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-10-14 16:15:00+00:00",
|
||||||
|
"exit_time": "2025-10-15 02:15:00+00:00",
|
||||||
|
"entry_price": 2.508449,
|
||||||
|
"exit_price": 2.523552,
|
||||||
|
"quantity": 612.4,
|
||||||
|
"sl": 2.544104,
|
||||||
|
"tp": 2.437139,
|
||||||
|
"gross_pnl": -9.2492,
|
||||||
|
"entry_fee": 0.61447,
|
||||||
|
"exit_fee": 0.618169,
|
||||||
|
"net_pnl": -10.481839,
|
||||||
|
"close_reason": "REVERSE_SIGNAL",
|
||||||
|
"ml_proba": 0.5683,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 68.85343626442496,
|
||||||
|
"macd_hist": 0.010657476860447013,
|
||||||
|
"atr": 0.023769893751947626,
|
||||||
|
"adx": 39.4255156509299
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-10-27 16:00:00+00:00",
|
||||||
|
"exit_time": "2025-10-27 21:00:00+00:00",
|
||||||
|
"entry_price": 2.674233,
|
||||||
|
"exit_price": 2.623904,
|
||||||
|
"quantity": 578.8,
|
||||||
|
"sl": 2.699397,
|
||||||
|
"tp": 2.623904,
|
||||||
|
"gross_pnl": 29.129941,
|
||||||
|
"entry_fee": 0.619138,
|
||||||
|
"exit_fee": 0.607486,
|
||||||
|
"net_pnl": 27.903316,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.5625,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 66.34709912155408,
|
||||||
|
"macd_hist": 0.005634259928464551,
|
||||||
|
"atr": 0.01677605457389115,
|
||||||
|
"adx": 23.34205636197947
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-11-28 01:45:00+00:00",
|
||||||
|
"exit_time": "2025-11-28 05:30:00+00:00",
|
||||||
|
"entry_price": 2.171517,
|
||||||
|
"exit_price": 2.200054,
|
||||||
|
"quantity": 699.4,
|
||||||
|
"sl": 2.157249,
|
||||||
|
"tp": 2.200054,
|
||||||
|
"gross_pnl": 19.958706,
|
||||||
|
"entry_fee": 0.607504,
|
||||||
|
"exit_fee": 0.615487,
|
||||||
|
"net_pnl": 18.735716,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.6381,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 22.942287299402874,
|
||||||
|
"macd_hist": -0.003478384036068617,
|
||||||
|
"atr": 0.009512299239799599,
|
||||||
|
"adx": 35.89384138114383
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-12-19 13:15:00+00:00",
|
||||||
|
"exit_time": "2025-12-19 15:15:00+00:00",
|
||||||
|
"entry_price": 1.878712,
|
||||||
|
"exit_price": 1.889911,
|
||||||
|
"quantity": 796.9,
|
||||||
|
"sl": 1.889911,
|
||||||
|
"tp": 1.856315,
|
||||||
|
"gross_pnl": -8.924064,
|
||||||
|
"entry_fee": 0.598858,
|
||||||
|
"exit_fee": 0.602428,
|
||||||
|
"net_pnl": -10.12535,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.5945,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 68.16547032772114,
|
||||||
|
"macd_hist": -4.5929936914913816e-05,
|
||||||
|
"atr": 0.007465649526915487,
|
||||||
|
"adx": 40.69667585881617
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-12-25 22:45:00+00:00",
|
||||||
|
"exit_time": "2025-12-25 23:30:00+00:00",
|
||||||
|
"entry_price": 1.844884,
|
||||||
|
"exit_price": 1.836907,
|
||||||
|
"quantity": 818.5,
|
||||||
|
"sl": 1.836907,
|
||||||
|
"tp": 1.86084,
|
||||||
|
"gross_pnl": -6.529692,
|
||||||
|
"entry_fee": 0.604015,
|
||||||
|
"exit_fee": 0.601403,
|
||||||
|
"net_pnl": -7.735111,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.6099,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 22.431779710524914,
|
||||||
|
"macd_hist": -0.0022220637884117073,
|
||||||
|
"atr": 0.005318421811566107,
|
||||||
|
"adx": 20.682478174103885
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2026-01-17 15:00:00+00:00",
|
||||||
|
"exit_time": "2026-01-17 15:15:00+00:00",
|
||||||
|
"entry_price": 2.074093,
|
||||||
|
"exit_price": 2.080899,
|
||||||
|
"quantity": 732.6,
|
||||||
|
"sl": 2.080899,
|
||||||
|
"tp": 2.06048,
|
||||||
|
"gross_pnl": -4.986389,
|
||||||
|
"entry_fee": 0.607792,
|
||||||
|
"exit_fee": 0.609787,
|
||||||
|
"net_pnl": -6.203967,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.594,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 69.16433708427633,
|
||||||
|
"macd_hist": 0.0015812464458042678,
|
||||||
|
"atr": 0.004537618137465387,
|
||||||
|
"adx": 16.189151941493567
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2026-01-30 01:30:00+00:00",
|
||||||
|
"exit_time": "2026-01-30 02:15:00+00:00",
|
||||||
|
"entry_price": 1.743626,
|
||||||
|
"exit_price": 1.733473,
|
||||||
|
"quantity": 875.8,
|
||||||
|
"sl": 1.766432,
|
||||||
|
"tp": 1.698012,
|
||||||
|
"gross_pnl": 8.891376,
|
||||||
|
"entry_fee": 0.610827,
|
||||||
|
"exit_fee": 0.60727,
|
||||||
|
"net_pnl": 7.673278,
|
||||||
|
"close_reason": "REVERSE_SIGNAL",
|
||||||
|
"ml_proba": 0.6829,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 19.89605260729724,
|
||||||
|
"macd_hist": -0.003114826868995284,
|
||||||
|
"atr": 0.015204543588406344,
|
||||||
|
"adx": 20.39618087837
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2026-02-28 06:15:00+00:00",
|
||||||
|
"exit_time": "2026-02-28 06:30:00+00:00",
|
||||||
|
"entry_price": 1.331433,
|
||||||
|
"exit_price": 1.322797,
|
||||||
|
"quantity": 1141.2,
|
||||||
|
"sl": 1.322797,
|
||||||
|
"tp": 1.348705,
|
||||||
|
"gross_pnl": -9.855576,
|
||||||
|
"entry_fee": 0.607773,
|
||||||
|
"exit_fee": 0.60383,
|
||||||
|
"net_pnl": -11.067179,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.7416,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 22.07257216583472,
|
||||||
|
"macd_hist": -0.0019878129960472814,
|
||||||
|
"atr": 0.005757434514952236,
|
||||||
|
"adx": 36.23941502849302
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"validation": {
|
||||||
|
"overall": "FAIL",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "exit_after_entry",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "모든 트레이드에서 청산 > 진입"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sl_tp_direction",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "SL/TP 방향 정합"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no_overlap",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "포지션 비중첩 확인"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "positive_fees",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "수수료 양수 확인"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no_negative_balance",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "잔고 양수 유지"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "win_rate_high",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "승률 정상 (53.3%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "win_rate_low",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "승률 정상 (53.3%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mdd_nonzero",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "MDD 정상 (2.2%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trade_frequency",
|
||||||
|
"passed": false,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "월 평균 1.6건 < 5건 — 신호 생성 부족"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "profit_factor_high",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "PF 정상 (4.21)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
3744
results/combined/strategy_sweep_20260306_232337.json
Normal file
3744
results/combined/strategy_sweep_20260306_232337.json
Normal file
File diff suppressed because it is too large
Load Diff
3405
results/combined/wf_backtest_20260306_224143.json
Normal file
3405
results/combined/wf_backtest_20260306_224143.json
Normal file
File diff suppressed because it is too large
Load Diff
86
results/compare/compare_2026-03-18.json
Normal file
86
results/compare/compare_2026-03-18.json
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"symbol": "SOLUSDT",
|
||||||
|
"best_params": {
|
||||||
|
"atr_sl_mult": 1.0,
|
||||||
|
"atr_tp_mult": 4.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 20,
|
||||||
|
"volume_multiplier": 2.5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 31,
|
||||||
|
"total_pnl": 909.294,
|
||||||
|
"return_pct": 90.93,
|
||||||
|
"win_rate": 38.71,
|
||||||
|
"avg_win": 117.2904,
|
||||||
|
"avg_loss": -26.2206,
|
||||||
|
"payoff_ratio": 4.47,
|
||||||
|
"max_consecutive_losses": 6,
|
||||||
|
"profit_factor": 2.83,
|
||||||
|
"max_drawdown_pct": 10.87,
|
||||||
|
"sharpe_ratio": 57.43,
|
||||||
|
"total_fees": 117.2484,
|
||||||
|
"close_reasons": {
|
||||||
|
"TAKE_PROFIT": 12,
|
||||||
|
"STOP_LOSS": 19
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "LINKUSDT",
|
||||||
|
"best_params": {
|
||||||
|
"atr_sl_mult": 2.0,
|
||||||
|
"atr_tp_mult": 3.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 0,
|
||||||
|
"volume_multiplier": 2.5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 38,
|
||||||
|
"total_pnl": 12.3248,
|
||||||
|
"return_pct": 1.23,
|
||||||
|
"win_rate": 39.47,
|
||||||
|
"avg_win": 88.1543,
|
||||||
|
"avg_loss": -56.9561,
|
||||||
|
"payoff_ratio": 1.55,
|
||||||
|
"max_consecutive_losses": 5,
|
||||||
|
"profit_factor": 1.01,
|
||||||
|
"max_drawdown_pct": 24.28,
|
||||||
|
"sharpe_ratio": 0.67,
|
||||||
|
"total_fees": 142.4705,
|
||||||
|
"close_reasons": {
|
||||||
|
"TAKE_PROFIT": 15,
|
||||||
|
"STOP_LOSS": 23
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "AVAXUSDT",
|
||||||
|
"best_params": {
|
||||||
|
"atr_sl_mult": 1.5,
|
||||||
|
"atr_tp_mult": 3.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 25,
|
||||||
|
"volume_multiplier": 2.5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 20,
|
||||||
|
"total_pnl": 497.5511,
|
||||||
|
"return_pct": 49.76,
|
||||||
|
"win_rate": 55.0,
|
||||||
|
"avg_win": 90.6485,
|
||||||
|
"avg_loss": -55.5092,
|
||||||
|
"payoff_ratio": 1.63,
|
||||||
|
"max_consecutive_losses": 3,
|
||||||
|
"profit_factor": 2.0,
|
||||||
|
"max_drawdown_pct": 8.89,
|
||||||
|
"sharpe_ratio": 47.39,
|
||||||
|
"total_fees": 76.184,
|
||||||
|
"close_reasons": {
|
||||||
|
"STOP_LOSS": 9,
|
||||||
|
"TAKE_PROFIT": 11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
7511
results/dogeusdt/strategy_sweep_20260317_172011.json
Normal file
7511
results/dogeusdt/strategy_sweep_20260317_172011.json
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user