Compare commits
37 Commits
41b0aa3f28
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6ba45f8de | ||
|
|
295ed7db76 | ||
|
|
ec7a6e427c | ||
|
|
f488720ca2 | ||
|
|
930d4d2c7a | ||
|
|
e31c4bf080 | ||
|
|
b8a371992f | ||
|
|
1cfb1b322a | ||
|
|
75b5c5d7fe | ||
|
|
af865c3db2 | ||
|
|
c94c605f3e | ||
|
|
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 |
18
.env.example
18
.env.example
@@ -1,9 +1,8 @@
|
|||||||
BINANCE_API_KEY=
|
BINANCE_API_KEY=
|
||||||
BINANCE_API_SECRET=
|
BINANCE_API_SECRET=
|
||||||
SYMBOLS=XRPUSDT,SOLUSDT,DOGEUSDT
|
SYMBOLS=XRPUSDT
|
||||||
CORRELATION_SYMBOLS=BTCUSDT,ETHUSDT
|
CORRELATION_SYMBOLS=BTCUSDT,ETHUSDT
|
||||||
LEVERAGE=10
|
LEVERAGE=20
|
||||||
RISK_PER_TRADE=0.02
|
|
||||||
DISCORD_WEBHOOK_URL=
|
DISCORD_WEBHOOK_URL=
|
||||||
ML_THRESHOLD=0.55
|
ML_THRESHOLD=0.55
|
||||||
NO_ML_FILTER=true
|
NO_ML_FILTER=true
|
||||||
@@ -15,19 +14,10 @@ SIGNAL_THRESHOLD=3
|
|||||||
ADX_THRESHOLD=25
|
ADX_THRESHOLD=25
|
||||||
VOL_MULTIPLIER=2.5
|
VOL_MULTIPLIER=2.5
|
||||||
|
|
||||||
# Per-symbol strategy params (2026-03-17 sweep optimized)
|
# Per-symbol strategy params (2026-03-21 운영 설정)
|
||||||
ATR_SL_MULT_XRPUSDT=1.5
|
ATR_SL_MULT_XRPUSDT=1.5
|
||||||
ATR_TP_MULT_XRPUSDT=4.0
|
ATR_TP_MULT_XRPUSDT=4.0
|
||||||
ADX_THRESHOLD_XRPUSDT=30
|
ADX_THRESHOLD_XRPUSDT=25
|
||||||
|
|
||||||
ATR_SL_MULT_SOLUSDT=1.0
|
|
||||||
ATR_TP_MULT_SOLUSDT=4.0
|
|
||||||
ADX_THRESHOLD_SOLUSDT=20
|
|
||||||
MARGIN_MAX_RATIO_SOLUSDT=0.08
|
|
||||||
|
|
||||||
ATR_SL_MULT_DOGEUSDT=2.0
|
|
||||||
ATR_TP_MULT_DOGEUSDT=2.0
|
|
||||||
ADX_THRESHOLD_DOGEUSDT=30
|
|
||||||
DASHBOARD_API_URL=http://10.1.10.24:8000
|
DASHBOARD_API_URL=http://10.1.10.24:8000
|
||||||
BINANCE_TESTNET_API_KEY=
|
BINANCE_TESTNET_API_KEY=
|
||||||
BINANCE_TESTNET_API_SECRET=
|
BINANCE_TESTNET_API_SECRET=
|
||||||
|
|||||||
245
ARCHITECTURE.md
245
ARCHITECTURE.md
@@ -12,6 +12,7 @@
|
|||||||
3. [5개 레이어 상세](#3-5개-레이어-상세) — 각 레이어의 역할과 동작 원리
|
3. [5개 레이어 상세](#3-5개-레이어-상세) — 각 레이어의 역할과 동작 원리
|
||||||
4. [MLOps 파이프라인](#4-mlops-파이프라인) — ML 모델의 학습·배포·모니터링 전체 흐름
|
4. [MLOps 파이프라인](#4-mlops-파이프라인) — ML 모델의 학습·배포·모니터링 전체 흐름
|
||||||
5. [핵심 동작 시나리오](#5-핵심-동작-시나리오) — 실제 상황별 봇의 동작 흐름도
|
5. [핵심 동작 시나리오](#5-핵심-동작-시나리오) — 실제 상황별 봇의 동작 흐름도
|
||||||
|
5-1. [MTF Pullback Bot](#5-1-mtf-pullback-bot) — 멀티타임프레임 풀백 전략 Dry-run 봇
|
||||||
6. [테스트 커버리지](#6-테스트-커버리지) — 무엇을 어떻게 테스트하는지
|
6. [테스트 커버리지](#6-테스트-커버리지) — 무엇을 어떻게 테스트하는지
|
||||||
7. [파일 구조](#7-파일-구조) — 전체 파일 역할 요약
|
7. [파일 구조](#7-파일-구조) — 전체 파일 역할 요약
|
||||||
|
|
||||||
@@ -35,15 +36,15 @@ CoinTrader는 **Binance Futures 자동매매 봇**입니다.
|
|||||||
|
|
||||||
```
|
```
|
||||||
main.py
|
main.py
|
||||||
└─ Config (SYMBOLS=XRPUSDT,SOLUSDT,DOGEUSDT)
|
└─ Config (SYMBOLS=XRPUSDT) # 멀티심볼 지원, 현재 XRP만 운영
|
||||||
└─ RiskManager (공유 싱글턴, asyncio.Lock)
|
└─ RiskManager (공유 싱글턴, asyncio.Lock)
|
||||||
└─ asyncio.gather(
|
└─ asyncio.gather(
|
||||||
TradingBot(symbol="XRPUSDT", risk=shared_risk),
|
TradingBot(symbol="XRPUSDT", risk=shared_risk),
|
||||||
TradingBot(symbol="SOLUSDT", risk=shared_risk),
|
|
||||||
TradingBot(symbol="DOGEUSDT", risk=shared_risk),
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **운영 이력**: SOL/DOGE/TRX는 파라미터 스윕에서 모든 조합에서 PF < 1.0으로 제외 (2026-03-21).
|
||||||
|
|
||||||
- **독립**: 각 봇은 자체 `Exchange`, `MLFilter`, `DataStream`, `SymbolStrategyParams`를 소유
|
- **독립**: 각 봇은 자체 `Exchange`, `MLFilter`, `DataStream`, `SymbolStrategyParams`를 소유
|
||||||
- **공유**: `RiskManager`만 싱글턴으로 글로벌 리스크(일일 손실 한도, 동일 방향 제한) 관리
|
- **공유**: `RiskManager`만 싱글턴으로 글로벌 리스크(일일 손실 한도, 동일 방향 제한) 관리
|
||||||
- **병렬**: `asyncio.gather()`로 동시 실행, 서로 간섭 없음
|
- **병렬**: `asyncio.gather()`로 동시 실행, 서로 간섭 없음
|
||||||
@@ -329,7 +330,7 @@ ML 필터를 통과한 신호를 실제 주문으로 변환하고, 리스크 한
|
|||||||
**주문 흐름:**
|
**주문 흐름:**
|
||||||
|
|
||||||
```
|
```
|
||||||
1. set_leverage(10x)
|
1. set_leverage(20x)
|
||||||
2. place_order(MARKET) ← 진입
|
2. place_order(MARKET) ← 진입
|
||||||
3. place_order(STOP_MARKET) ← SL 설정 (3회 재시도)
|
3. place_order(STOP_MARKET) ← SL 설정 (3회 재시도)
|
||||||
4. place_order(TAKE_PROFIT_MARKET) ← TP 설정 (3회 재시도)
|
4. place_order(TAKE_PROFIT_MARKET) ← TP 설정 (3회 재시도)
|
||||||
@@ -602,7 +603,7 @@ sequenceDiagram
|
|||||||
|
|
||||||
BOT->>EX: get_balance()
|
BOT->>EX: get_balance()
|
||||||
BOT->>RM: get_dynamic_margin_ratio(balance)
|
BOT->>RM: get_dynamic_margin_ratio(balance)
|
||||||
BOT->>EX: set_leverage(10)
|
BOT->>EX: set_leverage(20)
|
||||||
BOT->>EX: place_order(MARKET, BUY, qty=100.0)
|
BOT->>EX: place_order(MARKET, BUY, qty=100.0)
|
||||||
BOT->>EX: place_order(STOP_MARKET, SELL, stop=2.3100)
|
BOT->>EX: place_order(STOP_MARKET, SELL, stop=2.3100)
|
||||||
BOT->>EX: place_order(TAKE_PROFIT_MARKET, SELL, stop=2.4150)
|
BOT->>EX: place_order(TAKE_PROFIT_MARKET, SELL, stop=2.4150)
|
||||||
@@ -668,6 +669,203 @@ sequenceDiagram
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 5-1. MTF Pullback Bot
|
||||||
|
|
||||||
|
기존 메인 봇(`bot.py`)과 **별도로** 운영되는 멀티타임프레임 풀백 전략 봇입니다. 4월 OOS(Out-of-Sample) 검증 기간 동안 Dry-run 모드로 실행됩니다.
|
||||||
|
|
||||||
|
**파일:** `src/mtf_bot.py`
|
||||||
|
|
||||||
|
### 왜 MTF 봇을 만들었는가
|
||||||
|
|
||||||
|
메인 봇의 기술 지표 기반 접근(RSI+MACD+BB+EMA+StochRSI)은 PF 0.89로 수익성이 부족했습니다. 이를 개선하기 위해 여러 방향을 시도했으나 모두 실패했습니다:
|
||||||
|
|
||||||
|
| 시도 | 결과 | 판정 |
|
||||||
|
|------|------|------|
|
||||||
|
| ML 필터 (LightGBM 26피처) | ML OFF > ML ON | 폐기 — 피처 알파 부족 |
|
||||||
|
| 멀티심볼 확장 (SOL/DOGE/TRX) | 전 심볼 PF < 1.0 | 폐기 — XRP 단독 운영 |
|
||||||
|
| L/S Ratio 시그널 | 전 조합 PF < 1.0 | 폐기 — edge 없음 |
|
||||||
|
| FR × OI 변화율 | SHORT PF=1.88 / LONG PF=0.50 | 폐기 — 대칭성 실패 |
|
||||||
|
| Taker Buy/Sell Ratio | PF 0.93 | 폐기 — 거래비용 커버 불가 |
|
||||||
|
|
||||||
|
Binance 공개 API 피처 전수 테스트(2026-03-30) 결과, **단독 edge를 가진 피처가 없음**이 확정되었습니다. 핵심 교훈은 "r < 0.15인 시그널은 거래비용(0.08%) 커버 불가"라는 것이었습니다.
|
||||||
|
|
||||||
|
이에 **피처 추가가 아닌 접근 방식 자체를 전환**했습니다:
|
||||||
|
|
||||||
|
- **기존**: 15분봉 단일 타임프레임 + 지표 가중치 합산 → 피처 알파 부족
|
||||||
|
- **전환**: 멀티타임프레임 정보 비대칭 활용 → 1h 추세 확인 후 15m 풀백 패턴 진입
|
||||||
|
|
||||||
|
MTF 접근은 동일 Binance 데이터로도 **"언제 진입하느냐"를 바꿈으로써** edge를 확보하려는 시도입니다. 1h 추세 필터가 횡보장 거래를 제거하고, 3캔들 풀백 시퀀스가 노이즈 진입을 줄여 거래 품질을 높입니다.
|
||||||
|
|
||||||
|
현재 4월 OOS Dry-run으로 실전 검증 중이며, 50건 이상 누적 후 PF를 기준으로 LIVE 전환 여부를 판단합니다.
|
||||||
|
|
||||||
|
### 전략 핵심 아이디어
|
||||||
|
|
||||||
|
> **"1시간봉으로 추세를 확인하고, 15분봉에서 일시적 이탈(풀백) 후 복귀하는 순간에 추세 방향으로 진입한다."**
|
||||||
|
|
||||||
|
메인 봇(`bot.py`)이 RSI·MACD·BB 등 기술 지표 가중치 합산으로 신호를 만드는 것과 달리, MTF 봇은 **타임프레임 간 정보 비대칭**을 활용합니다. 상위 프레임(1h)의 거시 추세가 확인된 상태에서, 하위 프레임(15m)의 일시적 역행을 노이즈로 간주하고 추세 복귀 시점에 진입합니다.
|
||||||
|
|
||||||
|
### 아키텍처 (4개 모듈)
|
||||||
|
|
||||||
|
```
|
||||||
|
Module 1: TimeframeSync + DataFetcher
|
||||||
|
│ REST 폴링(30초 주기), deque(maxlen=250)으로 15m/1h 캔들 관리
|
||||||
|
│ Look-ahead bias 차단: _remove_incomplete_candle()로 미완성 봉 제외
|
||||||
|
▼
|
||||||
|
Module 2: MetaFilter (1h 거시 추세 판독)
|
||||||
|
│ EMA50 vs EMA200 + ADX > 20 → LONG_ALLOWED / SHORT_ALLOWED / WAIT
|
||||||
|
│ WAIT 상태에서는 모든 진입을 차단 (횡보장 방어)
|
||||||
|
▼
|
||||||
|
Module 3: TriggerStrategy (15m 풀백 패턴 인식)
|
||||||
|
│ 3캔들 시퀀스: t-2(기준) → t-1(풀백: EMA 이탈 + 거래량 고갈) → t(돌파: EMA 복귀)
|
||||||
|
│ Volume-backed 확인: vol_t-1 < vol_sma20 × 0.50
|
||||||
|
▼
|
||||||
|
Module 4: ExecutionManager (Dry-run 가상 주문)
|
||||||
|
│ 가상 포지션 진입/청산, ATR 기반 SL/TP 관리
|
||||||
|
│ 듀얼 레이어 킬스위치: Fast Kill (8연패) + Slow Kill (15거래 PF<0.75)
|
||||||
|
└→ Discord 알림 + JSONL 거래 기록
|
||||||
|
```
|
||||||
|
|
||||||
|
### 작동 원리 상세
|
||||||
|
|
||||||
|
#### Module 1: TimeframeSync + DataFetcher
|
||||||
|
|
||||||
|
**TimeframeSync** — 현재 시각이 캔들 마감 직후인지 판별합니다.
|
||||||
|
|
||||||
|
- 15분 캔들: 분(minute)이 `{0, 15, 30, 45}` 이고 초(second)가 2~5초 사이
|
||||||
|
- 1시간 캔들: 분이 `0`이고 초가 2~5초 사이
|
||||||
|
- 2~5초 윈도우는 Binance 서버가 캔들을 확정하는 딜레이를 고려한 것
|
||||||
|
|
||||||
|
**DataFetcher** — ccxt를 통해 Binance Futures REST API로 OHLCV 데이터를 관리합니다.
|
||||||
|
|
||||||
|
- 초기화 시 15m/1h 각각 250개 캔들을 `deque(maxlen=250)`에 적재
|
||||||
|
- 30초마다 최근 3개 캔들을 폴링하여 새 캔들만 추가 (timestamp 비교로 중복 방지)
|
||||||
|
- `_remove_incomplete_candle()`: 현재 진행 중인 캔들의 open timestamp를 계산하여, 마지막 캔들이 미완성이면 제거 → Look-ahead bias 원천 차단
|
||||||
|
- WebSocket 대신 REST 폴링을 선택한 이유: 연결 끊김 리스크 제거, 30초 주기면 15분봉 매매에 충분
|
||||||
|
|
||||||
|
#### Module 2: MetaFilter (1h 거시 추세 판독)
|
||||||
|
|
||||||
|
완성된 1h 캔들로 거시 시장 상태를 3가지로 분류합니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
입력: 1h OHLCV (완성 캔들만)
|
||||||
|
↓
|
||||||
|
EMA50 = EMA(close, 50) ← 중기 이동평균
|
||||||
|
EMA200 = EMA(close, 200) ← 장기 이동평균
|
||||||
|
ADX = ADX(14) ← 추세 강도 (0~100)
|
||||||
|
ATR = ATR(14) ← 변동성 (SL/TP 계산용)
|
||||||
|
↓
|
||||||
|
판정:
|
||||||
|
EMA50 > EMA200 AND ADX > 20 → LONG_ALLOWED (상승 추세 확인)
|
||||||
|
EMA50 < EMA200 AND ADX > 20 → SHORT_ALLOWED (하락 추세 확인)
|
||||||
|
그 외 → WAIT (횡보장, 진입 차단)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **ADX 20 기준**: ADX가 20 미만이면 추세가 약하다고 판단, EMA 크로스만으로 진입하지 않음
|
||||||
|
- **캔들 단위 캐싱**: 동일 1h 캔들 timestamp에 대해 지표를 재계산하지 않음 (`_cache_timestamp` 비교)
|
||||||
|
- MetaFilter가 `WAIT`를 반환하면 Module 3(TriggerStrategy)는 아예 호출되지 않음
|
||||||
|
|
||||||
|
#### Module 3: TriggerStrategy (15m 풀백 패턴 인식)
|
||||||
|
|
||||||
|
MetaFilter가 추세를 확인한 후, 15분봉에서 **3캔들 시퀀스** 풀백 패턴을 인식합니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
LONG 시나리오 (meta_state = LONG_ALLOWED):
|
||||||
|
|
||||||
|
t-2 ────── 기준 캔들 (Vol_SMA20 산출용)
|
||||||
|
t-1 ────── 풀백 캔들: ① close < EMA15 (이탈) AND ② volume < Vol_SMA20 × 0.50 (거래량 고갈)
|
||||||
|
t ────── 돌파 캔들: close > EMA15 (복귀) → EXECUTE_LONG 신호
|
||||||
|
|
||||||
|
SHORT 시나리오 (meta_state = SHORT_ALLOWED):
|
||||||
|
|
||||||
|
t-2 ────── 기준 캔들
|
||||||
|
t-1 ────── 풀백 캔들: ① close > EMA15 (이탈) AND ② volume < Vol_SMA20 × 0.50 (거래량 고갈)
|
||||||
|
t ────── 돌파 캔들: close < EMA15 (복귀) → EXECUTE_SHORT 신호
|
||||||
|
```
|
||||||
|
|
||||||
|
**3가지 조건이 모두 충족**되어야 진입 신호가 발생합니다:
|
||||||
|
|
||||||
|
1. **EMA 이탈** (t-1): 추세 반대 방향으로 일시 이탈 → 풀백 확인
|
||||||
|
2. **거래량 고갈** (t-1): `vol_t-1 / vol_sma20_t-2 < 0.50` → 이탈이 거래량 없는 가짜 움직임인지 확인
|
||||||
|
3. **EMA 복귀** (t): 추세 방향으로 다시 돌아옴 → 풀백 종료, 추세 재개 확인
|
||||||
|
|
||||||
|
하나라도 불충족이면 `HOLD`를 반환하며, 불충족 사유를 `_last_info`에 기록합니다.
|
||||||
|
|
||||||
|
#### Module 4: ExecutionManager (가상 주문 + SL/TP + 킬스위치)
|
||||||
|
|
||||||
|
**진입**: TriggerStrategy의 신호 + MetaFilter의 1h ATR 값으로 SL/TP를 설정합니다.
|
||||||
|
|
||||||
|
| 항목 | LONG | SHORT |
|
||||||
|
|------|------|-------|
|
||||||
|
| SL | entry - ATR × 1.5 | entry + ATR × 1.5 |
|
||||||
|
| TP | entry + ATR × 2.3 | entry - ATR × 2.3 |
|
||||||
|
| R:R | 1 : 1.53 | 1 : 1.53 |
|
||||||
|
|
||||||
|
- 중복 진입 차단: 이미 포지션이 있으면 새 신호 무시
|
||||||
|
- ATR이 None/0/NaN이면 주문 차단
|
||||||
|
|
||||||
|
**SL/TP 모니터링**: 매 루프(1초)마다 보유 포지션의 SL/TP 도달을 15m 캔들 high/low로 확인합니다.
|
||||||
|
|
||||||
|
- LONG: `low ≤ SL` → SL 청산, `high ≥ TP` → TP 청산
|
||||||
|
- SHORT: `high ≥ SL` → SL 청산, `low ≤ TP` → TP 청산
|
||||||
|
- SL+TP 동시 히트 시: **SL 우선** (보수적 접근)
|
||||||
|
- PnL은 bps(basis points) 단위로 계산: `(exit - entry) / entry × 10000`
|
||||||
|
|
||||||
|
**거래 기록**: 모든 청산은 `data/trade_history/mtf_{symbol}.jsonl`에 JSONL로 저장됩니다. 기록 항목: symbol, side, entry/exit price·ts, sl/tp price, atr, pnl_bps, reason.
|
||||||
|
|
||||||
|
**듀얼 킬스위치**:
|
||||||
|
|
||||||
|
| 종류 | 조건 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| Fast Kill | 최근 8거래 **연속** 손실 (pnl_bps < 0) | 급격한 손실 시 즉시 중단 |
|
||||||
|
| Slow Kill | 최근 15거래 PF < 0.75 | 만성적 손실 시 중단 |
|
||||||
|
|
||||||
|
- 부팅 시 JSONL에서 최근 N건 복원 → 소급 검증 (재시작해도 킬스위치 상태 유지)
|
||||||
|
- 킬스위치 발동 시: 신규 진입만 차단, 기존 포지션의 SL/TP 청산은 정상 작동
|
||||||
|
- 수동 해제: `RESET_KILL_SWITCH_MTF_{SYMBOL}=True` 환경변수 + 재시작
|
||||||
|
|
||||||
|
### 메인 루프 (MTFPullbackBot)
|
||||||
|
|
||||||
|
```
|
||||||
|
초기화: DataFetcher.initialize() → 250개 캔들 로드 → 초기 Meta 상태 출력 → Discord 알림
|
||||||
|
↓
|
||||||
|
while True (1초 주기):
|
||||||
|
├─ 30초마다: _poll_and_update() → 15m/1h 최신 캔들 추가
|
||||||
|
├─ 15m 캔들 마감 감지 (TimeframeSync):
|
||||||
|
│ ├─ Heartbeat 로그 (Meta, ADX, EMA50/200, ATR, Close, Position)
|
||||||
|
│ ├─ TriggerStrategy.generate_signal(df_15m, meta_state)
|
||||||
|
│ ├─ 신호 ≠ HOLD → ExecutionManager.execute() → Discord 진입 알림
|
||||||
|
│ └─ 신호 = HOLD → 사유 로그
|
||||||
|
└─ 포지션 보유 중: _check_sl_tp() → SL/TP 도달 시 청산 + Discord 알림
|
||||||
|
```
|
||||||
|
|
||||||
|
- 1초 루프인 이유: TimeframeSync의 2~5초 윈도우를 놓치지 않기 위함
|
||||||
|
- 15m 중복 체크 방지: `_last_15m_check_ts`로 1분 이내 같은 캔들 이중 처리 차단
|
||||||
|
- 캔들 마감 감지 시 즉시 `_poll_and_update()` 한 번 더 호출하여 최신 데이터 보장
|
||||||
|
|
||||||
|
### 메인 봇과의 차이점
|
||||||
|
|
||||||
|
| 항목 | 메인 봇 (`bot.py`) | MTF 봇 (`mtf_bot.py`) |
|
||||||
|
|------|-------------------|----------------------|
|
||||||
|
| 데이터 소스 | WebSocket (실시간 스트림) | REST 폴링 (30초 주기) |
|
||||||
|
| 타임프레임 | 15분봉 단일 | 1h (추세) + 15m (진입) |
|
||||||
|
| 신호 방식 | RSI·MACD·BB·EMA·StochRSI 가중치 합산 | 3캔들 풀백 시퀀스 패턴 |
|
||||||
|
| ML 필터 | LightGBM/ONNX (26 피처) | 없음 (패턴 자체가 필터) |
|
||||||
|
| 상관관계 | BTC/ETH 피처 사용 | 사용 안 함 |
|
||||||
|
| SL/TP 계산 | 15m ATR 기반 | 1h ATR 기반 |
|
||||||
|
| 반대 시그널 재진입 | 지원 (close → 역방향 open) | 미지원 (포지션 중 신호 무시) |
|
||||||
|
| 실행 모드 | Live (실주문) | Dry-run (가상 주문) |
|
||||||
|
| 프로세스 | 메인 프로세스 내 asyncio.gather | 별도 프로세스/Docker 서비스 |
|
||||||
|
|
||||||
|
### 설계 원칙
|
||||||
|
|
||||||
|
- **Look-ahead bias 원천 차단**: `_remove_incomplete_candle()`이 현재 진행 중인 캔들을 조건부 제거. 버퍼 250개 → 미완성 봉 제외 → EMA 200 정상 계산
|
||||||
|
- **REST 폴링 안정성**: WebSocket 대신 30초 주기 REST 폴링으로 연결 끊김 리스크 제거
|
||||||
|
- **Binance 서버 딜레이 고려**: 캔들 마감 판별 시 2~5초 윈도우 적용
|
||||||
|
- **메인 봇과 독립**: `bot.py`와 별도 프로세스, 별도 Docker 서비스로 배포
|
||||||
|
- **듀얼 킬스위치**: `ExecutionManager`에 내장. Fast Kill(8연패) + Slow Kill(15거래 PF<0.75, bps 기반). 부팅 시 JSONL에서 이력 복원 + 소급 검증. 수동 해제: `RESET_KILL_SWITCH_MTF_{SYMBOL}=True`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 6. 테스트 커버리지
|
## 6. 테스트 커버리지
|
||||||
|
|
||||||
### 6.1 테스트 실행
|
### 6.1 테스트 실행
|
||||||
@@ -677,25 +875,29 @@ pytest tests/ -v # 전체 실행
|
|||||||
bash scripts/run_tests.sh # 래퍼 스크립트 실행
|
bash scripts/run_tests.sh # 래퍼 스크립트 실행
|
||||||
```
|
```
|
||||||
|
|
||||||
`tests/` 폴더에 15개 테스트 파일, 총 **138개의 테스트 케이스**가 작성되어 있습니다.
|
`tests/` 폴더에 19개 테스트 파일, 총 **191개의 테스트 케이스**가 작성되어 있습니다.
|
||||||
|
|
||||||
### 6.2 모듈별 테스트 현황
|
### 6.2 모듈별 테스트 현황
|
||||||
|
|
||||||
| 테스트 파일 | 대상 모듈 | 케이스 | 주요 검증 항목 |
|
| 테스트 파일 | 대상 모듈 | 케이스 | 주요 검증 항목 |
|
||||||
|------------|----------|:------:|--------------|
|
|------------|----------|:------:|--------------|
|
||||||
| `test_bot.py` | `src/bot.py` | 11 | 반대 시그널 재진입, ML 차단 시 스킵, OI/펀딩비 피처 전달 |
|
| `test_bot.py` | `src/bot.py` | 18 | 반대 시그널 재진입, ML 차단 시 스킵, OI/펀딩비 피처 전달 |
|
||||||
| `test_indicators.py` | `src/indicators.py` | 7 | RSI 범위, MACD 컬럼, 볼린저 대소관계, ADX 횡보장 차단 |
|
| `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_features.py` | `src/ml_features.py` | 14 | 26개 피처 수, RS 분모 0 처리, NaN 없음 |
|
||||||
| `test_ml_filter.py` | `src/ml_filter.py` | 5 | 모델 없을 때 폴백, 임계값 판단, 핫리로드 |
|
| `test_ml_filter.py` | `src/ml_filter.py` | 5 | 모델 없을 때 폴백, 임계값 판단, 핫리로드 |
|
||||||
| `test_risk_manager.py` | `src/risk_manager.py` | 13 | 일일 손실 한도, 동일 방향 제한, 동적 증거금 비율 |
|
| `test_risk_manager.py` | `src/risk_manager.py` | 15 | 일일 손실 한도, 동일 방향 제한, 동적 증거금 비율 |
|
||||||
| `test_exchange.py` | `src/exchange.py` | 8 | 수량 계산, OI·펀딩비 조회 정상/오류 |
|
| `test_exchange.py` | `src/exchange.py` | 12 | 수량 계산, OI·펀딩비 조회 정상/오류 |
|
||||||
| `test_data_stream.py` | `src/data_stream.py` | 6 | 3심볼 버퍼, 캔들 파싱, 프리로드 200개 |
|
| `test_data_stream.py` | `src/data_stream.py` | 7 | 3심볼 버퍼, 캔들 파싱, 프리로드 200개 |
|
||||||
| `test_label_builder.py` | `src/label_builder.py` | 4 | TP/SL 도달 레이블, 미결 → None |
|
| `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_dataset_builder.py` | `src/dataset_builder.py` | 14 | DataFrame 반환, 필수 컬럼, inf/NaN 없음 |
|
||||||
| `test_mlx_filter.py` | `src/mlx_filter.py` | 5 | GPU 학습, 저장/로드 동일 예측 (Apple Silicon 전용) |
|
| `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_fetch_history.py` | `scripts/fetch_history.py` | 5 | OI=0 Upsert, 중복 방지, 타임스탬프 정렬 |
|
||||||
| `test_config.py` | `src/config.py` | 6 | 환경변수 로드, symbols 리스트 파싱 |
|
| `test_config.py` | `src/config.py` | 9 | 환경변수 로드, symbols 리스트 파싱 |
|
||||||
| `test_weekly_report.py` | `scripts/weekly_report.py` | 15 | 백테스트, 대시보드 API, 추이 분석, ML 트리거, 스윕 |
|
| `test_weekly_report.py` | `scripts/weekly_report.py` | 17 | 백테스트, 대시보드 API, 추이 분석, ML 트리거, 스윕 |
|
||||||
|
| `test_dashboard_api.py` | `dashboard/` | 16 | 대시보드 API 엔드포인트, 거래 통계 |
|
||||||
|
| `test_log_parser.py` | `dashboard/` | 8 | 로그 파싱, 필터링 |
|
||||||
|
| `test_ml_pipeline_fixes.py` | ML 파이프라인 | 7 | ML 파이프라인 버그 수정 검증 |
|
||||||
|
| `test_mtf_bot.py` | `src/mtf_bot.py` | 28 | MetaFilter, TriggerStrategy, ExecutionManager, SL/TP 체크, 킬스위치 |
|
||||||
|
|
||||||
> `test_mlx_filter.py`는 Apple Silicon(`mlx` 패키지)이 없는 환경에서 자동 스킵됩니다.
|
> `test_mlx_filter.py`는 Apple Silicon(`mlx` 패키지)이 없는 환경에서 자동 스킵됩니다.
|
||||||
|
|
||||||
@@ -720,6 +922,7 @@ bash scripts/run_tests.sh # 래퍼 스크립트 실행
|
|||||||
| OI 변화율 계산 | ✅ | ✅ | `test_bot` |
|
| OI 변화율 계산 | ✅ | ✅ | `test_bot` |
|
||||||
| Parquet Upsert | ✅ | — | `test_fetch_history` |
|
| Parquet Upsert | ✅ | — | `test_fetch_history` |
|
||||||
| 주간 리포트 | ✅ | ✅ | `test_weekly_report` |
|
| 주간 리포트 | ✅ | ✅ | `test_weekly_report` |
|
||||||
|
| MTF Pullback Bot | ✅ | ✅ | `test_mtf_bot` (20 cases) |
|
||||||
| User Data Stream TP/SL | ❌ | — | 미작성 (WebSocket 의존) |
|
| User Data Stream TP/SL | ❌ | — | 미작성 (WebSocket 의존) |
|
||||||
| Discord 알림 전송 | ❌ | — | 미작성 (외부 웹훅 의존) |
|
| Discord 알림 전송 | ❌ | — | 미작성 (외부 웹훅 의존) |
|
||||||
|
|
||||||
@@ -750,6 +953,8 @@ bash scripts/run_tests.sh # 래퍼 스크립트 실행
|
|||||||
| `src/label_builder.py` | MLOps | 학습 레이블 생성 (ATR SL/TP 룩어헤드) |
|
| `src/label_builder.py` | MLOps | 학습 레이블 생성 (ATR SL/TP 룩어헤드) |
|
||||||
| `src/dataset_builder.py` | MLOps | 벡터화 데이터셋 빌더 (학습용) |
|
| `src/dataset_builder.py` | MLOps | 벡터화 데이터셋 빌더 (학습용) |
|
||||||
| `src/backtester.py` | MLOps | 백테스트 엔진 (단일 + Walk-Forward) |
|
| `src/backtester.py` | MLOps | 백테스트 엔진 (단일 + Walk-Forward) |
|
||||||
|
| `src/mtf_bot.py` | MTF Bot | 멀티타임프레임 풀백 봇 (1h MetaFilter + 15m TriggerStrategy + Dry-run ExecutionManager) |
|
||||||
|
| `src/backtest_validator.py` | MLOps | 백테스트 결과 검증 |
|
||||||
| `src/logger_setup.py` | — | Loguru 로거 설정 |
|
| `src/logger_setup.py` | — | Loguru 로거 설정 |
|
||||||
| `scripts/fetch_history.py` | MLOps | 과거 캔들 + OI/펀딩비 수집 |
|
| `scripts/fetch_history.py` | MLOps | 과거 캔들 + OI/펀딩비 수집 |
|
||||||
| `scripts/train_model.py` | MLOps | LightGBM 모델 학습 |
|
| `scripts/train_model.py` | MLOps | LightGBM 모델 학습 |
|
||||||
@@ -762,4 +967,16 @@ bash scripts/run_tests.sh # 래퍼 스크립트 실행
|
|||||||
| `scripts/compare_symbols.py` | MLOps | 종목 비교 백테스트 (심볼별 파라미터 sweep) |
|
| `scripts/compare_symbols.py` | MLOps | 종목 비교 백테스트 (심볼별 파라미터 sweep) |
|
||||||
| `scripts/position_sizing_analysis.py` | MLOps | Robust Monte Carlo 포지션 사이징 분석 |
|
| `scripts/position_sizing_analysis.py` | MLOps | Robust Monte Carlo 포지션 사이징 분석 |
|
||||||
| `scripts/run_backtest.py` | MLOps | 단일 백테스트 CLI |
|
| `scripts/run_backtest.py` | MLOps | 단일 백테스트 CLI |
|
||||||
|
| `scripts/mtf_backtest.py` | MLOps | MTF 풀백 전략 백테스트 |
|
||||||
|
| `scripts/evaluate_oos.py` | MLOps | OOS Dry-run 평가 스크립트 |
|
||||||
|
| `scripts/revalidate_apr15.py` | MLOps | 4월 15일 재검증 스크립트 |
|
||||||
|
| `scripts/collect_oi.py` | MLOps | OI 데이터 수집 |
|
||||||
|
| `scripts/collect_ls_ratio.py` | MLOps | 롱/숏 비율 수집 |
|
||||||
|
| `scripts/fr_oi_backtest.py` | MLOps | 펀딩비+OI 백테스트 |
|
||||||
|
| `scripts/funding_oi_analysis.py` | MLOps | 펀딩비+OI 분석 |
|
||||||
|
| `scripts/ls_ratio_backtest.py` | MLOps | 롱/숏 비율 백테스트 |
|
||||||
|
| `scripts/profile_training.py` | MLOps | 학습 프로파일링 |
|
||||||
|
| `scripts/taker_ratio_analysis.py` | MLOps | 테이커 비율 분석 |
|
||||||
|
| `scripts/trade_ls_analysis.py` | MLOps | 거래 롱/숏 분석 |
|
||||||
|
| `scripts/verify_prod_api.py` | MLOps | 프로덕션 API 검증 |
|
||||||
| `models/{symbol}/active_lgbm_params.json` | MLOps | 심볼별 승인된 LightGBM 파라미터 |
|
| `models/{symbol}/active_lgbm_params.json` | MLOps | 심볼별 승인된 LightGBM 파라미터 |
|
||||||
|
|||||||
13
CLAUDE.md
13
CLAUDE.md
@@ -90,7 +90,7 @@ bash scripts/deploy_model.sh --symbol XRPUSDT
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Environment variables via `.env` file (see `.env.example`). Key vars: `BINANCE_API_KEY`, `BINANCE_API_SECRET`, `SYMBOLS` (comma-separated, e.g. `XRPUSDT,TRXUSDT`), `CORRELATION_SYMBOLS` (default `BTCUSDT,ETHUSDT`), `LEVERAGE`, `DISCORD_WEBHOOK_URL`, `MARGIN_MAX_RATIO`, `MARGIN_MIN_RATIO`, `MAX_SAME_DIRECTION` (default 2), `NO_ML_FILTER`.
|
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)`.
|
`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)`.
|
||||||
|
|
||||||
@@ -144,3 +144,14 @@ All design documents and implementation plans are stored in `docs/plans/` with t
|
|||||||
| 2026-03-19 | `critical-bugfixes` (C5,C1,C3,C8) | Completed |
|
| 2026-03-19 | `critical-bugfixes` (C5,C1,C3,C8) | Completed |
|
||||||
| 2026-03-21 | `dashboard-code-review-r2` (#14,#19) | Completed |
|
| 2026-03-21 | `dashboard-code-review-r2` (#14,#19) | Completed |
|
||||||
| 2026-03-21 | `code-review-fixes-r2` (9 issues) | 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) | 설계 완료, 구현 대기 |
|
||||||
|
| 2026-03-30 | `ls-ratio-backtest` (design + result) | Edge 없음 확정, 폐기 |
|
||||||
|
| 2026-03-30 | `fr-oi-backtest` (result) | SHORT PF=1.88이나 대칭성 실패(Case2), 폐기 |
|
||||||
|
| 2026-03-30 | `public-api-research-closed` | Binance 공개 API 전수 테스트 완료, 단독 edge 없음 |
|
||||||
|
| 2026-03-30 | `mtf-pullback-bot` | MTF Pullback Bot 배포, 4월 OOS Dry-run 검증 진행 중 |
|
||||||
|
|||||||
25
Jenkinsfile
vendored
25
Jenkinsfile
vendored
@@ -47,10 +47,15 @@ pipeline {
|
|||||||
if (changes == 'ALL') {
|
if (changes == 'ALL') {
|
||||||
// 첫 빌드이거나 diff 실패 시 전체 빌드
|
// 첫 빌드이거나 diff 실패 시 전체 빌드
|
||||||
env.BOT_CHANGED = 'true'
|
env.BOT_CHANGED = 'true'
|
||||||
|
env.MTF_CHANGED = 'true'
|
||||||
env.DASH_API_CHANGED = 'true'
|
env.DASH_API_CHANGED = 'true'
|
||||||
env.DASH_UI_CHANGED = 'true'
|
env.DASH_UI_CHANGED = 'true'
|
||||||
} else {
|
} else {
|
||||||
env.BOT_CHANGED = (changes =~ /(?m)^(src\/|main\.py|requirements\.txt|Dockerfile)/).find() ? 'true' : 'false'
|
// mtf_bot.py 변경 감지 (mtf-bot 서비스만 재시작)
|
||||||
|
env.MTF_CHANGED = (changes =~ /(?m)^src\/mtf_bot\.py/).find() ? 'true' : 'false'
|
||||||
|
// src/ 변경 중 mtf_bot.py만 바뀐 경우 메인 봇은 재시작 불필요
|
||||||
|
def botFiles = changes.split('\n').findAll { it =~ /^(src\/(?!mtf_bot\.py)|scripts\/|main\.py|requirements\.txt|Dockerfile)/ }
|
||||||
|
env.BOT_CHANGED = botFiles.size() > 0 ? 'true' : 'false'
|
||||||
env.DASH_API_CHANGED = (changes =~ /(?m)^dashboard\/api\//).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'
|
env.DASH_UI_CHANGED = (changes =~ /(?m)^dashboard\/ui\//).find() ? 'true' : 'false'
|
||||||
}
|
}
|
||||||
@@ -62,7 +67,7 @@ pipeline {
|
|||||||
env.COMPOSE_CHANGED = 'false'
|
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}"
|
echo "BOT_CHANGED=${env.BOT_CHANGED}, MTF_CHANGED=${env.MTF_CHANGED}, DASH_API_CHANGED=${env.DASH_API_CHANGED}, DASH_UI_CHANGED=${env.DASH_UI_CHANGED}, COMPOSE_CHANGED=${env.COMPOSE_CHANGED}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +75,7 @@ pipeline {
|
|||||||
stage('Build Docker Images') {
|
stage('Build Docker Images') {
|
||||||
parallel {
|
parallel {
|
||||||
stage('Bot') {
|
stage('Bot') {
|
||||||
when { expression { env.BOT_CHANGED == 'true' } }
|
when { expression { env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true' } }
|
||||||
steps {
|
steps {
|
||||||
sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ."
|
sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ."
|
||||||
}
|
}
|
||||||
@@ -95,7 +100,7 @@ pipeline {
|
|||||||
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"
|
||||||
script {
|
script {
|
||||||
if (env.BOT_CHANGED == 'true') {
|
if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') {
|
||||||
sh "docker push ${FULL_IMAGE}"
|
sh "docker push ${FULL_IMAGE}"
|
||||||
sh "docker push ${LATEST_IMAGE}"
|
sh "docker push ${LATEST_IMAGE}"
|
||||||
}
|
}
|
||||||
@@ -123,7 +128,13 @@ pipeline {
|
|||||||
|
|
||||||
// 변경된 서비스만 pull & recreate (나머지는 중단 없음)
|
// 변경된 서비스만 pull & recreate (나머지는 중단 없음)
|
||||||
def services = []
|
def services = []
|
||||||
if (env.BOT_CHANGED == 'true') services.add('cointrader')
|
if (env.BOT_CHANGED == 'true') {
|
||||||
|
services.add('cointrader')
|
||||||
|
services.add('ls-ratio-collector')
|
||||||
|
}
|
||||||
|
if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') {
|
||||||
|
services.add('mtf-bot')
|
||||||
|
}
|
||||||
if (env.DASH_API_CHANGED == 'true') services.add('dashboard-api')
|
if (env.DASH_API_CHANGED == 'true') services.add('dashboard-api')
|
||||||
if (env.DASH_UI_CHANGED == 'true') services.add('dashboard-ui')
|
if (env.DASH_UI_CHANGED == 'true') services.add('dashboard-ui')
|
||||||
|
|
||||||
@@ -141,7 +152,7 @@ pipeline {
|
|||||||
stage('Cleanup') {
|
stage('Cleanup') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
if (env.BOT_CHANGED == 'true') {
|
if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') {
|
||||||
sh "docker rmi ${FULL_IMAGE} || true"
|
sh "docker rmi ${FULL_IMAGE} || true"
|
||||||
sh "docker rmi ${LATEST_IMAGE} || true"
|
sh "docker rmi ${LATEST_IMAGE} || true"
|
||||||
}
|
}
|
||||||
@@ -164,7 +175,7 @@ pipeline {
|
|||||||
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- 🤖 봇: ${env.BOT_CHANGED}\\n- 📊 API: ${env.DASH_API_CHANGED}\\n- 🖥️ UI: ${env.DASH_UI_CHANGED}"}' \
|
-d '{"content": "✅ **[배포 성공]** `cointrader` (Build #${env.BUILD_NUMBER}) 운영 서버(10.1.10.24) 배포 완료!\\n- 🤖 봇: ${env.BOT_CHANGED}\\n- 📈 MTF: ${env.MTF_CHANGED}\\n- 📊 API: ${env.DASH_API_CHANGED}\\n- 🖥️ UI: ${env.DASH_UI_CHANGED}"}' \
|
||||||
${DISCORD_WEBHOOK}
|
${DISCORD_WEBHOOK}
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -1,6 +1,8 @@
|
|||||||
# CoinTrader
|
# CoinTrader
|
||||||
|
|
||||||
Binance Futures 자동매매 봇. 복합 기술 지표와 ML 필터(LightGBM / MLX 신경망)를 결합하여 다중 심볼(XRP, TRX, DOGE 등) 선물 포지션을 동시에 자동 진입·청산하며, Discord로 실시간 알림을 전송합니다.
|
Binance Futures 자동매매 봇. 복합 기술 지표와 킬스위치로 XRPUSDT 선물 포지션을 자동 진입·청산하며, Discord로 실시간 알림을 전송합니다. 멀티심볼 아키텍처를 지원하지만, 현재 XRP만 운영 중입니다.
|
||||||
|
|
||||||
|
> **심볼 운영 이력**: SOL, DOGE, TRX는 파라미터 스윕에서 모든 ADX 수준에서 PF < 1.0으로, 현재 전략으로는 수익을 낼 수 없어 제외되었습니다 (2026-03-21). ML 필터도 기술 지표 기반 피처의 예측력 한계로 비활성화 상태 (`NO_ML_FILTER=true`).
|
||||||
|
|
||||||
> **이 봇은 실제 자산을 거래합니다.** 운영 전 반드시 Binance Testnet에서 충분히 검증하세요.
|
> **이 봇은 실제 자산을 거래합니다.** 운영 전 반드시 Binance Testnet에서 충분히 검증하세요.
|
||||||
> 과거 수익이 미래 수익을 보장하지 않습니다. 투자 손실에 대한 책임은 사용자 본인에게 있습니다.
|
> 과거 수익이 미래 수익을 보장하지 않습니다. 투자 손실에 대한 책임은 사용자 본인에게 있습니다.
|
||||||
@@ -23,6 +25,7 @@ Binance Futures 자동매매 봇. 복합 기술 지표와 ML 필터(LightGBM / M
|
|||||||
- **모니터링 대시보드**: 거래 내역, 수익 통계, 차트를 웹에서 조회
|
- **모니터링 대시보드**: 거래 내역, 수익 통계, 차트를 웹에서 조회
|
||||||
- **주간 전략 리포트**: 자동 성능 측정, 추이 추적, 킬스위치 모니터링, ML 재학습 시점 판단
|
- **주간 전략 리포트**: 자동 성능 측정, 추이 추적, 킬스위치 모니터링, ML 재학습 시점 판단
|
||||||
- **종목 비교 분석**: 심볼별 파라미터 sweep + Robust Monte Carlo 포지션 사이징
|
- **종목 비교 분석**: 심볼별 파라미터 sweep + Robust Monte Carlo 포지션 사이징
|
||||||
|
- **MTF Pullback Bot**: 1h MetaFilter(EMA50/200 + ADX) + 15m 3캔들 풀백 시퀀스 기반 Dry-run 봇 (OOS 검증용)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -52,7 +55,7 @@ cp .env.example .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
|
||||||
SYMBOLS=XRPUSDT,SOLUSDT,DOGEUSDT # 거래할 심볼 (쉼표 구분)
|
SYMBOLS=XRPUSDT # 거래할 심볼 (쉼표 구분, 멀티심볼 지원)
|
||||||
|
|
||||||
# 권장
|
# 권장
|
||||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||||
@@ -129,21 +132,14 @@ Discord 웹훅을 설정했다면 진입/청산 시 실시간 알림을 받게
|
|||||||
**심볼별 오버라이드**: `{환경변수}_{심볼}` 형태로 심볼마다 독립 설정 가능. 미설정 시 전역 기본값 사용.
|
**심볼별 오버라이드**: `{환경변수}_{심볼}` 형태로 심볼마다 독립 설정 가능. 미설정 시 전역 기본값 사용.
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# 예시: 스윕 최적화 결과
|
# 현재 운영 설정 (2026-03-21)
|
||||||
ATR_SL_MULT_XRPUSDT=1.5
|
ATR_SL_MULT_XRPUSDT=1.5
|
||||||
ATR_TP_MULT_XRPUSDT=4.0
|
ATR_TP_MULT_XRPUSDT=4.0
|
||||||
ADX_THRESHOLD_XRPUSDT=30
|
ADX_THRESHOLD_XRPUSDT=25
|
||||||
|
|
||||||
ATR_SL_MULT_SOLUSDT=1.0
|
|
||||||
ATR_TP_MULT_SOLUSDT=4.0
|
|
||||||
ADX_THRESHOLD_SOLUSDT=20
|
|
||||||
MARGIN_MAX_RATIO_SOLUSDT=0.08
|
|
||||||
|
|
||||||
ATR_SL_MULT_DOGEUSDT=2.0
|
|
||||||
ATR_TP_MULT_DOGEUSDT=2.0
|
|
||||||
ADX_THRESHOLD_DOGEUSDT=30
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **제외된 심볼**: SOLUSDT(PF 0.00~0.83), DOGEUSDT(PF 0.70~0.83), TRXUSDT(PF 0.08) — 모든 파라미터 조합에서 PF < 1.0.
|
||||||
|
|
||||||
### ML 필터
|
### ML 필터
|
||||||
|
|
||||||
ML 필터는 기술 지표 신호를 한 번 더 검증하여 오진입을 차단합니다. 기본적으로 **비활성화** 상태입니다.
|
ML 필터는 기술 지표 신호를 한 번 더 검증하여 오진입을 차단합니다. 기본적으로 **비활성화** 상태입니다.
|
||||||
@@ -151,7 +147,7 @@ ML 필터는 기술 지표 신호를 한 번 더 검증하여 오진입을 차
|
|||||||
- `NO_ML_FILTER=true` (기본값) — ML 없이 기술 지표만으로 운영
|
- `NO_ML_FILTER=true` (기본값) — ML 없이 기술 지표만으로 운영
|
||||||
- `NO_ML_FILTER=false` — ML 필터 활성화 (모델 파일 필요)
|
- `NO_ML_FILTER=false` — ML 필터 활성화 (모델 파일 필요)
|
||||||
|
|
||||||
> 현재 기본값이 비활성화인 이유: 학습 데이터가 충분히 축적되기 전까지 ML 모델의 예측력이 낮습니다. ADX 필터와 거래량 배수 조합만으로 PF 1.5 이상을 달성하고 있어, 충분한 거래 데이터(150건 이상)가 쌓일 때까지 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만으로는 수수료를 이기는 알파를 만들 수 없었습니다. 오더북/청산 데이터 등 새로운 피처 소스에서 알파가 확인되면 재활성화 예정.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -180,7 +176,7 @@ ML 필터는 기술 지표 신호를 한 번 더 검증하여 오진입을 차
|
|||||||
| **Slow Kill** | 최근 15거래 Profit Factor < 0.75 | 점진적 엣지 소실 |
|
| **Slow Kill** | 최근 15거래 Profit Factor < 0.75 | 점진적 엣지 소실 |
|
||||||
|
|
||||||
**동작 방식:**
|
**동작 방식:**
|
||||||
- 심볼별 독립 제어: SOL이 킬되어도 XRP/DOGE는 정상 운영
|
- 심볼별 독립 제어: 한 심볼이 킬되어도 다른 심볼은 정상 운영
|
||||||
- 진입만 차단: 기존 포지션의 SL/TP 청산은 정상 작동 (물린 상태 방치 방지)
|
- 진입만 차단: 기존 포지션의 SL/TP 청산은 정상 작동 (물린 상태 방치 방지)
|
||||||
- 거래 이력 persist: `data/trade_history/{symbol}.jsonl`에 매 청산마다 기록
|
- 거래 이력 persist: `data/trade_history/{symbol}.jsonl`에 매 청산마다 기록
|
||||||
- 봇 재시작 시 소급 검증: 이력 파일에서 마지막 15건을 읽어 킬스위치 상태 복원
|
- 봇 재시작 시 소급 검증: 이력 파일에서 마지막 15건을 읽어 킬스위치 상태 복원
|
||||||
@@ -190,8 +186,6 @@ ML 필터는 기술 지표 신호를 한 번 더 검증하여 오진입을 차
|
|||||||
```
|
```
|
||||||
[킬스위치 모니터링]
|
[킬스위치 모니터링]
|
||||||
XRP: 연속손실 2/8 | 15거래PF 1.42
|
XRP: 연속손실 2/8 | 15거래PF 1.42
|
||||||
SOL: 연속손실 0/8 | 15거래PF -.-- (3건)
|
|
||||||
DOGE: 연속손실 6/8 ⚠ | 15거래PF 0.71 🔴 KILLED
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| 환경변수 | 설명 |
|
| 환경변수 | 설명 |
|
||||||
@@ -285,6 +279,7 @@ cointrader/
|
|||||||
│ ├── label_builder.py # 학습 레이블 생성
|
│ ├── label_builder.py # 학습 레이블 생성
|
||||||
│ ├── dataset_builder.py # 벡터화 데이터셋 빌더 (학습용)
|
│ ├── dataset_builder.py # 벡터화 데이터셋 빌더 (학습용)
|
||||||
│ ├── backtester.py # 백테스트 엔진 (단일 + Walk-Forward)
|
│ ├── backtester.py # 백테스트 엔진 (단일 + Walk-Forward)
|
||||||
|
│ ├── mtf_bot.py # MTF Pullback Bot (1h MetaFilter + 15m 3캔들 풀백 + Dry-run)
|
||||||
│ ├── risk_manager.py # 공유 리스크 관리 (asyncio.Lock, 동일 방향 제한)
|
│ ├── risk_manager.py # 공유 리스크 관리 (asyncio.Lock, 동일 방향 제한)
|
||||||
│ ├── notifier.py # Discord 웹훅 알림
|
│ ├── notifier.py # Discord 웹훅 알림
|
||||||
│ └── logger_setup.py # Loguru 로거 설정
|
│ └── logger_setup.py # Loguru 로거 설정
|
||||||
|
|||||||
@@ -52,5 +52,40 @@ services:
|
|||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
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
|
||||||
|
- ./data:/app/data
|
||||||
|
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:
|
volumes:
|
||||||
dashboard-data:
|
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` |
|
||||||
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로 정확히 분류되는지 확인
|
||||||
|
- 위 모든 항목 통과 후 실전 배포
|
||||||
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())
|
||||||
@@ -5,7 +5,7 @@ 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
|
||||||
@@ -15,3 +15,4 @@ pyarrow>=15.0.0
|
|||||||
onnxruntime>=1.18.0
|
onnxruntime>=1.18.0
|
||||||
optuna>=3.6.0
|
optuna>=3.6.0
|
||||||
quantstats>=0.0.81
|
quantstats>=0.0.81
|
||||||
|
ccxt>=4.5.0
|
||||||
|
|||||||
121
scripts/collect_ls_ratio.py
Normal file
121
scripts/collect_ls_ratio.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
Long/Short Ratio 장기 수집 스크립트.
|
||||||
|
15분마다 cron 실행하여 Binance Trading Data API에서
|
||||||
|
top_acct_ls_ratio, global_ls_ratio를 data/{symbol}/ls_ratio_15m.parquet에 누적한다.
|
||||||
|
|
||||||
|
수집 대상:
|
||||||
|
- topLongShortAccountRatio × 3심볼 (XRPUSDT, BTCUSDT, ETHUSDT)
|
||||||
|
- globalLongShortAccountRatio × 3심볼 (XRPUSDT, BTCUSDT, ETHUSDT)
|
||||||
|
→ 총 API 호출 6회/15분 (rate limit 무관)
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
python scripts/collect_ls_ratio.py
|
||||||
|
python scripts/collect_ls_ratio.py --symbols XRPUSDT BTCUSDT
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
BASE_URL = "https://fapi.binance.com"
|
||||||
|
DEFAULT_SYMBOLS = ["XRPUSDT", "BTCUSDT", "ETHUSDT"]
|
||||||
|
|
||||||
|
ENDPOINTS = {
|
||||||
|
"top_acct_ls_ratio": "/futures/data/topLongShortAccountRatio",
|
||||||
|
"global_ls_ratio": "/futures/data/globalLongShortAccountRatio",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_latest(session: aiohttp.ClientSession, symbol: str) -> dict | None:
|
||||||
|
"""심볼 하나에 대해 두 ratio의 최신 1건씩 가져온다."""
|
||||||
|
row = {"timestamp": None, "symbol": symbol}
|
||||||
|
|
||||||
|
for col_name, endpoint in ENDPOINTS.items():
|
||||||
|
url = f"{BASE_URL}{endpoint}"
|
||||||
|
params = {"symbol": symbol, "period": "15m", "limit": 1}
|
||||||
|
try:
|
||||||
|
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||||
|
data = await resp.json()
|
||||||
|
if isinstance(data, list) and data:
|
||||||
|
row[col_name] = float(data[0]["longShortRatio"])
|
||||||
|
# 타임스탬프는 첫 번째 응답에서 설정
|
||||||
|
if row["timestamp"] is None:
|
||||||
|
row["timestamp"] = pd.Timestamp(
|
||||||
|
int(data[0]["timestamp"]), unit="ms", tz="UTC"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f"[WARN] {symbol} {col_name}: unexpected response: {data}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] {symbol} {col_name}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def collect(symbols: list[str]):
|
||||||
|
"""모든 심볼 데이터를 수집하고 parquet에 추가한다."""
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
tasks = [fetch_latest(session, sym) for sym in symbols]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
collected = 0
|
||||||
|
|
||||||
|
for row in results:
|
||||||
|
if row is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
symbol = row["symbol"]
|
||||||
|
out_path = Path(f"data/{symbol.lower()}/ls_ratio_15m.parquet")
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
new_df = pd.DataFrame([{
|
||||||
|
"timestamp": row["timestamp"],
|
||||||
|
"top_acct_ls_ratio": row["top_acct_ls_ratio"],
|
||||||
|
"global_ls_ratio": row["global_ls_ratio"],
|
||||||
|
}])
|
||||||
|
|
||||||
|
if out_path.exists():
|
||||||
|
existing = pd.read_parquet(out_path)
|
||||||
|
# 중복 방지: 동일 timestamp가 이미 있으면 스킵
|
||||||
|
if row["timestamp"] in existing["timestamp"].values:
|
||||||
|
print(f"[SKIP] {symbol} ts={row['timestamp']} already exists")
|
||||||
|
continue
|
||||||
|
combined = pd.concat([existing, new_df], ignore_index=True)
|
||||||
|
else:
|
||||||
|
combined = new_df
|
||||||
|
|
||||||
|
combined.to_parquet(out_path, index=False)
|
||||||
|
collected += 1
|
||||||
|
print(
|
||||||
|
f"[{now.isoformat()}] {symbol}: "
|
||||||
|
f"top_acct={row['top_acct_ls_ratio']:.4f}, "
|
||||||
|
f"global={row['global_ls_ratio']:.4f} "
|
||||||
|
f"→ {out_path} ({len(combined)} rows)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if collected == 0:
|
||||||
|
print(f"[{now.isoformat()}] No new data collected")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="L/S Ratio 장기 수집")
|
||||||
|
parser.add_argument(
|
||||||
|
"--symbols", nargs="+", default=DEFAULT_SYMBOLS,
|
||||||
|
help="수집 대상 심볼 (기본: XRPUSDT BTCUSDT ETHUSDT)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
asyncio.run(collect(args.symbols))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
27
scripts/collect_ls_ratio_loop.sh
Executable file
27
scripts/collect_ls_ratio_loop.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# 15분 경계에 맞춰 collect_ls_ratio.py를 반복 실행한다.
|
||||||
|
# Docker 컨테이너 entrypoint용.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[collect_ls_ratio] Starting loop (interval: 15m)"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
# 현재 분/초를 기준으로 다음 15분 경계(00/15/30/45)까지 대기
|
||||||
|
now_min=$(date -u +%M | sed 's/^0//')
|
||||||
|
now_sec=$(date -u +%S | sed 's/^0//')
|
||||||
|
# 다음 15분 경계까지 남은 분
|
||||||
|
remainder=$((now_min % 15))
|
||||||
|
wait_min=$((15 - remainder))
|
||||||
|
# 초 단위로 변환 (경계 직후 10초 여유)
|
||||||
|
wait_sec=$(( wait_min * 60 - now_sec + 10 ))
|
||||||
|
if [ "$wait_sec" -le 10 ]; then
|
||||||
|
wait_sec=$((wait_sec + 900))
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[collect_ls_ratio] Next run in ${wait_sec}s ($(date -u))"
|
||||||
|
sleep "$wait_sec"
|
||||||
|
|
||||||
|
echo "[collect_ls_ratio] Running collection... ($(date -u))"
|
||||||
|
python scripts/collect_ls_ratio.py || echo "[collect_ls_ratio] ERROR: collection failed"
|
||||||
|
done
|
||||||
175
scripts/evaluate_oos.py
Normal file
175
scripts/evaluate_oos.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""
|
||||||
|
MTF Pullback Bot — OOS Dry-run 평가 스크립트
|
||||||
|
─────────────────────────────────────────────
|
||||||
|
프로덕션 서버에서 JSONL 거래 기록을 가져와
|
||||||
|
승률·PF·누적PnL·평균보유시간을 계산하고 LIVE 배포 판정을 출력한다.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/evaluate_oos.py
|
||||||
|
python scripts/evaluate_oos.py --symbol xrpusdt
|
||||||
|
python scripts/evaluate_oos.py --local # 로컬 파일만 사용 (서버 fetch 스킵)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
# ── 설정 ──────────────────────────────────────────────────────────
|
||||||
|
PROD_HOST = "root@10.1.10.24"
|
||||||
|
REMOTE_DIR = "/root/cointrader/data/trade_history"
|
||||||
|
LOCAL_DIR = Path("data/trade_history")
|
||||||
|
|
||||||
|
# ── 판정 기준 ─────────────────────────────────────────────────────
|
||||||
|
MIN_TRADES = 5
|
||||||
|
MIN_PF = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_from_prod(filename: str) -> Path:
|
||||||
|
"""프로덕션 서버에서 JSONL 파일을 scp로 가져온다."""
|
||||||
|
LOCAL_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
remote_path = f"{PROD_HOST}:{REMOTE_DIR}/{filename}"
|
||||||
|
local_path = LOCAL_DIR / filename
|
||||||
|
|
||||||
|
print(f"[Fetch] {remote_path} → {local_path}")
|
||||||
|
result = subprocess.run(
|
||||||
|
["scp", remote_path, str(local_path)],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"[Fetch] scp 실패: {result.stderr.strip()}")
|
||||||
|
if local_path.exists():
|
||||||
|
print(f"[Fetch] 로컬 캐시 사용: {local_path}")
|
||||||
|
else:
|
||||||
|
print("[Fetch] 로컬 캐시도 없음. 종료.")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print(f"[Fetch] 완료 ({local_path.stat().st_size:,} bytes)")
|
||||||
|
|
||||||
|
return local_path
|
||||||
|
|
||||||
|
|
||||||
|
def load_trades(path: Path) -> pd.DataFrame:
|
||||||
|
"""JSONL 파일을 DataFrame으로 로드."""
|
||||||
|
df = pd.read_json(path, lines=True)
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
print("[Load] 거래 기록이 비어있습니다.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
df["entry_ts"] = pd.to_datetime(df["entry_ts"], utc=True)
|
||||||
|
df["exit_ts"] = pd.to_datetime(df["exit_ts"], utc=True)
|
||||||
|
df["duration_min"] = (df["exit_ts"] - df["entry_ts"]).dt.total_seconds() / 60
|
||||||
|
|
||||||
|
print(f"[Load] {len(df)}건 로드 완료 ({df['entry_ts'].min():%Y-%m-%d} ~ {df['exit_ts'].max():%Y-%m-%d})")
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def calc_metrics(df: pd.DataFrame) -> dict:
|
||||||
|
"""핵심 지표 계산. 빈 DataFrame이면 안전한 기본값 반환."""
|
||||||
|
n = len(df)
|
||||||
|
if n == 0:
|
||||||
|
return {"trades": 0, "win_rate": 0.0, "pf": 0.0, "cum_pnl": 0.0, "avg_dur": 0.0}
|
||||||
|
|
||||||
|
wins = df[df["pnl_bps"] > 0]
|
||||||
|
losses = df[df["pnl_bps"] < 0]
|
||||||
|
|
||||||
|
win_rate = len(wins) / n * 100
|
||||||
|
gross_profit = wins["pnl_bps"].sum() if len(wins) > 0 else 0.0
|
||||||
|
gross_loss = abs(losses["pnl_bps"].sum()) if len(losses) > 0 else 0.0
|
||||||
|
pf = gross_profit / gross_loss if gross_loss > 0 else float("inf")
|
||||||
|
cum_pnl = df["pnl_bps"].sum()
|
||||||
|
avg_dur = df["duration_min"].mean()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"trades": n,
|
||||||
|
"win_rate": round(win_rate, 1),
|
||||||
|
"pf": round(pf, 2),
|
||||||
|
"cum_pnl": round(cum_pnl, 1),
|
||||||
|
"avg_dur": round(avg_dur, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def print_report(df: pd.DataFrame):
|
||||||
|
"""성적표 출력."""
|
||||||
|
total = calc_metrics(df)
|
||||||
|
longs = calc_metrics(df[df["side"] == "LONG"])
|
||||||
|
shorts = calc_metrics(df[df["side"] == "SHORT"])
|
||||||
|
|
||||||
|
header = f"{'':>10} {'Trades':>8} {'WinRate':>9} {'PF':>8} {'CumPnL':>10} {'AvgDur':>10}"
|
||||||
|
sep = "─" * 60
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(sep)
|
||||||
|
print(" MTF Pullback Bot — OOS Dry-run 성적표")
|
||||||
|
print(sep)
|
||||||
|
print(header)
|
||||||
|
print(sep)
|
||||||
|
|
||||||
|
for label, m in [("Total", total), ("LONG", longs), ("SHORT", shorts)]:
|
||||||
|
pf_str = f"{m['pf']:.2f}" if m["pf"] != float("inf") else "∞"
|
||||||
|
dur_str = f"{m['avg_dur']:.0f}m" if m["trades"] > 0 else "-"
|
||||||
|
print(
|
||||||
|
f"{label:>10} {m['trades']:>8d} {m['win_rate']:>8.1f}% {pf_str:>8} "
|
||||||
|
f"{m['cum_pnl']:>+10.1f} {dur_str:>10}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(sep)
|
||||||
|
|
||||||
|
# ── 개별 거래 내역 ──
|
||||||
|
print()
|
||||||
|
print(" 거래 내역")
|
||||||
|
print(sep)
|
||||||
|
print(f"{'#':>3} {'Side':>6} {'Entry':>10} {'Exit':>10} {'PnL(bps)':>10} {'Dur':>8} {'Reason'}")
|
||||||
|
print(sep)
|
||||||
|
for i, row in df.iterrows():
|
||||||
|
dur = f"{row['duration_min']:.0f}m"
|
||||||
|
reason = row.get("reason", "")
|
||||||
|
if len(reason) > 25:
|
||||||
|
reason = reason[:25] + "…"
|
||||||
|
print(
|
||||||
|
f"{i+1:>3} {row['side']:>6} {row['entry_price']:>10.4f} {row['exit_price']:>10.4f} "
|
||||||
|
f"{row['pnl_bps']:>+10.1f} {dur:>8} {reason}"
|
||||||
|
)
|
||||||
|
print(sep)
|
||||||
|
|
||||||
|
# ── 최종 판정 ──
|
||||||
|
print()
|
||||||
|
if total["trades"] >= MIN_TRADES and total["pf"] >= MIN_PF:
|
||||||
|
print(f" [판정: 통과] 엣지가 증명되었습니다. LIVE 배포(자금 투입)를 권장합니다.")
|
||||||
|
print(f" (거래수 {total['trades']} >= {MIN_TRADES}, PF {total['pf']:.2f} >= {MIN_PF:.1f})")
|
||||||
|
else:
|
||||||
|
reasons = []
|
||||||
|
if total["trades"] < MIN_TRADES:
|
||||||
|
reasons.append(f"거래수 {total['trades']} < {MIN_TRADES}")
|
||||||
|
if total["pf"] < MIN_PF:
|
||||||
|
reasons.append(f"PF {total['pf']:.2f} < {MIN_PF:.1f}")
|
||||||
|
print(f" [판정: 보류] 기준 미달. OOS 검증 실패로 실전 투입을 보류합니다.")
|
||||||
|
print(f" ({', '.join(reasons)})")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="MTF OOS Dry-run 평가")
|
||||||
|
parser.add_argument("--symbol", default="xrpusdt", help="심볼 (파일명 소문자, 기본: xrpusdt)")
|
||||||
|
parser.add_argument("--local", action="store_true", help="로컬 파일만 사용 (서버 fetch 스킵)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
filename = f"mtf_{args.symbol}.jsonl"
|
||||||
|
|
||||||
|
if args.local:
|
||||||
|
local_path = LOCAL_DIR / filename
|
||||||
|
if not local_path.exists():
|
||||||
|
print(f"[Error] 로컬 파일 없음: {local_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
local_path = fetch_from_prod(filename)
|
||||||
|
|
||||||
|
df = load_trades(local_path)
|
||||||
|
print_report(df)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
342
scripts/mtf_backtest.py
Normal file
342
scripts/mtf_backtest.py
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
"""
|
||||||
|
MTF Pullback Backtest
|
||||||
|
─────────────────────
|
||||||
|
Trigger: 1h 추세 방향으로 15m 눌림목 진입
|
||||||
|
LONG: 1h Meta=LONG + 15m close < EMA20 + vol < SMA20*0.5 → 다음 봉 close > EMA20 시 진입
|
||||||
|
SHORT: 1h Meta=SHORT + 15m close > EMA20 + vol < SMA20*0.5 → 다음 봉 close < EMA20 시 진입
|
||||||
|
|
||||||
|
SL/TP: 1h ATR 기반 (진입 시점 직전 완성된 1h 캔들)
|
||||||
|
Look-ahead bias 방지: 1h 지표는 직전 완성 봉만 사용
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pandas_ta as ta
|
||||||
|
import numpy as np
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
# ─── 설정 ────────────────────────────────────────────────────────
|
||||||
|
SYMBOL = "xrpusdt"
|
||||||
|
DATA_PATH = Path(f"data/{SYMBOL}/combined_15m.parquet")
|
||||||
|
START = "2026-02-01"
|
||||||
|
END = "2026-03-30"
|
||||||
|
|
||||||
|
ATR_SL_MULT = 1.5
|
||||||
|
ATR_TP_MULT = 2.3
|
||||||
|
FEE_RATE = 0.0004 # 0.04% per side
|
||||||
|
|
||||||
|
# 1h 메타필터
|
||||||
|
MTF_EMA_FAST = 50
|
||||||
|
MTF_EMA_SLOW = 200
|
||||||
|
MTF_ADX_THRESHOLD = 20
|
||||||
|
|
||||||
|
# 15m Trigger
|
||||||
|
EMA_PULLBACK_LEN = 20
|
||||||
|
VOL_DRY_RATIO = 0.5 # volume < vol_ma20 * 0.5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Trade:
|
||||||
|
entry_time: pd.Timestamp
|
||||||
|
entry_price: float
|
||||||
|
side: str
|
||||||
|
sl: float
|
||||||
|
tp: float
|
||||||
|
exit_time: pd.Timestamp | None = None
|
||||||
|
exit_price: float | None = None
|
||||||
|
pnl_pct: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def build_1h_data(df_15m: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""15m → 1h 리샘플링 + EMA50, EMA200, ADX, ATR."""
|
||||||
|
df_1h = df_15m[["open", "high", "low", "close", "volume"]].resample("1h").agg(
|
||||||
|
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
|
||||||
|
).dropna()
|
||||||
|
|
||||||
|
df_1h["ema50_1h"] = ta.ema(df_1h["close"], length=MTF_EMA_FAST)
|
||||||
|
df_1h["ema200_1h"] = ta.ema(df_1h["close"], length=MTF_EMA_SLOW)
|
||||||
|
adx_df = ta.adx(df_1h["high"], df_1h["low"], df_1h["close"], length=14)
|
||||||
|
df_1h["adx_1h"] = adx_df["ADX_14"]
|
||||||
|
df_1h["atr_1h"] = ta.atr(df_1h["high"], df_1h["low"], df_1h["close"], length=14)
|
||||||
|
|
||||||
|
return df_1h[["ema50_1h", "ema200_1h", "adx_1h", "atr_1h"]]
|
||||||
|
|
||||||
|
|
||||||
|
def merge_1h_to_15m(df_15m: pd.DataFrame, df_1h: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""Look-ahead bias 방지: 1h 봉 완성 시점(+1h) 기준 backward merge."""
|
||||||
|
df_1h_shifted = df_1h.copy()
|
||||||
|
df_1h_shifted.index = df_1h_shifted.index + pd.Timedelta(hours=1)
|
||||||
|
|
||||||
|
df_15m_reset = df_15m.reset_index()
|
||||||
|
df_1h_reset = df_1h_shifted.reset_index()
|
||||||
|
df_1h_reset.rename(columns={"index": "timestamp"}, inplace=True)
|
||||||
|
if "timestamp" not in df_15m_reset.columns:
|
||||||
|
df_15m_reset.rename(columns={df_15m_reset.columns[0]: "timestamp"}, inplace=True)
|
||||||
|
|
||||||
|
df_15m_reset["timestamp"] = pd.to_datetime(df_15m_reset["timestamp"]).astype("datetime64[us]")
|
||||||
|
df_1h_reset["timestamp"] = pd.to_datetime(df_1h_reset["timestamp"]).astype("datetime64[us]")
|
||||||
|
|
||||||
|
merged = pd.merge_asof(
|
||||||
|
df_15m_reset.sort_values("timestamp"),
|
||||||
|
df_1h_reset.sort_values("timestamp"),
|
||||||
|
on="timestamp",
|
||||||
|
direction="backward",
|
||||||
|
)
|
||||||
|
return merged.set_index("timestamp")
|
||||||
|
|
||||||
|
|
||||||
|
def get_1h_meta(row) -> str:
|
||||||
|
"""1h 메타필터: EMA50/200 방향 + ADX > 20."""
|
||||||
|
ema50 = row.get("ema50_1h")
|
||||||
|
ema200 = row.get("ema200_1h")
|
||||||
|
adx = row.get("adx_1h")
|
||||||
|
|
||||||
|
if pd.isna(ema50) or pd.isna(ema200) or pd.isna(adx):
|
||||||
|
return "HOLD"
|
||||||
|
if adx < MTF_ADX_THRESHOLD:
|
||||||
|
return "HOLD"
|
||||||
|
if ema50 > ema200:
|
||||||
|
return "LONG"
|
||||||
|
elif ema50 < ema200:
|
||||||
|
return "SHORT"
|
||||||
|
return "HOLD"
|
||||||
|
|
||||||
|
|
||||||
|
def calc_metrics(trades: list[Trade]) -> dict:
|
||||||
|
if not trades:
|
||||||
|
return {"trades": 0, "win_rate": 0, "pf": 0, "pnl_bps": 0, "max_dd_bps": 0,
|
||||||
|
"avg_win_bps": 0, "avg_loss_bps": 0, "long_trades": 0, "short_trades": 0}
|
||||||
|
|
||||||
|
pnls = [t.pnl_pct for t in trades]
|
||||||
|
wins = [p for p in pnls if p > 0]
|
||||||
|
losses = [p for p in pnls if p <= 0]
|
||||||
|
|
||||||
|
gross_profit = sum(wins) if wins else 0
|
||||||
|
gross_loss = abs(sum(losses)) if losses else 0
|
||||||
|
pf = gross_profit / gross_loss if gross_loss > 0 else float("inf")
|
||||||
|
|
||||||
|
cumulative = np.cumsum(pnls)
|
||||||
|
peak = np.maximum.accumulate(cumulative)
|
||||||
|
dd = cumulative - peak
|
||||||
|
max_dd = abs(dd.min()) if len(dd) > 0 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"trades": len(trades),
|
||||||
|
"win_rate": len(wins) / len(trades) * 100,
|
||||||
|
"pf": round(pf, 2),
|
||||||
|
"pnl_bps": round(sum(pnls) * 10000, 1),
|
||||||
|
"max_dd_bps": round(max_dd * 10000, 1),
|
||||||
|
"avg_win_bps": round(np.mean(wins) * 10000, 1) if wins else 0,
|
||||||
|
"avg_loss_bps": round(np.mean(losses) * 10000, 1) if losses else 0,
|
||||||
|
"long_trades": sum(1 for t in trades if t.side == "LONG"),
|
||||||
|
"short_trades": sum(1 for t in trades if t.side == "SHORT"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 70)
|
||||||
|
print(" MTF Pullback Backtest")
|
||||||
|
print(f" {SYMBOL.upper()} | {START} ~ {END}")
|
||||||
|
print(f" SL: 1h ATR×{ATR_SL_MULT} | TP: 1h ATR×{ATR_TP_MULT} | Fee: {FEE_RATE*100:.2f}%/side")
|
||||||
|
print(f" Pullback: EMA{EMA_PULLBACK_LEN} | Vol dry: <{VOL_DRY_RATIO*100:.0f}% of SMA20")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# ── 데이터 로드 ──
|
||||||
|
df_raw = pd.read_parquet(DATA_PATH)
|
||||||
|
if df_raw.index.tz is not None:
|
||||||
|
df_raw.index = df_raw.index.tz_localize(None)
|
||||||
|
|
||||||
|
# 1h EMA200 워밍업 (200h = 800 bars)
|
||||||
|
warmup_start = pd.Timestamp(START) - pd.Timedelta(hours=250)
|
||||||
|
df_full = df_raw[df_raw.index >= warmup_start].copy()
|
||||||
|
print(f"\n데이터: {len(df_full)} bars (워밍업 포함)")
|
||||||
|
|
||||||
|
# ── 15m 지표: EMA20, vol_ma20 ──
|
||||||
|
df_full["ema20"] = ta.ema(df_full["close"], length=EMA_PULLBACK_LEN)
|
||||||
|
df_full["vol_ma20"] = ta.sma(df_full["volume"], length=20)
|
||||||
|
|
||||||
|
# ── 1h 지표 ──
|
||||||
|
df_1h = build_1h_data(df_full)
|
||||||
|
print(f"1h 캔들: {len(df_1h)} bars")
|
||||||
|
|
||||||
|
# ── 병합 ──
|
||||||
|
df_merged = merge_1h_to_15m(df_full, df_1h)
|
||||||
|
|
||||||
|
# ── 분석 기간 ──
|
||||||
|
df = df_merged[(df_merged.index >= START) & (df_merged.index <= END)].copy()
|
||||||
|
print(f"분석 기간: {len(df)} bars ({df.index.min()} ~ {df.index.max()})")
|
||||||
|
|
||||||
|
# ── 신호 스캔 & 시뮬레이션 ──
|
||||||
|
trades: list[Trade] = []
|
||||||
|
in_trade = False
|
||||||
|
current_trade: Trade | None = None
|
||||||
|
pullback_ready = False # 눌림 감지 상태
|
||||||
|
pullback_side = ""
|
||||||
|
|
||||||
|
# 디버그 카운터
|
||||||
|
meta_long_count = 0
|
||||||
|
meta_short_count = 0
|
||||||
|
pullback_detected = 0
|
||||||
|
entry_triggered = 0
|
||||||
|
|
||||||
|
for i in range(1, len(df)):
|
||||||
|
row = df.iloc[i]
|
||||||
|
prev = df.iloc[i - 1]
|
||||||
|
|
||||||
|
# ── 기존 포지션 SL/TP 체크 ──
|
||||||
|
if in_trade and current_trade is not None:
|
||||||
|
hit_sl = False
|
||||||
|
hit_tp = False
|
||||||
|
|
||||||
|
if current_trade.side == "LONG":
|
||||||
|
if row["low"] <= current_trade.sl:
|
||||||
|
hit_sl = True
|
||||||
|
if row["high"] >= current_trade.tp:
|
||||||
|
hit_tp = True
|
||||||
|
else:
|
||||||
|
if row["high"] >= current_trade.sl:
|
||||||
|
hit_sl = True
|
||||||
|
if row["low"] <= current_trade.tp:
|
||||||
|
hit_tp = True
|
||||||
|
|
||||||
|
if hit_sl or hit_tp:
|
||||||
|
exit_price = current_trade.sl if hit_sl else current_trade.tp
|
||||||
|
if hit_sl and hit_tp:
|
||||||
|
exit_price = current_trade.sl # 보수적
|
||||||
|
|
||||||
|
if current_trade.side == "LONG":
|
||||||
|
raw_pnl = (exit_price - current_trade.entry_price) / current_trade.entry_price
|
||||||
|
else:
|
||||||
|
raw_pnl = (current_trade.entry_price - exit_price) / current_trade.entry_price
|
||||||
|
|
||||||
|
current_trade.exit_time = df.index[i]
|
||||||
|
current_trade.exit_price = exit_price
|
||||||
|
current_trade.pnl_pct = raw_pnl - FEE_RATE * 2
|
||||||
|
trades.append(current_trade)
|
||||||
|
in_trade = False
|
||||||
|
current_trade = None
|
||||||
|
|
||||||
|
# ── 포지션 중이면 새 진입 스킵 ──
|
||||||
|
if in_trade:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# NaN 체크
|
||||||
|
if pd.isna(row.get("ema20")) or pd.isna(row.get("vol_ma20")) or pd.isna(row.get("atr_1h")):
|
||||||
|
pullback_ready = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── Step 1: 1h Meta Filter ──
|
||||||
|
meta = get_1h_meta(row)
|
||||||
|
if meta == "LONG":
|
||||||
|
meta_long_count += 1
|
||||||
|
elif meta == "SHORT":
|
||||||
|
meta_short_count += 1
|
||||||
|
|
||||||
|
if meta == "HOLD":
|
||||||
|
pullback_ready = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── Step 2: 눌림(Pullback) 감지 ──
|
||||||
|
# 이전 봉이 눌림 조건을 충족했는지 확인
|
||||||
|
if pullback_ready and pullback_side == meta:
|
||||||
|
# ── Step 4: 추세 재개 확인 (현재 봉 close 기준) ──
|
||||||
|
if pullback_side == "LONG" and row["close"] > row["ema20"]:
|
||||||
|
# 진입: 이 봉의 open (추세 재개 확인된 봉)
|
||||||
|
# 실제로는 close 시점에 확인하므로 다음 봉 open에 진입해야 look-ahead 방지
|
||||||
|
# 하지만 사양서에 "직후 캔들의 종가가 EMA20 상향 돌파한 첫 번째 캔들의 시가"라고 되어 있으므로
|
||||||
|
# → 이 봉(close > EMA20)의 open에서 진입은 look-ahead bias
|
||||||
|
# → 정확히는: prev가 pullback, 현재 봉 close > EMA20 확인 → 다음 봉 open 진입
|
||||||
|
# 여기서는 다음 봉 open으로 처리
|
||||||
|
if i + 1 < len(df):
|
||||||
|
next_row = df.iloc[i + 1]
|
||||||
|
entry_price = next_row["open"]
|
||||||
|
atr_1h = row["atr_1h"]
|
||||||
|
|
||||||
|
sl = entry_price - atr_1h * ATR_SL_MULT
|
||||||
|
tp = entry_price + atr_1h * ATR_TP_MULT
|
||||||
|
|
||||||
|
current_trade = Trade(
|
||||||
|
entry_time=df.index[i + 1],
|
||||||
|
entry_price=entry_price,
|
||||||
|
side="LONG",
|
||||||
|
sl=sl, tp=tp,
|
||||||
|
)
|
||||||
|
in_trade = True
|
||||||
|
pullback_ready = False
|
||||||
|
entry_triggered += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif pullback_side == "SHORT" and row["close"] < row["ema20"]:
|
||||||
|
if i + 1 < len(df):
|
||||||
|
next_row = df.iloc[i + 1]
|
||||||
|
entry_price = next_row["open"]
|
||||||
|
atr_1h = row["atr_1h"]
|
||||||
|
|
||||||
|
sl = entry_price + atr_1h * ATR_SL_MULT
|
||||||
|
tp = entry_price - atr_1h * ATR_TP_MULT
|
||||||
|
|
||||||
|
current_trade = Trade(
|
||||||
|
entry_time=df.index[i + 1],
|
||||||
|
entry_price=entry_price,
|
||||||
|
side="SHORT",
|
||||||
|
sl=sl, tp=tp,
|
||||||
|
)
|
||||||
|
in_trade = True
|
||||||
|
pullback_ready = False
|
||||||
|
entry_triggered += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── Step 2+3: 눌림 + 거래량 고갈 감지 (다음 봉에서 재개 확인) ──
|
||||||
|
vol_dry = row["volume"] < row["vol_ma20"] * VOL_DRY_RATIO
|
||||||
|
|
||||||
|
if meta == "LONG" and row["close"] < row["ema20"] and vol_dry:
|
||||||
|
pullback_ready = True
|
||||||
|
pullback_side = "LONG"
|
||||||
|
pullback_detected += 1
|
||||||
|
elif meta == "SHORT" and row["close"] > row["ema20"] and vol_dry:
|
||||||
|
pullback_ready = True
|
||||||
|
pullback_side = "SHORT"
|
||||||
|
pullback_detected += 1
|
||||||
|
else:
|
||||||
|
# 조건 불충족 시 pullback 상태 리셋
|
||||||
|
# 단, 연속 pullback 허용 (여러 봉 동안 눌림 지속 가능)
|
||||||
|
if not (meta == pullback_side):
|
||||||
|
pullback_ready = False
|
||||||
|
|
||||||
|
# ── 결과 출력 ──
|
||||||
|
m = calc_metrics(trades)
|
||||||
|
long_trades = [t for t in trades if t.side == "LONG"]
|
||||||
|
short_trades = [t for t in trades if t.side == "SHORT"]
|
||||||
|
lm = calc_metrics(long_trades)
|
||||||
|
sm = calc_metrics(short_trades)
|
||||||
|
|
||||||
|
print(f"\n─── 신호 파이프라인 ───")
|
||||||
|
print(f"1h Meta LONG: {meta_long_count} bars | SHORT: {meta_short_count} bars")
|
||||||
|
print(f"Pullback 감지: {pullback_detected}건")
|
||||||
|
print(f"진입 트리거: {entry_triggered}건")
|
||||||
|
print(f"실제 거래: {m['trades']}건 (L:{m['long_trades']} / S:{m['short_trades']})")
|
||||||
|
|
||||||
|
print(f"\n{'=' * 70}")
|
||||||
|
print(f" 결과")
|
||||||
|
print(f"{'=' * 70}")
|
||||||
|
|
||||||
|
header = f"{'구분':<10} {'Trades':>7} {'WinRate':>8} {'PF':>6} {'PnL(bps)':>10} {'MaxDD(bps)':>11} {'AvgWin':>8} {'AvgLoss':>8}"
|
||||||
|
print(header)
|
||||||
|
print("-" * len(header))
|
||||||
|
print(f"{'전체':<10} {m['trades']:>7} {m['win_rate']:>7.1f}% {m['pf']:>6.2f} {m['pnl_bps']:>10.1f} {m['max_dd_bps']:>11.1f} {m['avg_win_bps']:>8.1f} {m['avg_loss_bps']:>8.1f}")
|
||||||
|
print(f"{'LONG':<10} {lm['trades']:>7} {lm['win_rate']:>7.1f}% {lm['pf']:>6.2f} {lm['pnl_bps']:>10.1f} {lm['max_dd_bps']:>11.1f} {lm['avg_win_bps']:>8.1f} {lm['avg_loss_bps']:>8.1f}")
|
||||||
|
print(f"{'SHORT':<10} {sm['trades']:>7} {sm['win_rate']:>7.1f}% {sm['pf']:>6.2f} {sm['pnl_bps']:>10.1f} {sm['max_dd_bps']:>11.1f} {sm['avg_win_bps']:>8.1f} {sm['avg_loss_bps']:>8.1f}")
|
||||||
|
|
||||||
|
# 개별 거래 목록
|
||||||
|
if trades:
|
||||||
|
print(f"\n─── 개별 거래 ───")
|
||||||
|
print(f"{'#':>3} {'Side':<6} {'Entry Time':<20} {'Entry':>10} {'Exit':>10} {'PnL(bps)':>10} {'Result':>8}")
|
||||||
|
print("-" * 75)
|
||||||
|
for idx, t in enumerate(trades, 1):
|
||||||
|
result = "WIN" if t.pnl_pct > 0 else "LOSS"
|
||||||
|
pnl_bps = t.pnl_pct * 10000
|
||||||
|
print(f"{idx:>3} {t.side:<6} {str(t.entry_time):<20} {t.entry_price:>10.4f} {t.exit_price:>10.4f} {pnl_bps:>+10.1f} {result:>8}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -20,6 +20,8 @@ import json
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pandas_ta as ta
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -50,6 +52,8 @@ def parse_args():
|
|||||||
|
|
||||||
# Walk-Forward
|
# Walk-Forward
|
||||||
p.add_argument("--walk-forward", action="store_true", help="Walk-Forward 백테스트 (기간별 모델 학습/검증)")
|
p.add_argument("--walk-forward", action="store_true", help="Walk-Forward 백테스트 (기간별 모델 학습/검증)")
|
||||||
|
p.add_argument("--compare-ml", action="store_true",
|
||||||
|
help="ML on vs off Walk-Forward 비교 (--walk-forward 자동 활성화)")
|
||||||
p.add_argument("--train-months", type=int, default=6, help="WF 학습 윈도우 개월 (기본: 6)")
|
p.add_argument("--train-months", type=int, default=6, help="WF 학습 윈도우 개월 (기본: 6)")
|
||||||
p.add_argument("--test-months", type=int, default=1, help="WF 검증 윈도우 개월 (기본: 1)")
|
p.add_argument("--test-months", type=int, default=1, help="WF 검증 윈도우 개월 (기본: 1)")
|
||||||
return p.parse_args()
|
return p.parse_args()
|
||||||
@@ -105,6 +109,174 @@ def print_fold_table(folds: list[dict]):
|
|||||||
print("=" * 90)
|
print("=" * 90)
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_regime(btc_return: float, btc_avg_adx: float) -> str:
|
||||||
|
"""BTC ADX와 수익률 기반 시장 레짐 분류."""
|
||||||
|
if btc_avg_adx >= 25:
|
||||||
|
return "상승 추세" if btc_return > 0 else "하락 추세"
|
||||||
|
return "횡보"
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_fold_market_context(
|
||||||
|
raw_df: pd.DataFrame, test_start: str, test_end: str
|
||||||
|
) -> dict:
|
||||||
|
"""폴드 기간의 BTC/ETH 수익률과 시장 레짐 계산."""
|
||||||
|
ts_start = pd.Timestamp(test_start)
|
||||||
|
ts_end = pd.Timestamp(test_end)
|
||||||
|
|
||||||
|
idx = raw_df.index
|
||||||
|
if idx.tz is not None:
|
||||||
|
idx = idx.tz_localize(None)
|
||||||
|
if ts_start.tz is not None:
|
||||||
|
ts_start = ts_start.tz_localize(None)
|
||||||
|
if ts_end.tz is not None:
|
||||||
|
ts_end = ts_end.tz_localize(None)
|
||||||
|
|
||||||
|
fold_df = raw_df[(idx >= ts_start) & (idx < ts_end)]
|
||||||
|
if len(fold_df) < 20:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# BTC return
|
||||||
|
btc_start = fold_df["close_btc"].iloc[0]
|
||||||
|
btc_end = fold_df["close_btc"].iloc[-1]
|
||||||
|
btc_return = (btc_end - btc_start) / btc_start * 100
|
||||||
|
|
||||||
|
# ETH return
|
||||||
|
eth_start = fold_df["close_eth"].iloc[0]
|
||||||
|
eth_end = fold_df["close_eth"].iloc[-1]
|
||||||
|
eth_return = (eth_end - eth_start) / eth_start * 100
|
||||||
|
|
||||||
|
# BTC ADX (period average)
|
||||||
|
adx_df = ta.adx(fold_df["high_btc"], fold_df["low_btc"], fold_df["close_btc"], length=14)
|
||||||
|
btc_avg_adx = adx_df["ADX_14"].mean()
|
||||||
|
if np.isnan(btc_avg_adx):
|
||||||
|
btc_avg_adx = 0.0
|
||||||
|
|
||||||
|
regime = _classify_regime(btc_return, btc_avg_adx)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"btc_return_pct": round(btc_return, 1),
|
||||||
|
"eth_return_pct": round(eth_return, 1),
|
||||||
|
"btc_avg_adx": round(btc_avg_adx, 1),
|
||||||
|
"market_regime": regime,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_ls_ratio(symbol: str, test_start: str, test_end: str) -> dict | None:
|
||||||
|
"""폴드 기간의 L/S ratio 평균값 로드. 데이터 없으면 None."""
|
||||||
|
path = Path(f"data/{symbol.lower()}/ls_ratio_15m.parquet")
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
df = pd.read_parquet(path)
|
||||||
|
ts_start = pd.Timestamp(test_start)
|
||||||
|
ts_end = pd.Timestamp(test_end)
|
||||||
|
|
||||||
|
# tz 맞추기
|
||||||
|
if df["timestamp"].dt.tz is not None:
|
||||||
|
if ts_start.tz is None:
|
||||||
|
ts_start = ts_start.tz_localize("UTC")
|
||||||
|
if ts_end.tz is None:
|
||||||
|
ts_end = ts_end.tz_localize("UTC")
|
||||||
|
|
||||||
|
mask = (df["timestamp"] >= ts_start) & (df["timestamp"] < ts_end)
|
||||||
|
period_df = df[mask]
|
||||||
|
|
||||||
|
if period_df.empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"top_acct_avg": round(period_df["top_acct_ls_ratio"].mean(), 2),
|
||||||
|
"global_avg": round(period_df["global_ls_ratio"].mean(), 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def calc_market_context(folds: list[dict], symbols: list[str]) -> list[dict]:
|
||||||
|
"""각 폴드에 대한 시장 컨텍스트 계산."""
|
||||||
|
# XRP parquet에서 BTC/ETH 데이터 로드 (임베딩됨)
|
||||||
|
primary_sym = symbols[0].lower()
|
||||||
|
raw_path = Path(f"data/{primary_sym}/combined_15m.parquet")
|
||||||
|
if not raw_path.exists():
|
||||||
|
logger.warning(f"데이터 파일 없음: {raw_path}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
raw_df = pd.read_parquet(raw_path)
|
||||||
|
if "close_btc" not in raw_df.columns or "close_eth" not in raw_df.columns:
|
||||||
|
logger.warning("BTC/ETH 상관 데이터 없음")
|
||||||
|
return []
|
||||||
|
|
||||||
|
contexts = []
|
||||||
|
for fold in folds:
|
||||||
|
test_start = fold.get("test_start")
|
||||||
|
test_end = fold.get("test_end")
|
||||||
|
if not test_start or not test_end:
|
||||||
|
contexts.append({"fold": fold["fold"], "market_context": None})
|
||||||
|
continue
|
||||||
|
|
||||||
|
ctx = _calc_fold_market_context(raw_df, test_start, test_end)
|
||||||
|
if ctx is None:
|
||||||
|
contexts.append({"fold": fold["fold"], "market_context": None})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# L/S ratio (XRP, BTC, ETH)
|
||||||
|
ls_data = {}
|
||||||
|
for ls_sym in ["xrpusdt", "btcusdt", "ethusdt"]:
|
||||||
|
ls = _load_ls_ratio(ls_sym, test_start, test_end)
|
||||||
|
if ls:
|
||||||
|
ls_data[ls_sym.replace("usdt", "")] = ls
|
||||||
|
|
||||||
|
ctx["ls_ratio"] = ls_data if ls_data else None
|
||||||
|
contexts.append({"fold": fold["fold"], "market_context": ctx})
|
||||||
|
|
||||||
|
return contexts
|
||||||
|
|
||||||
|
|
||||||
|
def print_market_context(contexts: list[dict]):
|
||||||
|
"""시장 컨텍스트 테이블 출력."""
|
||||||
|
if not contexts:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Market Regime 테이블
|
||||||
|
print("\n📊 Market Context per Fold")
|
||||||
|
print(f"{'─' * 80}")
|
||||||
|
print(f" {'Fold':>4} {'BTC Return':>12} {'ETH Return':>12} {'Market Regime':<32}")
|
||||||
|
print(f"{'─' * 80}")
|
||||||
|
|
||||||
|
for c in contexts:
|
||||||
|
ctx = c.get("market_context")
|
||||||
|
if ctx is None:
|
||||||
|
print(f" {c['fold']:>4} {'N/A':>12} {'N/A':>12} {'N/A':<32}")
|
||||||
|
else:
|
||||||
|
regime_str = f"{ctx['market_regime']} (BTC ADX {ctx['btc_avg_adx']:.0f})"
|
||||||
|
print(f" {c['fold']:>4} {ctx['btc_return_pct']:>+11.1f}% "
|
||||||
|
f"{ctx['eth_return_pct']:>+11.1f}% {regime_str:<32}")
|
||||||
|
print(f"{'─' * 80}")
|
||||||
|
|
||||||
|
# L/S Ratio 테이블 (데이터 있는 폴드가 하나라도 있으면)
|
||||||
|
has_ls = any(
|
||||||
|
c.get("market_context") and c["market_context"].get("ls_ratio")
|
||||||
|
for c in contexts
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_ls:
|
||||||
|
print("\n📊 L/S Ratio Context per Fold (period avg)")
|
||||||
|
print(f"{'─' * 80}")
|
||||||
|
print(f" {'Fold':>4} {'XRP Top/Global':>18} {'BTC Top/Global':>18} {'ETH Top/Global':>18}")
|
||||||
|
print(f"{'─' * 80}")
|
||||||
|
for c in contexts:
|
||||||
|
ctx = c.get("market_context")
|
||||||
|
ls = ctx.get("ls_ratio") if ctx else None
|
||||||
|
parts = []
|
||||||
|
for sym in ["xrp", "btc", "eth"]:
|
||||||
|
if ls and sym in ls:
|
||||||
|
parts.append(f"{ls[sym]['top_acct_avg']:.2f} / {ls[sym]['global_avg']:.2f}")
|
||||||
|
else:
|
||||||
|
parts.append("N/A")
|
||||||
|
print(f" {c['fold']:>4} {parts[0]:>18} {parts[1]:>18} {parts[2]:>18}")
|
||||||
|
print(f"{'─' * 80}")
|
||||||
|
else:
|
||||||
|
print(" ℹ️ L/S ratio 데이터 없음 — collector 데이터 축적 후 표시됩니다")
|
||||||
|
|
||||||
|
|
||||||
def save_result(result: dict, cfg):
|
def save_result(result: dict, cfg):
|
||||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
mode = result.get("mode", "standard")
|
mode = result.get("mode", "standard")
|
||||||
@@ -148,6 +320,164 @@ def save_result(result: dict, cfg):
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def compare_ml(symbols: list[str], args):
|
||||||
|
"""ML on vs ML off Walk-Forward 백테스트 비교."""
|
||||||
|
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"])
|
||||||
|
# 시장 컨텍스트는 첫 번째 실행에서만 출력 (동일 데이터)
|
||||||
|
if label == "ML OFF":
|
||||||
|
contexts = calc_market_context(result["folds"], symbols)
|
||||||
|
if contexts:
|
||||||
|
print_market_context(contexts)
|
||||||
|
|
||||||
|
_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]
|
||||||
|
if isinstance(obj, (np.integer,)):
|
||||||
|
return int(obj)
|
||||||
|
if isinstance(obj, (np.floating,)):
|
||||||
|
return float(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)
|
||||||
|
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
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
||||||
@@ -156,6 +486,12 @@ def main():
|
|||||||
else:
|
else:
|
||||||
symbols = [s.strip().upper() for s in args.symbols.split(",") if s.strip()]
|
symbols = [s.strip().upper() for s in args.symbols.split(",") if s.strip()]
|
||||||
|
|
||||||
|
if args.compare_ml:
|
||||||
|
if args.no_ml:
|
||||||
|
logger.warning("--no-ml is ignored when using --compare-ml")
|
||||||
|
compare_ml(symbols, args)
|
||||||
|
return
|
||||||
|
|
||||||
if args.walk_forward:
|
if args.walk_forward:
|
||||||
cfg = WalkForwardConfig(
|
cfg = WalkForwardConfig(
|
||||||
symbols=symbols,
|
symbols=symbols,
|
||||||
@@ -182,6 +518,12 @@ def main():
|
|||||||
print_summary(result["summary"], cfg, mode="walk_forward")
|
print_summary(result["summary"], cfg, mode="walk_forward")
|
||||||
if result.get("folds"):
|
if result.get("folds"):
|
||||||
print_fold_table(result["folds"])
|
print_fold_table(result["folds"])
|
||||||
|
contexts = calc_market_context(result["folds"], symbols)
|
||||||
|
if contexts:
|
||||||
|
print_market_context(contexts)
|
||||||
|
# JSON에 market_context 추가
|
||||||
|
for fold, ctx in zip(result["folds"], contexts):
|
||||||
|
fold["market_context"] = ctx.get("market_context")
|
||||||
save_result(result, cfg)
|
save_result(result, cfg)
|
||||||
else:
|
else:
|
||||||
cfg = BacktestConfig(
|
cfg = BacktestConfig(
|
||||||
|
|||||||
256
scripts/taker_ratio_analysis.py
Normal file
256
scripts/taker_ratio_analysis.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
"""
|
||||||
|
Taker Buy/Sell Ratio vs Next-Candle Price Change Correlation Analysis
|
||||||
|
- Taker Buy Ratio (from klines + Trading Data API)
|
||||||
|
- Long/Short Ratio (global)
|
||||||
|
- Top Trader Long/Short Ratio (accounts & positions)
|
||||||
|
|
||||||
|
Usage: python scripts/taker_ratio_analysis.py [SYMBOL1] [SYMBOL2] ...
|
||||||
|
Default: XRPUSDT BTCUSDT ETHUSDT
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import sys
|
||||||
|
|
||||||
|
BASE = "https://fapi.binance.com"
|
||||||
|
SYMBOLS = sys.argv[1:] if len(sys.argv) > 1 else ["XRPUSDT", "BTCUSDT", "ETHUSDT"]
|
||||||
|
INTERVAL = "15m"
|
||||||
|
DAYS = 30
|
||||||
|
|
||||||
|
async def fetch_json(session, url, params):
|
||||||
|
async with session.get(url, params=params) as resp:
|
||||||
|
return await resp.json()
|
||||||
|
|
||||||
|
async def fetch_klines(session, symbol, start_ms, end_ms):
|
||||||
|
all_klines = []
|
||||||
|
current = start_ms
|
||||||
|
while current < end_ms:
|
||||||
|
params = {"symbol": symbol, "interval": INTERVAL, "startTime": current, "endTime": end_ms, "limit": 1500}
|
||||||
|
data = await fetch_json(session, f"{BASE}/fapi/v1/klines", params)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
all_klines.extend(data)
|
||||||
|
current = data[-1][0] + 1
|
||||||
|
return all_klines
|
||||||
|
|
||||||
|
async def fetch_ratio(session, url, symbol):
|
||||||
|
params = {"symbol": symbol, "period": INTERVAL, "limit": 500}
|
||||||
|
data = await fetch_json(session, url, params)
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
|
||||||
|
async def analyze_symbol(session, symbol, start_ms, end_ms):
|
||||||
|
"""Fetch and analyze a single symbol"""
|
||||||
|
klines, ls_ratio, top_acct, top_pos, taker = await asyncio.gather(
|
||||||
|
fetch_klines(session, symbol, start_ms, end_ms),
|
||||||
|
fetch_ratio(session, f"{BASE}/futures/data/globalLongShortAccountRatio", symbol),
|
||||||
|
fetch_ratio(session, f"{BASE}/futures/data/topLongShortAccountRatio", symbol),
|
||||||
|
fetch_ratio(session, f"{BASE}/futures/data/topLongShortPositionRatio", symbol),
|
||||||
|
fetch_ratio(session, f"{BASE}/futures/data/takerlongshortRatio", symbol),
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n {symbol}: Klines={len(klines)}, L/S={len(ls_ratio)}, TopAcct={len(top_acct)}, TopPos={len(top_pos)}, Taker={len(taker)}")
|
||||||
|
|
||||||
|
# Build DataFrame
|
||||||
|
df_k = pd.DataFrame(klines, columns=[
|
||||||
|
"open_time","open","high","low","close","volume",
|
||||||
|
"close_time","quote_vol","trades","taker_buy_vol","taker_buy_quote_vol","ignore"
|
||||||
|
])
|
||||||
|
df_k["open_time"] = pd.to_datetime(df_k["open_time"], unit="ms")
|
||||||
|
for c in ["open","high","low","close","volume","taker_buy_vol","taker_buy_quote_vol","quote_vol"]:
|
||||||
|
df_k[c] = df_k[c].astype(float)
|
||||||
|
|
||||||
|
df_k["kline_taker_buy_ratio"] = (df_k["taker_buy_vol"] / df_k["volume"]).replace([np.inf, -np.inf], np.nan)
|
||||||
|
df_k["next_return"] = df_k["close"].shift(-1) / df_k["close"] - 1
|
||||||
|
df_k["next_4_return"] = df_k["close"].shift(-4) / df_k["close"] - 1
|
||||||
|
df_k = df_k.set_index("open_time")
|
||||||
|
|
||||||
|
def join_ratio(data, col_name):
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
df = pd.DataFrame(data)
|
||||||
|
df["timestamp"] = pd.to_datetime(df["timestamp"].astype(int), unit="ms")
|
||||||
|
if "buySellRatio" in df.columns:
|
||||||
|
df["buySellRatio"] = df["buySellRatio"].astype(float)
|
||||||
|
df["buyVol"] = df["buyVol"].astype(float)
|
||||||
|
df["sellVol"] = df["sellVol"].astype(float)
|
||||||
|
df = df.set_index("timestamp")
|
||||||
|
df_k.update(df_k.join(df[["buySellRatio","buyVol","sellVol"]], how="left"))
|
||||||
|
for c in ["buySellRatio","buyVol","sellVol"]:
|
||||||
|
if c not in df_k.columns:
|
||||||
|
df_k[c] = np.nan
|
||||||
|
joined = df_k.join(df[["buySellRatio","buyVol","sellVol"]], how="left", rsuffix="_new")
|
||||||
|
for c in ["buySellRatio","buyVol","sellVol"]:
|
||||||
|
if f"{c}_new" in joined.columns:
|
||||||
|
df_k[c] = joined[f"{c}_new"]
|
||||||
|
else:
|
||||||
|
df["longShortRatio"] = df["longShortRatio"].astype(float)
|
||||||
|
df = df.set_index("timestamp").rename(columns={"longShortRatio": col_name})
|
||||||
|
df_k[col_name] = df_k.join(df[[col_name]], how="left")[col_name]
|
||||||
|
|
||||||
|
join_ratio(taker, "buySellRatio")
|
||||||
|
join_ratio(ls_ratio, "global_ls_ratio")
|
||||||
|
join_ratio(top_acct, "top_acct_ls_ratio")
|
||||||
|
join_ratio(top_pos, "top_pos_ls_ratio")
|
||||||
|
|
||||||
|
return df_k
|
||||||
|
|
||||||
|
def print_analysis(symbol, df_k):
|
||||||
|
"""Print analysis results for a symbol"""
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print(f"{symbol} {INTERVAL} Taker/Ratio → Price Correlation Analysis ({DAYS} days klines, ~5 days ratios)")
|
||||||
|
print("="*70)
|
||||||
|
|
||||||
|
features = ["kline_taker_buy_ratio", "buySellRatio", "global_ls_ratio",
|
||||||
|
"top_acct_ls_ratio", "top_pos_ls_ratio"]
|
||||||
|
available = [f for f in features if f in df_k.columns and df_k[f].notna().sum() > 20]
|
||||||
|
|
||||||
|
# 1. Correlation
|
||||||
|
print("\n[1] Pearson Correlation with Next-Candle Returns")
|
||||||
|
print("-"*55)
|
||||||
|
print(f"{'Feature':<25} {'next_15m':>12} {'next_1h':>12}")
|
||||||
|
print("-"*55)
|
||||||
|
for feat in available:
|
||||||
|
c1 = df_k[feat].corr(df_k["next_return"])
|
||||||
|
c4 = df_k[feat].corr(df_k["next_4_return"])
|
||||||
|
print(f"{feat:<25} {c1:>12.4f} {c4:>12.4f}")
|
||||||
|
|
||||||
|
# 2. Quintile - Taker
|
||||||
|
print("\n[2] Taker Buy Ratio Quintile → Next Returns")
|
||||||
|
print("-"*60)
|
||||||
|
for ratio_col in ["kline_taker_buy_ratio", "buySellRatio"]:
|
||||||
|
if ratio_col not in available:
|
||||||
|
continue
|
||||||
|
valid = df_k[[ratio_col, "next_return", "next_4_return"]].dropna()
|
||||||
|
try:
|
||||||
|
valid["quintile"] = pd.qcut(valid[ratio_col], 5, labels=["Q1(sell)","Q2","Q3","Q4","Q5(buy)"])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
print(f"\n {ratio_col}:")
|
||||||
|
print(f" {'Quintile':<12} {'mean_ratio':>12} {'next_15m_bps':>14} {'next_1h_bps':>13} {'count':>7} {'win_rate':>10}")
|
||||||
|
for q in ["Q1(sell)","Q2","Q3","Q4","Q5(buy)"]:
|
||||||
|
grp = valid[valid["quintile"] == q]
|
||||||
|
if len(grp) == 0:
|
||||||
|
continue
|
||||||
|
mr = grp[ratio_col].mean()
|
||||||
|
r1 = grp["next_return"].mean() * 10000
|
||||||
|
r4 = grp["next_4_return"].mean() * 10000
|
||||||
|
wr = (grp["next_return"] > 0).mean() * 100
|
||||||
|
print(f" {q:<12} {mr:>12.4f} {r1:>14.2f} {r4:>13.2f} {len(grp):>7} {wr:>9.1f}%")
|
||||||
|
|
||||||
|
# 3. Extreme analysis
|
||||||
|
print("\n[3] Extreme Taker Buy Ratio Analysis (top/bottom 10%)")
|
||||||
|
print("-"*60)
|
||||||
|
for ratio_col in ["kline_taker_buy_ratio", "buySellRatio"]:
|
||||||
|
if ratio_col not in available:
|
||||||
|
continue
|
||||||
|
valid = df_k[[ratio_col, "next_return", "next_4_return"]].dropna()
|
||||||
|
p10 = valid[ratio_col].quantile(0.10)
|
||||||
|
p90 = valid[ratio_col].quantile(0.90)
|
||||||
|
bottom = valid[valid[ratio_col] <= p10]
|
||||||
|
top = valid[valid[ratio_col] >= p90]
|
||||||
|
mid = valid[(valid[ratio_col] > p10) & (valid[ratio_col] < p90)]
|
||||||
|
|
||||||
|
print(f"\n {ratio_col}:")
|
||||||
|
print(f" {'Group':<18} {'mean_ratio':>12} {'next_15m_bps':>14} {'next_1h_bps':>13} {'win_rate':>10} {'count':>7}")
|
||||||
|
for name, grp in [("Bottom 10% (sell)", bottom), ("Middle 80%", mid), ("Top 10% (buy)", top)]:
|
||||||
|
if len(grp) == 0:
|
||||||
|
continue
|
||||||
|
mr = grp[ratio_col].mean()
|
||||||
|
r1 = grp["next_return"].mean() * 10000
|
||||||
|
r4 = grp["next_4_return"].mean() * 10000
|
||||||
|
wr = (grp["next_return"] > 0).mean() * 100
|
||||||
|
print(f" {name:<18} {mr:>12.4f} {r1:>14.2f} {r4:>13.2f} {wr:>9.1f}% {len(grp):>7}")
|
||||||
|
|
||||||
|
# 4. L/S ratio quintile
|
||||||
|
print("\n[4] Long/Short Ratio Quintile → Next Returns")
|
||||||
|
print("-"*60)
|
||||||
|
for ratio_col in ["global_ls_ratio", "top_acct_ls_ratio", "top_pos_ls_ratio"]:
|
||||||
|
if ratio_col not in available:
|
||||||
|
continue
|
||||||
|
valid = df_k[[ratio_col, "next_return", "next_4_return"]].dropna()
|
||||||
|
if len(valid) < 20:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
valid["quintile"] = pd.qcut(valid[ratio_col], 5, labels=["Q1(short)","Q2","Q3","Q4","Q5(long)"], duplicates="drop")
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
print(f"\n {ratio_col}:")
|
||||||
|
print(f" {'Quintile':<12} {'mean_ratio':>12} {'next_15m_bps':>14} {'next_1h_bps':>13} {'win_rate':>10} {'count':>7}")
|
||||||
|
for q in valid["quintile"].cat.categories:
|
||||||
|
grp = valid[valid["quintile"] == q]
|
||||||
|
if len(grp) == 0:
|
||||||
|
continue
|
||||||
|
mr = grp[ratio_col].mean()
|
||||||
|
r1 = grp["next_return"].mean() * 10000
|
||||||
|
r4 = grp["next_4_return"].mean() * 10000
|
||||||
|
wr = (grp["next_return"] > 0).mean() * 100
|
||||||
|
print(f" {q:<12} {mr:>12.4f} {r1:>14.2f} {r4:>13.2f} {wr:>9.1f}% {len(grp):>7}")
|
||||||
|
|
||||||
|
# 5. Contrarian vs Momentum
|
||||||
|
print("\n[5] Contrarian vs Momentum Signal Test")
|
||||||
|
print("-"*60)
|
||||||
|
for ratio_col, label in [("kline_taker_buy_ratio", "Taker Buy Ratio"),
|
||||||
|
("global_ls_ratio", "Global L/S Ratio"),
|
||||||
|
("top_acct_ls_ratio", "Top Trader Acct Ratio"),
|
||||||
|
("top_pos_ls_ratio", "Top Trader Pos Ratio")]:
|
||||||
|
if ratio_col not in available:
|
||||||
|
continue
|
||||||
|
valid = df_k[[ratio_col, "next_return", "next_4_return"]].dropna()
|
||||||
|
median = valid[ratio_col].median()
|
||||||
|
high = valid[valid[ratio_col] > median]
|
||||||
|
low = valid[valid[ratio_col] <= median]
|
||||||
|
h_wr = (high["next_return"] > 0).mean() * 100
|
||||||
|
l_wr = (low["next_return"] > 0).mean() * 100
|
||||||
|
h_r = high["next_return"].mean() * 10000
|
||||||
|
l_r = low["next_return"].mean() * 10000
|
||||||
|
signal = "Momentum" if h_r > l_r else "Contrarian"
|
||||||
|
print(f"\n {label}:")
|
||||||
|
print(f" Above median → next 15m: {h_r:+.2f} bps (win {h_wr:.1f}%)")
|
||||||
|
print(f" Below median → next 15m: {l_r:+.2f} bps (win {l_wr:.1f}%)")
|
||||||
|
print(f" → Signal type: {signal}")
|
||||||
|
|
||||||
|
# 6. Stats
|
||||||
|
print("\n[6] Feature Statistics Summary")
|
||||||
|
print("-"*60)
|
||||||
|
for feat in available:
|
||||||
|
s = df_k[feat].dropna()
|
||||||
|
print(f" {feat}: mean={s.mean():.4f}, std={s.std():.4f}, min={s.min():.4f}, max={s.max():.4f}, n={len(s)}")
|
||||||
|
print(f"\n Total klines: {len(df_k)}")
|
||||||
|
print(f" Period: {df_k.index[0]} ~ {df_k.index[-1]}")
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
end_dt = datetime.now(timezone.utc)
|
||||||
|
start_dt = end_dt - timedelta(days=DAYS)
|
||||||
|
start_ms = int(start_dt.timestamp() * 1000)
|
||||||
|
end_ms = int(end_dt.timestamp() * 1000)
|
||||||
|
|
||||||
|
print(f"Fetching {DAYS} days of {INTERVAL} data for {', '.join(SYMBOLS)}...")
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*[analyze_symbol(session, sym, start_ms, end_ms) for sym in SYMBOLS]
|
||||||
|
)
|
||||||
|
|
||||||
|
for sym, df in zip(SYMBOLS, results):
|
||||||
|
print_analysis(sym, df)
|
||||||
|
|
||||||
|
# Cross-symbol comparison
|
||||||
|
if len(SYMBOLS) > 1:
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print("CROSS-SYMBOL COMPARISON SUMMARY")
|
||||||
|
print("="*70)
|
||||||
|
print(f"\n{'Symbol':<12} {'taker_buy→15m':>14} {'taker_buy→1h':>13} {'global_ls→1h':>13} {'top_acct→1h':>13} {'top_pos→1h':>12}")
|
||||||
|
print("-"*78)
|
||||||
|
for sym, df in zip(SYMBOLS, results):
|
||||||
|
tb = df["kline_taker_buy_ratio"].corr(df["next_return"]) if "kline_taker_buy_ratio" in df.columns else float('nan')
|
||||||
|
tb4 = df["kline_taker_buy_ratio"].corr(df["next_4_return"]) if "kline_taker_buy_ratio" in df.columns else float('nan')
|
||||||
|
gl = df["global_ls_ratio"].corr(df["next_4_return"]) if "global_ls_ratio" in df.columns and df["global_ls_ratio"].notna().sum() > 20 else float('nan')
|
||||||
|
ta = df["top_acct_ls_ratio"].corr(df["next_4_return"]) if "top_acct_ls_ratio" in df.columns and df["top_acct_ls_ratio"].notna().sum() > 20 else float('nan')
|
||||||
|
tp = df["top_pos_ls_ratio"].corr(df["next_4_return"]) if "top_pos_ls_ratio" in df.columns and df["top_pos_ls_ratio"].notna().sum() > 20 else float('nan')
|
||||||
|
print(f"{sym:<12} {tb:>14.4f} {tb4:>13.4f} {gl:>13.4f} {ta:>13.4f} {tp:>12.4f}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -17,7 +17,7 @@ import numpy as np
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
from sklearn.metrics import roc_auc_score, classification_report
|
from sklearn.metrics import roc_auc_score, classification_report
|
||||||
|
|
||||||
from src.dataset_builder import generate_dataset_vectorized
|
from src.dataset_builder import generate_dataset_vectorized, stratified_undersample
|
||||||
from src.ml_features import FEATURE_COLS
|
from src.ml_features import FEATURE_COLS
|
||||||
from src.mlx_filter import MLXFilter
|
from src.mlx_filter import MLXFilter
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ def _split_combined(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame | None
|
|||||||
return xrp_df, btc_df, eth_df
|
return xrp_df, btc_df, eth_df
|
||||||
|
|
||||||
|
|
||||||
def train_mlx(data_path: str, time_weight_decay: float = 2.0) -> float:
|
def train_mlx(data_path: str, time_weight_decay: float = 2.0, atr_sl_mult: float = 2.0, atr_tp_mult: float = 2.0) -> float:
|
||||||
print(f"데이터 로드: {data_path}")
|
print(f"데이터 로드: {data_path}")
|
||||||
raw = pd.read_parquet(data_path)
|
raw = pd.read_parquet(data_path)
|
||||||
print(f"캔들 수: {len(raw)}")
|
print(f"캔들 수: {len(raw)}")
|
||||||
@@ -58,7 +58,8 @@ def train_mlx(data_path: str, time_weight_decay: float = 2.0) -> float:
|
|||||||
|
|
||||||
print("\n데이터셋 생성 중...")
|
print("\n데이터셋 생성 중...")
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=time_weight_decay)
|
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=time_weight_decay,
|
||||||
|
atr_sl_mult=atr_sl_mult, atr_tp_mult=atr_tp_mult)
|
||||||
t1 = time.perf_counter()
|
t1 = time.perf_counter()
|
||||||
print(f"데이터셋 생성 완료: {t1 - t0:.1f}초, {len(dataset)}개 샘플")
|
print(f"데이터셋 생성 완료: {t1 - t0:.1f}초, {len(dataset)}개 샘플")
|
||||||
|
|
||||||
@@ -85,16 +86,10 @@ def train_mlx(data_path: str, time_weight_decay: float = 2.0) -> float:
|
|||||||
y_train, y_val = y.iloc[:split], y.iloc[split:]
|
y_train, y_val = y.iloc[:split], y.iloc[split:]
|
||||||
w_train = w[:split]
|
w_train = w[:split]
|
||||||
|
|
||||||
# --- 클래스 불균형 처리: 언더샘플링 (가중치 인덱스 보존) ---
|
# --- 클래스 불균형 처리: stratified 언더샘플링 (Signal 전수 유지, HOLD만 샘플링) ---
|
||||||
pos_idx = np.where(y_train == 1)[0]
|
source = dataset["source"].values if "source" in dataset.columns else np.full(len(dataset), "signal")
|
||||||
neg_idx = np.where(y_train == 0)[0]
|
source_train = source[:split]
|
||||||
|
balanced_idx = stratified_undersample(y_train.values, source_train, seed=42)
|
||||||
if len(neg_idx) > len(pos_idx):
|
|
||||||
np.random.seed(42)
|
|
||||||
neg_idx = np.random.choice(neg_idx, size=len(pos_idx), replace=False)
|
|
||||||
|
|
||||||
balanced_idx = np.concatenate([pos_idx, neg_idx])
|
|
||||||
np.random.shuffle(balanced_idx)
|
|
||||||
|
|
||||||
X_train = X_train.iloc[balanced_idx]
|
X_train = X_train.iloc[balanced_idx]
|
||||||
y_train = y_train.iloc[balanced_idx]
|
y_train = y_train.iloc[balanced_idx]
|
||||||
@@ -170,6 +165,8 @@ def walk_forward_auc(
|
|||||||
time_weight_decay: float = 2.0,
|
time_weight_decay: float = 2.0,
|
||||||
n_splits: int = 5,
|
n_splits: int = 5,
|
||||||
train_ratio: float = 0.6,
|
train_ratio: float = 0.6,
|
||||||
|
atr_sl_mult: float = 2.0,
|
||||||
|
atr_tp_mult: float = 2.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Walk-Forward 검증: 슬라이딩 윈도우로 n_splits번 학습/검증 반복."""
|
"""Walk-Forward 검증: 슬라이딩 윈도우로 n_splits번 학습/검증 반복."""
|
||||||
print(f"\n=== Walk-Forward 검증 ({n_splits}폴드, decay={time_weight_decay}) ===")
|
print(f"\n=== Walk-Forward 검증 ({n_splits}폴드, decay={time_weight_decay}) ===")
|
||||||
@@ -177,7 +174,8 @@ def walk_forward_auc(
|
|||||||
df, btc_df, eth_df = _split_combined(raw)
|
df, btc_df, eth_df = _split_combined(raw)
|
||||||
|
|
||||||
dataset = generate_dataset_vectorized(
|
dataset = generate_dataset_vectorized(
|
||||||
df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=time_weight_decay
|
df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=time_weight_decay,
|
||||||
|
atr_sl_mult=atr_sl_mult, atr_tp_mult=atr_tp_mult,
|
||||||
)
|
)
|
||||||
missing = [c for c in FEATURE_COLS if c not in dataset.columns]
|
missing = [c for c in FEATURE_COLS if c not in dataset.columns]
|
||||||
for col in missing:
|
for col in missing:
|
||||||
@@ -186,46 +184,37 @@ def walk_forward_auc(
|
|||||||
X_all = dataset[FEATURE_COLS].values.astype(np.float32)
|
X_all = dataset[FEATURE_COLS].values.astype(np.float32)
|
||||||
y_all = dataset["label"].values.astype(np.float32)
|
y_all = dataset["label"].values.astype(np.float32)
|
||||||
w_all = dataset["sample_weight"].values.astype(np.float32)
|
w_all = dataset["sample_weight"].values.astype(np.float32)
|
||||||
|
source_all = dataset["source"].values if "source" in dataset.columns else np.full(len(dataset), "signal")
|
||||||
n = len(dataset)
|
n = len(dataset)
|
||||||
|
|
||||||
step = max(1, int(n * (1 - train_ratio) / n_splits))
|
step = max(1, int(n * (1 - train_ratio) / n_splits))
|
||||||
train_end_start = int(n * train_ratio)
|
train_end_start = int(n * train_ratio)
|
||||||
|
|
||||||
aucs = []
|
aucs = []
|
||||||
|
from src.dataset_builder import LOOKAHEAD
|
||||||
|
|
||||||
for i in range(n_splits):
|
for i in range(n_splits):
|
||||||
tr_end = train_end_start + i * step
|
tr_end = train_end_start + i * step
|
||||||
val_end = tr_end + step
|
val_start = tr_end + LOOKAHEAD # purged gap
|
||||||
|
val_end = val_start + step
|
||||||
if val_end > n:
|
if val_end > n:
|
||||||
break
|
break
|
||||||
|
|
||||||
X_tr_raw = X_all[:tr_end]
|
X_tr_raw = X_all[:tr_end]
|
||||||
y_tr = y_all[:tr_end]
|
y_tr = y_all[:tr_end]
|
||||||
w_tr = w_all[:tr_end]
|
w_tr = w_all[:tr_end]
|
||||||
X_val_raw = X_all[tr_end:val_end]
|
X_val_raw = X_all[val_start:val_end]
|
||||||
y_val = y_all[tr_end:val_end]
|
y_val = y_all[val_start:val_end]
|
||||||
|
|
||||||
pos_idx = np.where(y_tr == 1)[0]
|
source_tr = source_all[:tr_end]
|
||||||
neg_idx = np.where(y_tr == 0)[0]
|
bal_idx = stratified_undersample(y_tr, source_tr, seed=42)
|
||||||
if len(neg_idx) > len(pos_idx):
|
|
||||||
np.random.seed(42)
|
|
||||||
neg_idx = np.random.choice(neg_idx, size=len(pos_idx), replace=False)
|
|
||||||
bal_idx = np.sort(np.concatenate([pos_idx, neg_idx]))
|
|
||||||
|
|
||||||
X_tr_bal = X_tr_raw[bal_idx]
|
X_tr_bal = X_tr_raw[bal_idx]
|
||||||
y_tr_bal = y_tr[bal_idx]
|
y_tr_bal = y_tr[bal_idx]
|
||||||
w_tr_bal = w_tr[bal_idx]
|
w_tr_bal = w_tr[bal_idx]
|
||||||
|
|
||||||
# 폴드별 정규화 (학습 데이터 기준으로 계산, 검증에도 동일 적용)
|
X_tr_df = pd.DataFrame(X_tr_bal, columns=FEATURE_COLS)
|
||||||
mean = X_tr_bal.mean(axis=0)
|
X_val_df = pd.DataFrame(X_val_raw, columns=FEATURE_COLS)
|
||||||
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
|
|
||||||
|
|
||||||
# DataFrame으로 래핑해서 MLXFilter.fit()에 전달
|
|
||||||
# fit() 내부 정규화가 덮어쓰지 않도록 이미 정규화된 데이터를 넘기고
|
|
||||||
# _mean=0, _std=1로 고정해 이중 정규화를 방지
|
|
||||||
X_tr_df = pd.DataFrame(X_tr_norm, columns=FEATURE_COLS)
|
|
||||||
X_val_df = pd.DataFrame(X_val_norm, columns=FEATURE_COLS)
|
|
||||||
|
|
||||||
model = MLXFilter(
|
model = MLXFilter(
|
||||||
input_dim=len(FEATURE_COLS),
|
input_dim=len(FEATURE_COLS),
|
||||||
@@ -235,16 +224,13 @@ def walk_forward_auc(
|
|||||||
batch_size=256,
|
batch_size=256,
|
||||||
)
|
)
|
||||||
model.fit(X_tr_df, pd.Series(y_tr_bal), sample_weight=w_tr_bal)
|
model.fit(X_tr_df, pd.Series(y_tr_bal), sample_weight=w_tr_bal)
|
||||||
# fit()이 내부에서 다시 정규화하므로 저장된 mean/std를 항등 변환으로 교체
|
|
||||||
model._mean = np.zeros(len(FEATURE_COLS), dtype=np.float32)
|
|
||||||
model._std = np.ones(len(FEATURE_COLS), dtype=np.float32)
|
|
||||||
|
|
||||||
proba = model.predict_proba(X_val_df)
|
proba = model.predict_proba(X_val_df)
|
||||||
auc = roc_auc_score(y_val, proba) if len(np.unique(y_val)) > 1 else 0.5
|
auc = roc_auc_score(y_val, proba) if len(np.unique(y_val)) > 1 else 0.5
|
||||||
aucs.append(auc)
|
aucs.append(auc)
|
||||||
print(
|
print(
|
||||||
f" 폴드 {i+1}/{n_splits}: 학습={tr_end}개, "
|
f" 폴드 {i+1}/{n_splits}: 학습={tr_end}개, "
|
||||||
f"검증={tr_end}~{val_end} ({step}개), AUC={auc:.4f}"
|
f"검증={val_start}~{val_end} ({step}개, embargo={LOOKAHEAD}), AUC={auc:.4f}"
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"\n Walk-Forward 평균 AUC: {np.mean(aucs):.4f} ± {np.std(aucs):.4f}")
|
print(f"\n Walk-Forward 평균 AUC: {np.mean(aucs):.4f} ± {np.std(aucs):.4f}")
|
||||||
@@ -260,12 +246,16 @@ def main():
|
|||||||
)
|
)
|
||||||
parser.add_argument("--wf", action="store_true", help="Walk-Forward 검증 실행")
|
parser.add_argument("--wf", action="store_true", help="Walk-Forward 검증 실행")
|
||||||
parser.add_argument("--wf-splits", type=int, default=5, help="Walk-Forward 폴드 수")
|
parser.add_argument("--wf-splits", type=int, default=5, help="Walk-Forward 폴드 수")
|
||||||
|
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)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.wf:
|
if args.wf:
|
||||||
walk_forward_auc(args.data, time_weight_decay=args.decay, n_splits=args.wf_splits)
|
walk_forward_auc(args.data, time_weight_decay=args.decay, n_splits=args.wf_splits,
|
||||||
|
atr_sl_mult=args.sl_mult, atr_tp_mult=args.tp_mult)
|
||||||
else:
|
else:
|
||||||
train_mlx(args.data, time_weight_decay=args.decay)
|
train_mlx(args.data, time_weight_decay=args.decay,
|
||||||
|
atr_sl_mult=args.sl_mult, atr_tp_mult=args.tp_mult)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
import warnings
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from multiprocessing import Pool, cpu_count
|
from multiprocessing import Pool, cpu_count
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -54,8 +55,6 @@ def _cgroup_cpu_count() -> int:
|
|||||||
|
|
||||||
|
|
||||||
LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 (dataset_builder.py와 동기화)
|
LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 (dataset_builder.py와 동기화)
|
||||||
ATR_SL_MULT = 1.5
|
|
||||||
ATR_TP_MULT = 3.0
|
|
||||||
MODEL_PATH = Path("models/lgbm_filter.pkl")
|
MODEL_PATH = Path("models/lgbm_filter.pkl")
|
||||||
PREV_MODEL_PATH = Path("models/lgbm_filter_prev.pkl")
|
PREV_MODEL_PATH = Path("models/lgbm_filter_prev.pkl")
|
||||||
LOG_PATH = Path("models/training_log.json")
|
LOG_PATH = Path("models/training_log.json")
|
||||||
@@ -63,6 +62,8 @@ LOG_PATH = Path("models/training_log.json")
|
|||||||
|
|
||||||
def _process_index(args: tuple) -> dict | None:
|
def _process_index(args: tuple) -> dict | None:
|
||||||
"""단일 인덱스에 대해 피처+레이블을 계산한다. Pool worker 함수."""
|
"""단일 인덱스에 대해 피처+레이블을 계산한다. Pool worker 함수."""
|
||||||
|
ATR_SL_MULT = 1.5 # legacy values
|
||||||
|
ATR_TP_MULT = 3.0
|
||||||
i, df_values, df_columns = args
|
i, df_values, df_columns = args
|
||||||
df = pd.DataFrame(df_values, columns=df_columns)
|
df = pd.DataFrame(df_values, columns=df_columns)
|
||||||
|
|
||||||
@@ -104,7 +105,11 @@ def _process_index(args: tuple) -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
def generate_dataset(df: pd.DataFrame, n_jobs: int | None = None) -> pd.DataFrame:
|
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,
|
||||||
|
)
|
||||||
total = len(df)
|
total = len(df)
|
||||||
indices = range(60, total - LOOKAHEAD)
|
indices = range(60, total - LOOKAHEAD)
|
||||||
|
|
||||||
@@ -191,7 +196,7 @@ def _load_lgbm_params(tuned_params_path: str | None) -> tuple[dict, float]:
|
|||||||
return lgbm_params, weight_scale
|
return lgbm_params, weight_scale
|
||||||
|
|
||||||
|
|
||||||
def train(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str | None = None):
|
def train(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str | None = None, atr_sl_mult: float = 2.0, atr_tp_mult: float = 2.0):
|
||||||
print(f"데이터 로드: {data_path}")
|
print(f"데이터 로드: {data_path}")
|
||||||
df_raw = pd.read_parquet(data_path)
|
df_raw = pd.read_parquet(data_path)
|
||||||
print(f"캔들 수: {len(df_raw)}, 컬럼: {list(df_raw.columns)}")
|
print(f"캔들 수: {len(df_raw)}, 컬럼: {list(df_raw.columns)}")
|
||||||
@@ -217,7 +222,9 @@ def train(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str
|
|||||||
dataset = generate_dataset_vectorized(
|
dataset = generate_dataset_vectorized(
|
||||||
df, btc_df=btc_df, eth_df=eth_df,
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
time_weight_decay=time_weight_decay,
|
time_weight_decay=time_weight_decay,
|
||||||
negative_ratio=5,
|
|
||||||
|
atr_sl_mult=atr_sl_mult,
|
||||||
|
atr_tp_mult=atr_tp_mult,
|
||||||
)
|
)
|
||||||
|
|
||||||
if dataset.empty or "label" not in dataset.columns:
|
if dataset.empty or "label" not in dataset.columns:
|
||||||
@@ -335,6 +342,8 @@ def walk_forward_auc(
|
|||||||
n_splits: int = 5,
|
n_splits: int = 5,
|
||||||
train_ratio: float = 0.6,
|
train_ratio: float = 0.6,
|
||||||
tuned_params_path: str | None = None,
|
tuned_params_path: str | None = None,
|
||||||
|
atr_sl_mult: float = 2.0,
|
||||||
|
atr_tp_mult: float = 2.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Walk-Forward 검증: 슬라이딩 윈도우로 n_splits번 학습/검증 반복.
|
"""Walk-Forward 검증: 슬라이딩 윈도우로 n_splits번 학습/검증 반복.
|
||||||
|
|
||||||
@@ -358,7 +367,9 @@ def walk_forward_auc(
|
|||||||
dataset = generate_dataset_vectorized(
|
dataset = generate_dataset_vectorized(
|
||||||
df, btc_df=btc_df, eth_df=eth_df,
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
time_weight_decay=time_weight_decay,
|
time_weight_decay=time_weight_decay,
|
||||||
negative_ratio=5,
|
|
||||||
|
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]
|
actual_feature_cols = [c for c in FEATURE_COLS if c in dataset.columns]
|
||||||
X = dataset[actual_feature_cols].values
|
X = dataset[actual_feature_cols].values
|
||||||
@@ -375,14 +386,17 @@ def walk_forward_auc(
|
|||||||
|
|
||||||
aucs = []
|
aucs = []
|
||||||
fold_metrics = []
|
fold_metrics = []
|
||||||
|
from src.dataset_builder import LOOKAHEAD
|
||||||
|
|
||||||
for i in range(n_splits):
|
for i in range(n_splits):
|
||||||
tr_end = train_end_start + i * step
|
tr_end = train_end_start + i * step
|
||||||
val_end = tr_end + step
|
val_start = tr_end + LOOKAHEAD # purged gap: 레이블 누수 방지
|
||||||
|
val_end = val_start + step
|
||||||
if val_end > n:
|
if val_end > n:
|
||||||
break
|
break
|
||||||
|
|
||||||
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
|
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
|
||||||
X_val, y_val = X[tr_end:val_end], y[tr_end:val_end]
|
X_val, y_val = X[val_start:val_end], y[val_start:val_end]
|
||||||
|
|
||||||
source_tr = source[:tr_end]
|
source_tr = source[:tr_end]
|
||||||
idx = stratified_undersample(y_tr, source_tr, seed=42)
|
idx = stratified_undersample(y_tr, source_tr, seed=42)
|
||||||
@@ -410,7 +424,7 @@ def walk_forward_auc(
|
|||||||
fold_metrics.append({"auc": auc, "precision": f_prec, "recall": f_rec, "threshold": f_thr})
|
fold_metrics.append({"auc": auc, "precision": f_prec, "recall": f_rec, "threshold": f_thr})
|
||||||
print(
|
print(
|
||||||
f" 폴드 {i+1}/{n_splits}: 학습={tr_end}개, "
|
f" 폴드 {i+1}/{n_splits}: 학습={tr_end}개, "
|
||||||
f"검증={tr_end}~{val_end} ({step}개), AUC={auc:.4f} | "
|
f"검증={val_start}~{val_end} ({step}개, embargo={LOOKAHEAD}), AUC={auc:.4f} | "
|
||||||
f"Thr={f_thr:.4f} Prec={f_prec:.3f} Rec={f_rec:.3f}"
|
f"Thr={f_thr:.4f} Prec={f_prec:.3f} Rec={f_rec:.3f}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -422,7 +436,7 @@ def walk_forward_auc(
|
|||||||
print(f" 폴드별: {[round(a, 4) for a in aucs]}")
|
print(f" 폴드별: {[round(a, 4) for a in aucs]}")
|
||||||
|
|
||||||
|
|
||||||
def compare(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str | None = None):
|
def compare(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str | None = None, atr_sl_mult: float = 2.0, atr_tp_mult: float = 2.0):
|
||||||
"""기존 피처 vs OI 파생 피처 추가 버전 A/B 비교."""
|
"""기존 피처 vs OI 파생 피처 추가 버전 A/B 비교."""
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
@@ -448,7 +462,9 @@ def compare(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: s
|
|||||||
dataset = generate_dataset_vectorized(
|
dataset = generate_dataset_vectorized(
|
||||||
df, btc_df=btc_df, eth_df=eth_df,
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
time_weight_decay=time_weight_decay,
|
time_weight_decay=time_weight_decay,
|
||||||
negative_ratio=5,
|
|
||||||
|
atr_sl_mult=atr_sl_mult,
|
||||||
|
atr_tp_mult=atr_tp_mult,
|
||||||
)
|
)
|
||||||
|
|
||||||
if dataset.empty:
|
if dataset.empty:
|
||||||
@@ -529,6 +545,135 @@ def compare(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: s
|
|||||||
print(f" {feat_name:<25} {imp_val:>6}{marker}")
|
print(f" {feat_name:<25} {imp_val:>6}{marker}")
|
||||||
|
|
||||||
|
|
||||||
|
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 제거
|
||||||
|
"""
|
||||||
|
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]}")
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--data", default=None)
|
parser.add_argument("--data", default=None)
|
||||||
@@ -544,8 +689,12 @@ def main():
|
|||||||
"--tuned-params", type=str, default=None,
|
"--tuned-params", type=str, default=None,
|
||||||
help="Optuna 튜닝 결과 JSON 경로 (지정 시 기본 파라미터를 덮어씀)",
|
help="Optuna 튜닝 결과 JSON 경로 (지정 시 기본 파라미터를 덮어씀)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument("--ablation", action="store_true",
|
||||||
|
help="Feature ablation 실험 (signal_strength/side 의존도 진단)")
|
||||||
parser.add_argument("--compare", action="store_true",
|
parser.add_argument("--compare", action="store_true",
|
||||||
help="OI 파생 피처 추가 전후 A/B 성능 비교")
|
help="OI 파생 피처 추가 전후 A/B 성능 비교")
|
||||||
|
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)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# --symbol 모드: 심볼별 디렉토리 경로 자동 결정
|
# --symbol 모드: 심볼별 디렉토리 경로 자동 결정
|
||||||
@@ -562,17 +711,27 @@ def main():
|
|||||||
elif args.data is None:
|
elif args.data is None:
|
||||||
args.data = "data/combined_15m.parquet"
|
args.data = "data/combined_15m.parquet"
|
||||||
|
|
||||||
if args.compare:
|
if args.ablation:
|
||||||
compare(args.data, time_weight_decay=args.decay, tuned_params_path=args.tuned_params)
|
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:
|
||||||
|
compare(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.wf:
|
elif args.wf:
|
||||||
walk_forward_auc(
|
walk_forward_auc(
|
||||||
args.data,
|
args.data,
|
||||||
time_weight_decay=args.decay,
|
time_weight_decay=args.decay,
|
||||||
n_splits=args.wf_splits,
|
n_splits=args.wf_splits,
|
||||||
tuned_params_path=args.tuned_params,
|
tuned_params_path=args.tuned_params,
|
||||||
|
atr_sl_mult=args.sl_mult,
|
||||||
|
atr_tp_mult=args.tp_mult,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
train(args.data, time_weight_decay=args.decay, tuned_params_path=args.tuned_params)
|
train(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)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ from src.dataset_builder import generate_dataset_vectorized, stratified_undersam
|
|||||||
# 데이터 로드 및 데이터셋 생성 (1회 캐싱)
|
# 데이터 로드 및 데이터셋 생성 (1회 캐싱)
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
def load_dataset(data_path: str, atr_sl_mult: float = 2.0, atr_tp_mult: float = 2.0) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||||
"""
|
"""
|
||||||
parquet 로드 → 벡터화 데이터셋 생성 → (X, y, w) numpy 배열 반환.
|
parquet 로드 → 벡터화 데이터셋 생성 → (X, y, w) numpy 배열 반환.
|
||||||
study 시작 전 1회만 호출하여 모든 trial이 공유한다.
|
study 시작 전 1회만 호출하여 모든 trial이 공유한다.
|
||||||
@@ -64,7 +64,8 @@ def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray, np
|
|||||||
df = df_raw[base_cols].copy()
|
df = df_raw[base_cols].copy()
|
||||||
|
|
||||||
print("\n데이터셋 생성 중 (1회만 실행)...")
|
print("\n데이터셋 생성 중 (1회만 실행)...")
|
||||||
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=0.0, negative_ratio=5)
|
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=0.0,
|
||||||
|
atr_sl_mult=atr_sl_mult, atr_tp_mult=atr_tp_mult)
|
||||||
|
|
||||||
if dataset.empty or "label" not in dataset.columns:
|
if dataset.empty or "label" not in dataset.columns:
|
||||||
raise ValueError("데이터셋 생성 실패: 샘플 0개")
|
raise ValueError("데이터셋 생성 실패: 샘플 0개")
|
||||||
@@ -149,14 +150,17 @@ def _walk_forward_cv(
|
|||||||
fold_n_pos: list[int] = []
|
fold_n_pos: list[int] = []
|
||||||
scores_so_far: list[float] = []
|
scores_so_far: list[float] = []
|
||||||
|
|
||||||
|
from src.dataset_builder import LOOKAHEAD
|
||||||
|
|
||||||
for fold_idx in range(n_splits):
|
for fold_idx in range(n_splits):
|
||||||
tr_end = train_end_start + fold_idx * step
|
tr_end = train_end_start + fold_idx * step
|
||||||
val_end = tr_end + step
|
val_start = tr_end + LOOKAHEAD # purged gap
|
||||||
|
val_end = val_start + step
|
||||||
if val_end > n:
|
if val_end > n:
|
||||||
break
|
break
|
||||||
|
|
||||||
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
|
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
|
||||||
X_val, y_val = X[tr_end:val_end], y[tr_end:val_end]
|
X_val, y_val = X[val_start:val_end], y[val_start:val_end]
|
||||||
|
|
||||||
# 계층적 샘플링: signal 전수 유지, HOLD negative만 양성 수 만큼
|
# 계층적 샘플링: signal 전수 유지, HOLD negative만 양성 수 만큼
|
||||||
source_tr = source[:tr_end]
|
source_tr = source[:tr_end]
|
||||||
@@ -527,6 +531,8 @@ def main():
|
|||||||
parser.add_argument("--train-ratio", type=float, default=0.6, help="학습 구간 비율 (기본: 0.6)")
|
parser.add_argument("--train-ratio", type=float, default=0.6, help="학습 구간 비율 (기본: 0.6)")
|
||||||
parser.add_argument("--min-recall", type=float, default=0.35, help="최소 재현율 제약 (기본: 0.35)")
|
parser.add_argument("--min-recall", type=float, default=0.35, help="최소 재현율 제약 (기본: 0.35)")
|
||||||
parser.add_argument("--no-baseline", action="store_true", help="베이스라인 측정 건너뜀")
|
parser.add_argument("--no-baseline", action="store_true", help="베이스라인 측정 건너뜀")
|
||||||
|
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)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# --symbol 모드: 심볼별 디렉토리 경로 자동 결정
|
# --symbol 모드: 심볼별 디렉토리 경로 자동 결정
|
||||||
@@ -538,7 +544,7 @@ def main():
|
|||||||
args.data = "data/combined_15m.parquet"
|
args.data = "data/combined_15m.parquet"
|
||||||
|
|
||||||
# 1. 데이터셋 로드 (1회)
|
# 1. 데이터셋 로드 (1회)
|
||||||
X, y, w, source = load_dataset(args.data)
|
X, y, w, source = load_dataset(args.data, atr_sl_mult=args.sl_mult, atr_tp_mult=args.tp_mult)
|
||||||
|
|
||||||
# 2. 베이스라인 측정
|
# 2. 베이스라인 측정
|
||||||
if args.symbol:
|
if args.symbol:
|
||||||
|
|||||||
230
scripts/verify_prod_api.py
Normal file
230
scripts/verify_prod_api.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"""실전 API SL/TP 콜백 검증 스크립트.
|
||||||
|
|
||||||
|
검증 항목:
|
||||||
|
1. SL/TP 주문 응답에 orderId vs algoId 확인
|
||||||
|
2. SL 트리거 시 UDS 콜백의 o, ot 필드 값
|
||||||
|
3. futures_cancel_order(orderId=...)로 TP 취소 가능 여부
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
1. 바이낸스 앱/웹에서 XRPUSDT 소액 LONG 포지션 수동 진입
|
||||||
|
2. python scripts/verify_prod_api.py 실행
|
||||||
|
→ 자동으로 SL/TP 배치 + UDS 리스닝
|
||||||
|
3. SL이 트리거되면 콜백 로그 확인 + TP 자동 취소 시도
|
||||||
|
|
||||||
|
환경변수: BINANCE_API_KEY, BINANCE_API_SECRET (실전 키)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from binance import AsyncClient, BinanceSocketManager
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.exchange import BinanceFuturesClient
|
||||||
|
from src.config import Config
|
||||||
|
|
||||||
|
# .env에서 실전 키 로드 (BINANCE_TESTNET이 설정되어 있으면 해제)
|
||||||
|
os.environ.pop("BINANCE_TESTNET", None)
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
SYMBOL = "XRPUSDT"
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
api_key = os.getenv("BINANCE_API_KEY", "")
|
||||||
|
api_secret = os.getenv("BINANCE_API_SECRET", "")
|
||||||
|
|
||||||
|
if not api_key or not api_secret:
|
||||||
|
logger.error("BINANCE_API_KEY / BINANCE_API_SECRET 환경변수 필요")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Exchange 클라이언트 (실전)
|
||||||
|
config = Config()
|
||||||
|
config.testnet = False
|
||||||
|
config.api_key = api_key
|
||||||
|
config.api_secret = api_secret
|
||||||
|
config.symbol = SYMBOL
|
||||||
|
|
||||||
|
exchange = BinanceFuturesClient(config, symbol=SYMBOL)
|
||||||
|
|
||||||
|
# ── Step 1: 현재 포지션 확인 ──
|
||||||
|
position = await exchange.get_position()
|
||||||
|
if position is None:
|
||||||
|
logger.error(
|
||||||
|
f"[{SYMBOL}] 포지션 없음. 먼저 바이낸스 앱/웹에서 소액 포지션을 수동으로 진입하세요."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
pos_amt = float(position["positionAmt"])
|
||||||
|
entry_price = float(position["entryPrice"])
|
||||||
|
mark_price = float(position.get("markPrice", entry_price))
|
||||||
|
side = "LONG" if pos_amt > 0 else "SHORT"
|
||||||
|
quantity = abs(pos_amt)
|
||||||
|
|
||||||
|
logger.info(f"[{SYMBOL}] 포지션 확인: {side} qty={quantity}, entry={entry_price}, mark={mark_price}")
|
||||||
|
|
||||||
|
# ── Step 2: 기존 오픈 주문 확인/정리 ──
|
||||||
|
open_orders = await exchange.get_open_orders()
|
||||||
|
if open_orders:
|
||||||
|
logger.info(f"[{SYMBOL}] 기존 오픈 주문 {len(open_orders)}개 — 전체 취소")
|
||||||
|
await exchange.cancel_all_orders()
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# ── Step 3: SL/TP 주문 배치 (현재가 기준 가까운 값) ──
|
||||||
|
# SL: 현재가에서 0.15% 떨어진 곳 (빨리 트리거되도록)
|
||||||
|
# TP: 현재가에서 2% 떨어진 곳 (트리거 안 되도록)
|
||||||
|
sl_side = "SELL" if side == "LONG" else "BUY"
|
||||||
|
|
||||||
|
if side == "LONG":
|
||||||
|
stop_loss = exchange._round_price(mark_price * 0.9985) # -0.15%
|
||||||
|
take_profit = exchange._round_price(mark_price * 1.02) # +2%
|
||||||
|
else:
|
||||||
|
stop_loss = exchange._round_price(mark_price * 1.0015) # +0.15%
|
||||||
|
take_profit = exchange._round_price(mark_price * 0.98) # -2%
|
||||||
|
|
||||||
|
logger.info(f"[{SYMBOL}] SL/TP 배치 예정: SL={stop_loss}, TP={take_profit}, side={sl_side}")
|
||||||
|
|
||||||
|
# SL 배치
|
||||||
|
sl_result = await exchange.place_order(
|
||||||
|
side=sl_side,
|
||||||
|
quantity=quantity,
|
||||||
|
order_type="STOP_MARKET",
|
||||||
|
stop_price=stop_loss,
|
||||||
|
reduce_only=True,
|
||||||
|
)
|
||||||
|
logger.success(f"[검증1] SL 주문 응답 전체:\n{json.dumps(sl_result, indent=2)}")
|
||||||
|
sl_order_id = sl_result.get("orderId")
|
||||||
|
sl_algo_id = sl_result.get("algoId")
|
||||||
|
logger.info(f" → orderId={sl_order_id}, algoId={sl_algo_id}")
|
||||||
|
|
||||||
|
# TP 배치
|
||||||
|
tp_result = await exchange.place_order(
|
||||||
|
side=sl_side,
|
||||||
|
quantity=quantity,
|
||||||
|
order_type="TAKE_PROFIT_MARKET",
|
||||||
|
stop_price=take_profit,
|
||||||
|
reduce_only=True,
|
||||||
|
)
|
||||||
|
logger.success(f"[검증1] TP 주문 응답 전체:\n{json.dumps(tp_result, indent=2)}")
|
||||||
|
tp_order_id = tp_result.get("orderId")
|
||||||
|
tp_algo_id = tp_result.get("algoId")
|
||||||
|
logger.info(f" → orderId={tp_order_id}, algoId={tp_algo_id}")
|
||||||
|
|
||||||
|
# ── Step 4: UDS 리스닝 — SL 트리거 대기 ──
|
||||||
|
logger.info(f"[{SYMBOL}] UDS 리스닝 시작 — SL 트리거 대기 중 (mark={mark_price}, SL={stop_loss})")
|
||||||
|
logger.info(" SL이 트리거되면 자동으로 TP 취소를 시도합니다.")
|
||||||
|
logger.info(" Ctrl+C로 중단 가능 (중단 시 잔여 주문 정리)")
|
||||||
|
|
||||||
|
sl_triggered = asyncio.Event()
|
||||||
|
|
||||||
|
async def on_uds_message(msg: dict):
|
||||||
|
if msg.get("e") != "ORDER_TRADE_UPDATE":
|
||||||
|
return
|
||||||
|
|
||||||
|
order = msg.get("o", {})
|
||||||
|
if order.get("s") != SYMBOL:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 모든 이벤트 원본 로깅
|
||||||
|
logger.info(
|
||||||
|
f"[검증2] UDS 원본: "
|
||||||
|
f"s={order.get('s')} "
|
||||||
|
f"o={order.get('o')} "
|
||||||
|
f"ot={order.get('ot')} "
|
||||||
|
f"x={order.get('x')} "
|
||||||
|
f"X={order.get('X')} "
|
||||||
|
f"R={order.get('R')} "
|
||||||
|
f"S={order.get('S')} "
|
||||||
|
f"i={order.get('i')} "
|
||||||
|
f"ap={order.get('ap')} "
|
||||||
|
f"rp={order.get('rp')} "
|
||||||
|
f"n={order.get('n')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# FILLED된 SL 감지
|
||||||
|
if order.get("x") == "TRADE" and order.get("X") == "FILLED":
|
||||||
|
ot = order.get("ot", "")
|
||||||
|
if ot == "STOP_MARKET":
|
||||||
|
logger.success(
|
||||||
|
f"[검증2] SL FILLED 확인! "
|
||||||
|
f"o={order.get('o')}, ot={ot}, "
|
||||||
|
f"orderId={order.get('i')}, "
|
||||||
|
f"exit_price={order.get('ap')}, rp={order.get('rp')}"
|
||||||
|
)
|
||||||
|
sl_triggered.set()
|
||||||
|
|
||||||
|
# UDS 연결
|
||||||
|
client = await AsyncClient.create(
|
||||||
|
api_key=api_key,
|
||||||
|
api_secret=api_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
bm = BinanceSocketManager(client)
|
||||||
|
async with bm.futures_user_socket() as stream:
|
||||||
|
logger.info("UDS 연결 완료")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = await asyncio.wait_for(stream.recv(), timeout=1.0)
|
||||||
|
await on_uds_message(msg)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if sl_triggered.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
# ── Step 5: TP 취소 검증 ──
|
||||||
|
cancel_id = tp_order_id or tp_algo_id
|
||||||
|
logger.info(f"[검증3] TP 취소 시도: futures_cancel_order(orderId={cancel_id})")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cancel_result = await exchange.cancel_order(cancel_id)
|
||||||
|
logger.success(f"[검증3] TP 취소 성공:\n{json.dumps(cancel_result, indent=2)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[검증3] TP 취소 실패: {e}")
|
||||||
|
|
||||||
|
# cancel_all_orders 폴백
|
||||||
|
logger.info("[검증3] cancel_all_orders 폴백 시도")
|
||||||
|
try:
|
||||||
|
fallback_result = await exchange.cancel_all_orders()
|
||||||
|
logger.success(f"[검증3] cancel_all_orders 결과: {fallback_result}")
|
||||||
|
except Exception as e2:
|
||||||
|
logger.error(f"[검증3] cancel_all_orders도 실패: {e2}")
|
||||||
|
|
||||||
|
# 최종 오픈 주문 확인
|
||||||
|
remaining = await exchange.get_open_orders()
|
||||||
|
if remaining:
|
||||||
|
logger.warning(f"[검증3] 잔여 오픈 주문 {len(remaining)}개:")
|
||||||
|
for o in remaining:
|
||||||
|
logger.warning(f" id={o.get('orderId')}, type={o.get('type')}, status={o.get('status')}")
|
||||||
|
else:
|
||||||
|
logger.success("[검증3] 잔여 오픈 주문 없음 — 고아주문 없음 확인!")
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("중단 — 잔여 주문 정리 중...")
|
||||||
|
try:
|
||||||
|
await exchange.cancel_all_orders()
|
||||||
|
logger.info("잔여 주문 전체 취소 완료")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"잔여 주문 취소 실패: {e}")
|
||||||
|
finally:
|
||||||
|
await client.close_connection()
|
||||||
|
|
||||||
|
# ── 결과 요약 ──
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("검증 결과 요약")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info(f"[1] SL orderId={sl_order_id}, algoId={sl_algo_id}")
|
||||||
|
logger.info(f"[1] TP orderId={tp_order_id}, algoId={tp_algo_id}")
|
||||||
|
logger.info(f"[2] SL 트리거 감지: {'YES' if sl_triggered.is_set() else 'NO (타임아웃/중단)'}")
|
||||||
|
logger.info(f"[3] 위 로그에서 TP 취소 성공 여부 확인")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -30,10 +30,10 @@ from src.notifier import DiscordNotifier
|
|||||||
|
|
||||||
|
|
||||||
# ── 프로덕션 파라미터 ──────────────────────────────────────────────
|
# ── 프로덕션 파라미터 ──────────────────────────────────────────────
|
||||||
SYMBOLS = ["XRPUSDT", "TRXUSDT", "DOGEUSDT"]
|
SYMBOLS = ["XRPUSDT"]
|
||||||
PROD_PARAMS = {
|
PROD_PARAMS = {
|
||||||
"atr_sl_mult": 2.0,
|
"atr_sl_mult": 1.5,
|
||||||
"atr_tp_mult": 2.0,
|
"atr_tp_mult": 4.0,
|
||||||
"signal_threshold": 3,
|
"signal_threshold": 3,
|
||||||
"adx_threshold": 25,
|
"adx_threshold": 25,
|
||||||
"volume_multiplier": 2.5,
|
"volume_multiplier": 2.5,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ def validate(trades: list[dict], summary: dict, cfg) -> dict:
|
|||||||
results: list[CheckResult] = []
|
results: list[CheckResult] = []
|
||||||
|
|
||||||
# 검증 1: 논리적 불변 조건
|
# 검증 1: 논리적 불변 조건
|
||||||
results.extend(_check_invariants(trades))
|
results.extend(_check_invariants(trades, cfg))
|
||||||
|
|
||||||
# 검증 2: 통계적 이상 감지
|
# 검증 2: 통계적 이상 감지
|
||||||
results.extend(_check_statistics(trades, summary))
|
results.extend(_check_statistics(trades, summary))
|
||||||
@@ -47,7 +47,7 @@ def validate(trades: list[dict], summary: dict, cfg) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _check_invariants(trades: list[dict]) -> list[CheckResult]:
|
def _check_invariants(trades: list[dict], cfg=None) -> list[CheckResult]:
|
||||||
"""논리적 불변 조건. 하나라도 위반 시 FAIL."""
|
"""논리적 불변 조건. 하나라도 위반 시 FAIL."""
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ def _check_invariants(trades: list[dict]) -> list[CheckResult]:
|
|||||||
))
|
))
|
||||||
|
|
||||||
# 5. 잔고가 음수가 된 적 없음
|
# 5. 잔고가 음수가 된 적 없음
|
||||||
balance = 1000.0 # cfg.initial_balance를 몰라도 trades에서 추적 가능
|
balance = cfg.initial_balance if cfg is not None else 1000.0
|
||||||
min_balance = balance
|
min_balance = balance
|
||||||
for t in trades:
|
for t in trades:
|
||||||
balance += t["net_pnl"]
|
balance += t["net_pnl"]
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ class Position:
|
|||||||
|
|
||||||
# ── 동기 RiskManager ─────────────────────────────────────────────────
|
# ── 동기 RiskManager ─────────────────────────────────────────────────
|
||||||
class BacktestRiskManager:
|
class BacktestRiskManager:
|
||||||
|
# Kill Switch 상수 (bot.py와 동일)
|
||||||
|
_FAST_KILL_STREAK = 8
|
||||||
|
_SLOW_KILL_WINDOW = 15
|
||||||
|
_SLOW_KILL_PF_THRESHOLD = 0.75
|
||||||
|
|
||||||
def __init__(self, cfg: BacktestConfig):
|
def __init__(self, cfg: BacktestConfig):
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
self.daily_pnl: float = 0.0
|
self.daily_pnl: float = 0.0
|
||||||
@@ -151,6 +156,8 @@ class BacktestRiskManager:
|
|||||||
self.base_balance: float = cfg.initial_balance
|
self.base_balance: float = cfg.initial_balance
|
||||||
self.open_positions: dict[str, str] = {} # {symbol: side}
|
self.open_positions: dict[str, str] = {} # {symbol: side}
|
||||||
self._current_date: str | None = None
|
self._current_date: str | None = None
|
||||||
|
self._trade_history: list[float] = [] # 최근 net_pnl 기록
|
||||||
|
self._killed: bool = False
|
||||||
|
|
||||||
def new_day(self, date_str: str):
|
def new_day(self, date_str: str):
|
||||||
if self._current_date != date_str:
|
if self._current_date != date_str:
|
||||||
@@ -158,12 +165,31 @@ class BacktestRiskManager:
|
|||||||
self.daily_pnl = 0.0
|
self.daily_pnl = 0.0
|
||||||
|
|
||||||
def is_trading_allowed(self) -> bool:
|
def is_trading_allowed(self) -> bool:
|
||||||
|
if self._killed:
|
||||||
|
return False
|
||||||
if self.initial_balance <= 0:
|
if self.initial_balance <= 0:
|
||||||
return True
|
return True
|
||||||
if self.daily_pnl < 0 and abs(self.daily_pnl) / self.initial_balance >= self.cfg.max_daily_loss_pct:
|
if self.daily_pnl < 0 and abs(self.daily_pnl) / self.initial_balance >= self.cfg.max_daily_loss_pct:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def record_trade(self, net_pnl: float):
|
||||||
|
"""거래 기록 후 Kill Switch 검사."""
|
||||||
|
self._trade_history.append(net_pnl)
|
||||||
|
# Fast Kill: 8연속 순손실
|
||||||
|
if len(self._trade_history) >= self._FAST_KILL_STREAK:
|
||||||
|
recent = self._trade_history[-self._FAST_KILL_STREAK:]
|
||||||
|
if all(p < 0 for p in recent):
|
||||||
|
self._killed = True
|
||||||
|
return
|
||||||
|
# Slow Kill: 최근 15거래 PF < 0.75
|
||||||
|
if len(self._trade_history) >= self._SLOW_KILL_WINDOW:
|
||||||
|
recent = self._trade_history[-self._SLOW_KILL_WINDOW:]
|
||||||
|
gross_profit = sum(p for p in recent if p > 0)
|
||||||
|
gross_loss = abs(sum(p for p in recent if p < 0))
|
||||||
|
if gross_loss > 0 and gross_profit / gross_loss < self._SLOW_KILL_PF_THRESHOLD:
|
||||||
|
self._killed = True
|
||||||
|
|
||||||
def can_open(self, symbol: str, side: str) -> bool:
|
def can_open(self, symbol: str, side: str) -> bool:
|
||||||
if len(self.open_positions) >= self.cfg.max_positions:
|
if len(self.open_positions) >= self.cfg.max_positions:
|
||||||
return False
|
return False
|
||||||
@@ -180,6 +206,7 @@ class BacktestRiskManager:
|
|||||||
def close(self, symbol: str, pnl: float):
|
def close(self, symbol: str, pnl: float):
|
||||||
self.open_positions.pop(symbol, None)
|
self.open_positions.pop(symbol, None)
|
||||||
self.daily_pnl += pnl
|
self.daily_pnl += pnl
|
||||||
|
self.record_trade(pnl)
|
||||||
|
|
||||||
def get_dynamic_margin_ratio(self, balance: float) -> float:
|
def get_dynamic_margin_ratio(self, balance: float) -> float:
|
||||||
ratio = self.cfg.margin_max_ratio - (
|
ratio = self.cfg.margin_max_ratio - (
|
||||||
@@ -317,16 +344,9 @@ class Backtester:
|
|||||||
self.ml_filters = {}
|
self.ml_filters = {}
|
||||||
for sym in self.cfg.symbols:
|
for sym in self.cfg.symbols:
|
||||||
if sym in ml_models and ml_models[sym] is not None:
|
if sym in ml_models and ml_models[sym] is not None:
|
||||||
mf = MLFilter.__new__(MLFilter)
|
self.ml_filters[sym] = MLFilter.from_model(
|
||||||
mf._disabled = False
|
ml_models[sym], threshold=self.cfg.ml_threshold
|
||||||
mf._onnx_session = None
|
)
|
||||||
mf._lgbm_model = ml_models[sym]
|
|
||||||
mf._threshold = self.cfg.ml_threshold
|
|
||||||
mf._onnx_path = Path("/dev/null")
|
|
||||||
mf._lgbm_path = Path("/dev/null")
|
|
||||||
mf._loaded_onnx_mtime = 0.0
|
|
||||||
mf._loaded_lgbm_mtime = 0.0
|
|
||||||
self.ml_filters[sym] = mf
|
|
||||||
else:
|
else:
|
||||||
self.ml_filters[sym] = None
|
self.ml_filters[sym] = None
|
||||||
|
|
||||||
@@ -335,6 +355,7 @@ class Backtester:
|
|||||||
logger.info(f"총 이벤트: {len(events):,}개")
|
logger.info(f"총 이벤트: {len(events):,}개")
|
||||||
|
|
||||||
# 메인 루프
|
# 메인 루프
|
||||||
|
latest_prices: dict[str, float] = {}
|
||||||
for ts, sym, candle_idx in events:
|
for ts, sym, candle_idx in events:
|
||||||
date_str = str(ts.date())
|
date_str = str(ts.date())
|
||||||
self.risk.new_day(date_str)
|
self.risk.new_day(date_str)
|
||||||
@@ -342,9 +363,10 @@ class Backtester:
|
|||||||
df_ind = all_indicators[sym]
|
df_ind = all_indicators[sym]
|
||||||
signal = all_signals[sym][candle_idx]
|
signal = all_signals[sym][candle_idx]
|
||||||
row = df_ind.iloc[candle_idx]
|
row = df_ind.iloc[candle_idx]
|
||||||
|
latest_prices[sym] = float(row["close"])
|
||||||
|
|
||||||
# 에퀴티 기록
|
# 에퀴티 기록
|
||||||
self._record_equity(ts)
|
self._record_equity(ts, current_prices=latest_prices)
|
||||||
|
|
||||||
# 1) 일일 손실 체크
|
# 1) 일일 손실 체크
|
||||||
if not self.risk.is_trading_allowed():
|
if not self.risk.is_trading_allowed():
|
||||||
@@ -568,12 +590,15 @@ class Backtester:
|
|||||||
}
|
}
|
||||||
self.trades.append(trade)
|
self.trades.append(trade)
|
||||||
|
|
||||||
def _record_equity(self, ts: pd.Timestamp):
|
def _record_equity(self, ts: pd.Timestamp, current_prices: dict[str, float] | None = None):
|
||||||
# 미실현 PnL 포함 에퀴티
|
|
||||||
unrealized = 0.0
|
unrealized = 0.0
|
||||||
for pos in self.positions.values():
|
for sym, pos in self.positions.items():
|
||||||
# 에퀴티 기록 시점에는 현재가를 알 수 없으므로 entry_price 기준으로 0 처리
|
price = (current_prices or {}).get(sym)
|
||||||
pass
|
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
|
equity = self.balance + unrealized
|
||||||
self.equity_curve.append({"timestamp": str(ts), "equity": round(equity, 4)})
|
self.equity_curve.append({"timestamp": str(ts), "equity": round(equity, 4)})
|
||||||
if equity > self._peak_equity:
|
if equity > self._peak_equity:
|
||||||
@@ -600,7 +625,7 @@ class WalkForwardConfig(BacktestConfig):
|
|||||||
train_months: int = 6 # 학습 윈도우 (개월)
|
train_months: int = 6 # 학습 윈도우 (개월)
|
||||||
test_months: int = 1 # 검증 윈도우 (개월)
|
test_months: int = 1 # 검증 윈도우 (개월)
|
||||||
time_weight_decay: float = 2.0
|
time_weight_decay: float = 2.0
|
||||||
negative_ratio: int = 5
|
negative_ratio: int = 3
|
||||||
|
|
||||||
|
|
||||||
class WalkForwardBacktester:
|
class WalkForwardBacktester:
|
||||||
@@ -676,6 +701,8 @@ class WalkForwardBacktester:
|
|||||||
"fold": i + 1,
|
"fold": i + 1,
|
||||||
"train_period": f"{train_start.date()} ~ {train_end.date()}",
|
"train_period": f"{train_start.date()} ~ {train_end.date()}",
|
||||||
"test_period": f"{test_start.date()} ~ {test_end.date()}",
|
"test_period": f"{test_start.date()} ~ {test_end.date()}",
|
||||||
|
"test_start": test_start.isoformat(),
|
||||||
|
"test_end": test_end.isoformat(),
|
||||||
"summary": result["summary"],
|
"summary": result["summary"],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -743,6 +770,8 @@ class WalkForwardBacktester:
|
|||||||
signal_threshold=self.cfg.signal_threshold,
|
signal_threshold=self.cfg.signal_threshold,
|
||||||
adx_threshold=self.cfg.adx_threshold,
|
adx_threshold=self.cfg.adx_threshold,
|
||||||
volume_multiplier=self.cfg.volume_multiplier,
|
volume_multiplier=self.cfg.volume_multiplier,
|
||||||
|
atr_sl_mult=self.cfg.atr_sl_mult,
|
||||||
|
atr_tp_mult=self.cfg.atr_tp_mult,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f" [{symbol}] 데이터셋 생성 실패: {e}")
|
logger.warning(f" [{symbol}] 데이터셋 생성 실패: {e}")
|
||||||
|
|||||||
140
src/bot.py
140
src/bot.py
@@ -58,7 +58,7 @@ class TradingBot:
|
|||||||
self.symbol = symbol or config.symbol
|
self.symbol = symbol or config.symbol
|
||||||
self.strategy = config.get_symbol_params(self.symbol)
|
self.strategy = config.get_symbol_params(self.symbol)
|
||||||
self.exchange = BinanceFuturesClient(config, symbol=self.symbol)
|
self.exchange = BinanceFuturesClient(config, symbol=self.symbol)
|
||||||
self.notifier = DiscordNotifier(config.discord_webhook_url)
|
self.notifier = DiscordNotifier(config.discord_webhook_url, testnet=config.testnet)
|
||||||
self.risk = risk or RiskManager(config)
|
self.risk = risk or RiskManager(config)
|
||||||
# 심볼별 모델 디렉토리. 없으면 기존 models/ 루트로 폴백
|
# 심볼별 모델 디렉토리. 없으면 기존 models/ 루트로 폴백
|
||||||
symbol_model_dir = Path(f"models/{self.symbol.lower()}")
|
symbol_model_dir = Path(f"models/{self.symbol.lower()}")
|
||||||
@@ -78,6 +78,10 @@ class TradingBot:
|
|||||||
self._entry_quantity: float | None = None
|
self._entry_quantity: float | None = None
|
||||||
self._is_reentering: bool = False # _close_and_reenter 중 콜백 상태 초기화 방지
|
self._is_reentering: bool = False # _close_and_reenter 중 콜백 상태 초기화 방지
|
||||||
self._entry_time_ms: int | None = None # 포지션 진입 시각 (ms, SYNC PnL 범위 제한용)
|
self._entry_time_ms: int | None = None # 포지션 진입 시각 (ms, SYNC PnL 범위 제한용)
|
||||||
|
self._sl_order_id: int | None = None # SL 주문 ID (고아 주문 취소용)
|
||||||
|
self._tp_order_id: int | None = None # TP 주문 ID (고아 주문 취소용)
|
||||||
|
self._sl_price: float | None = None # SL 가격 (가격 기반 close_reason 판별용)
|
||||||
|
self._tp_price: float | None = None # TP 가격 (가격 기반 close_reason 판별용)
|
||||||
self._close_event = asyncio.Event() # 콜백 청산 완료 대기용
|
self._close_event = asyncio.Event() # 콜백 청산 완료 대기용
|
||||||
self._close_lock = asyncio.Lock() # 청산 처리 원자성 보장 (C3 fix)
|
self._close_lock = asyncio.Lock() # 청산 처리 원자성 보장 (C3 fix)
|
||||||
self._prev_oi: float | None = None # OI 변화율 계산용 이전 값
|
self._prev_oi: float | None = None # OI 변화율 계산용 이전 값
|
||||||
@@ -88,7 +92,7 @@ class TradingBot:
|
|||||||
self._trade_history: list[dict] = [] # 최근 거래 이력 (net_pnl 기록)
|
self._trade_history: list[dict] = [] # 최근 거래 이력 (net_pnl 기록)
|
||||||
self.stream = MultiSymbolStream(
|
self.stream = MultiSymbolStream(
|
||||||
symbols=[self.symbol] + config.correlation_symbols,
|
symbols=[self.symbol] + config.correlation_symbols,
|
||||||
interval="15m",
|
interval=config.kline_interval,
|
||||||
on_candle=self._on_candle_closed,
|
on_candle=self._on_candle_closed,
|
||||||
)
|
)
|
||||||
# 부팅 시 거래 이력 복원 및 킬스위치 소급 검증
|
# 부팅 시 거래 이력 복원 및 킬스위치 소급 검증
|
||||||
@@ -98,7 +102,11 @@ class TradingBot:
|
|||||||
# ── 킬스위치 ──────────────────────────────────────────────────────
|
# ── 킬스위치 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _trade_history_path(self) -> Path:
|
def _trade_history_path(self) -> Path:
|
||||||
return _TRADE_HISTORY_DIR / f"{self.symbol.lower()}.jsonl"
|
base = _TRADE_HISTORY_DIR
|
||||||
|
if self.config.testnet:
|
||||||
|
base = base / "testnet"
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
return base / f"{self.symbol.lower()}.jsonl"
|
||||||
|
|
||||||
def _restore_trade_history(self) -> None:
|
def _restore_trade_history(self) -> None:
|
||||||
"""부팅 시 파일 마지막 N줄만 읽어 거래 이력을 복원한다.
|
"""부팅 시 파일 마지막 N줄만 읽어 거래 이력을 복원한다.
|
||||||
@@ -232,6 +240,13 @@ class TradingBot:
|
|||||||
open_orders = await self.exchange.get_open_orders()
|
open_orders = await self.exchange.get_open_orders()
|
||||||
has_sl = any(o.get("type") == "STOP_MARKET" for o in open_orders)
|
has_sl = any(o.get("type") == "STOP_MARKET" for o in open_orders)
|
||||||
has_tp = any(o.get("type") == "TAKE_PROFIT_MARKET" for o in open_orders)
|
has_tp = any(o.get("type") == "TAKE_PROFIT_MARKET" for o in open_orders)
|
||||||
|
# 오픈 주문에서 SL/TP 가격 복원 (가격 기반 close_reason 판별용)
|
||||||
|
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))
|
||||||
if has_sl and has_tp:
|
if has_sl and has_tp:
|
||||||
return
|
return
|
||||||
missing = []
|
missing = []
|
||||||
@@ -327,6 +342,23 @@ class TradingBot:
|
|||||||
return change
|
return change
|
||||||
|
|
||||||
async def process_candle(self, df, btc_df=None, eth_df=None):
|
async def process_candle(self, df, btc_df=None, eth_df=None):
|
||||||
|
# Demo 모드: 시그널/필터 전부 우회, 포지션 없을 때만 1회 LONG 진입 (UDS 검증용)
|
||||||
|
if self.config.testnet:
|
||||||
|
ind = Indicators(df)
|
||||||
|
df_with_indicators = ind.calculate_all()
|
||||||
|
current_price = df_with_indicators["close"].iloc[-1]
|
||||||
|
# 로컬 상태 + 바이낸스 포지션 모두 체크
|
||||||
|
if self.current_trade_side is not None:
|
||||||
|
logger.info(f"[{self.symbol}] [DEMO] 포지션 보유 중 (로컬) — SL/TP 대기 | 현재가: {current_price:.4f}")
|
||||||
|
return
|
||||||
|
position = await self.exchange.get_position()
|
||||||
|
if position is not None:
|
||||||
|
logger.info(f"[{self.symbol}] [DEMO] 포지션 보유 중 (바이낸스) — SL/TP 대기 | 현재가: {current_price:.4f}")
|
||||||
|
return
|
||||||
|
logger.info(f"[{self.symbol}] [DEMO] 강제 LONG 진입 | 현재가: {current_price:.4f}")
|
||||||
|
await self._open_position("LONG", df_with_indicators)
|
||||||
|
return
|
||||||
|
|
||||||
self.ml_filter.check_and_reload()
|
self.ml_filter.check_and_reload()
|
||||||
|
|
||||||
# 가격 수익률 계산 (oi_price_spread용)
|
# 가격 수익률 계산 (oi_price_spread용)
|
||||||
@@ -377,6 +409,8 @@ class TradingBot:
|
|||||||
self.current_trade_side = None
|
self.current_trade_side = None
|
||||||
self._entry_price = None
|
self._entry_price = None
|
||||||
self._entry_quantity = None
|
self._entry_quantity = None
|
||||||
|
self._sl_price = None
|
||||||
|
self._tp_price = None
|
||||||
if not await self.risk.can_open_new_position(self.symbol, raw_signal):
|
if not await self.risk.can_open_new_position(self.symbol, raw_signal):
|
||||||
logger.info(f"[{self.symbol}] 포지션 오픈 불가")
|
logger.info(f"[{self.symbol}] 포지션 오픈 불가")
|
||||||
return
|
return
|
||||||
@@ -418,15 +452,26 @@ class TradingBot:
|
|||||||
balance=per_symbol_balance, price=price, leverage=self.config.leverage, margin_ratio=margin_ratio
|
balance=per_symbol_balance, price=price, leverage=self.config.leverage, margin_ratio=margin_ratio
|
||||||
)
|
)
|
||||||
logger.info(f"[{self.symbol}] 포지션 크기: 잔고={per_symbol_balance:.2f}/{balance:.2f} USDT, 증거금비율={margin_ratio:.1%}, 수량={quantity}")
|
logger.info(f"[{self.symbol}] 포지션 크기: 잔고={per_symbol_balance:.2f}/{balance:.2f} USDT, 증거금비율={margin_ratio:.1%}, 수량={quantity}")
|
||||||
# df는 이미 calculate_all() 적용된 df_with_indicators이므로
|
# Demo 모드: 고정 퍼센트 SL/TP (ATR이 너무 작아 즉시 트리거 방지)
|
||||||
# Indicators를 재생성하지 않고 ATR을 직접 사용
|
if self.config.testnet:
|
||||||
atr = df["atr"].iloc[-1]
|
sl_pct = 0.005 # 0.5%
|
||||||
if signal == "LONG":
|
tp_pct = 0.02
|
||||||
stop_loss = price - atr * self.strategy.atr_sl_mult
|
if signal == "LONG":
|
||||||
take_profit = price + atr * self.strategy.atr_tp_mult
|
stop_loss = price * (1 - sl_pct)
|
||||||
|
take_profit = price * (1 + tp_pct)
|
||||||
|
else:
|
||||||
|
stop_loss = price * (1 + sl_pct)
|
||||||
|
take_profit = price * (1 - tp_pct)
|
||||||
else:
|
else:
|
||||||
stop_loss = price + atr * self.strategy.atr_sl_mult
|
# df는 이미 calculate_all() 적용된 df_with_indicators이므로
|
||||||
take_profit = price - atr * self.strategy.atr_tp_mult
|
# Indicators를 재생성하지 않고 ATR을 직접 사용
|
||||||
|
atr = df["atr"].iloc[-1]
|
||||||
|
if signal == "LONG":
|
||||||
|
stop_loss = price - atr * self.strategy.atr_sl_mult
|
||||||
|
take_profit = price + atr * self.strategy.atr_tp_mult
|
||||||
|
else:
|
||||||
|
stop_loss = price + atr * self.strategy.atr_sl_mult
|
||||||
|
take_profit = price - atr * self.strategy.atr_tp_mult
|
||||||
|
|
||||||
notional = quantity * price
|
notional = quantity * price
|
||||||
if quantity <= 0 or notional < self.exchange.MIN_NOTIONAL:
|
if quantity <= 0 or notional < self.exchange.MIN_NOTIONAL:
|
||||||
@@ -437,6 +482,7 @@ class TradingBot:
|
|||||||
return
|
return
|
||||||
|
|
||||||
side = "BUY" if signal == "LONG" else "SELL"
|
side = "BUY" if signal == "LONG" else "SELL"
|
||||||
|
await self.exchange.set_margin_type("ISOLATED")
|
||||||
await self.exchange.set_leverage(self.config.leverage)
|
await self.exchange.set_leverage(self.config.leverage)
|
||||||
await self.exchange.place_order(side=side, quantity=quantity)
|
await self.exchange.place_order(side=side, quantity=quantity)
|
||||||
|
|
||||||
@@ -452,6 +498,8 @@ class TradingBot:
|
|||||||
self._entry_price = price
|
self._entry_price = price
|
||||||
self._entry_quantity = quantity
|
self._entry_quantity = quantity
|
||||||
self._entry_time_ms = int(time.time() * 1000)
|
self._entry_time_ms = int(time.time() * 1000)
|
||||||
|
self._sl_price = stop_loss
|
||||||
|
self._tp_price = take_profit
|
||||||
self.notifier.notify_open(
|
self.notifier.notify_open(
|
||||||
symbol=self.symbol,
|
symbol=self.symbol,
|
||||||
side=signal,
|
side=signal,
|
||||||
@@ -494,22 +542,26 @@ class TradingBot:
|
|||||||
for attempt in range(1, self._SL_TP_MAX_RETRIES + 1):
|
for attempt in range(1, self._SL_TP_MAX_RETRIES + 1):
|
||||||
try:
|
try:
|
||||||
if not sl_placed:
|
if not sl_placed:
|
||||||
await self.exchange.place_order(
|
sl_result = await self.exchange.place_order(
|
||||||
side=sl_side,
|
side=sl_side,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
order_type="STOP_MARKET",
|
order_type="STOP_MARKET",
|
||||||
stop_price=self.exchange._round_price(stop_loss),
|
stop_price=self.exchange._round_price(stop_loss),
|
||||||
reduce_only=True,
|
reduce_only=True,
|
||||||
)
|
)
|
||||||
|
self._sl_order_id = sl_result.get("orderId") or sl_result.get("algoId")
|
||||||
|
logger.info(f"[{self.symbol}] SL 주문 배치: id={self._sl_order_id}")
|
||||||
sl_placed = True
|
sl_placed = True
|
||||||
if not tp_placed:
|
if not tp_placed:
|
||||||
await self.exchange.place_order(
|
tp_result = await self.exchange.place_order(
|
||||||
side=sl_side,
|
side=sl_side,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
order_type="TAKE_PROFIT_MARKET",
|
order_type="TAKE_PROFIT_MARKET",
|
||||||
stop_price=self.exchange._round_price(take_profit),
|
stop_price=self.exchange._round_price(take_profit),
|
||||||
reduce_only=True,
|
reduce_only=True,
|
||||||
)
|
)
|
||||||
|
self._tp_order_id = tp_result.get("orderId") or tp_result.get("algoId")
|
||||||
|
logger.info(f"[{self.symbol}] TP 주문 배치: id={self._tp_order_id}")
|
||||||
tp_placed = True
|
tp_placed = True
|
||||||
return # 둘 다 성공
|
return # 둘 다 성공
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -534,6 +586,8 @@ class TradingBot:
|
|||||||
self.current_trade_side = None
|
self.current_trade_side = None
|
||||||
self._entry_price = None
|
self._entry_price = None
|
||||||
self._entry_quantity = None
|
self._entry_quantity = None
|
||||||
|
self._sl_price = None
|
||||||
|
self._tp_price = None
|
||||||
self.notifier.notify_info(
|
self.notifier.notify_info(
|
||||||
f"🚨 [{self.symbol}] SL/TP 배치 실패 → 긴급 청산 완료"
|
f"🚨 [{self.symbol}] SL/TP 배치 실패 → 긴급 청산 완료"
|
||||||
)
|
)
|
||||||
@@ -546,6 +600,28 @@ class TradingBot:
|
|||||||
f"🔴 [{self.symbol}] 긴급 청산 실패! 수동 청산 필요: {e}"
|
f"🔴 [{self.symbol}] 긴급 청산 실패! 수동 청산 필요: {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _cancel_remaining_orders(self, reason: str = "") -> None:
|
||||||
|
"""잔여 SL/TP 고아 주문을 저장된 주문 ID로 직접 취소한다."""
|
||||||
|
ctx = f" ({reason})" if reason else ""
|
||||||
|
cancelled = 0
|
||||||
|
for label, oid in [("SL", self._sl_order_id), ("TP", self._tp_order_id)]:
|
||||||
|
if oid is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result = await self.exchange.cancel_order(oid)
|
||||||
|
logger.info(
|
||||||
|
f"[{self.symbol}] {label} 주문 취소 완료{ctx}: "
|
||||||
|
f"id={oid} → status={result.get('status', 'N/A')}"
|
||||||
|
)
|
||||||
|
cancelled += 1
|
||||||
|
except Exception as e:
|
||||||
|
# 이미 체결/취소된 주문이면 무시
|
||||||
|
logger.debug(f"[{self.symbol}] {label} 주문 취소 스킵{ctx}: id={oid}: {e}")
|
||||||
|
self._sl_order_id = None
|
||||||
|
self._tp_order_id = None
|
||||||
|
if cancelled == 0:
|
||||||
|
logger.info(f"[{self.symbol}] 취소할 잔여 주문 없음{ctx}")
|
||||||
|
|
||||||
def _calc_estimated_pnl(self, exit_price: float) -> float:
|
def _calc_estimated_pnl(self, exit_price: float) -> float:
|
||||||
"""진입가·수량 기반 예상 PnL 계산 (수수료 미반영)."""
|
"""진입가·수량 기반 예상 PnL 계산 (수수료 미반영)."""
|
||||||
if self._entry_price is None or self._entry_quantity is None or self.current_trade_side is None:
|
if self._entry_price is None or self._entry_quantity is None or self.current_trade_side is None:
|
||||||
@@ -568,6 +644,17 @@ class TradingBot:
|
|||||||
self._close_event.set()
|
self._close_event.set()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 실전 API에서 algo order는 ot=MARKET로 오므로 MANUAL로 판별됨
|
||||||
|
# → SL/TP 가격과 exit_price 비교로 재판별
|
||||||
|
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)
|
||||||
|
close_reason = "SL" if sl_dist < tp_dist else "TP"
|
||||||
|
logger.info(
|
||||||
|
f"[{self.symbol}] close_reason 재판별: MANUAL → {close_reason} "
|
||||||
|
f"(exit={exit_price:.4f}, SL={self._sl_price:.4f}, TP={self._tp_price:.4f})"
|
||||||
|
)
|
||||||
|
|
||||||
estimated_pnl = self._calc_estimated_pnl(exit_price)
|
estimated_pnl = self._calc_estimated_pnl(exit_price)
|
||||||
diff = net_pnl - estimated_pnl
|
diff = net_pnl - estimated_pnl
|
||||||
|
|
||||||
@@ -599,11 +686,16 @@ class TradingBot:
|
|||||||
if self._is_reentering:
|
if self._is_reentering:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 잔여 SL/TP 고아 주문 취소
|
||||||
|
await self._cancel_remaining_orders("UDS 청산 콜백")
|
||||||
|
|
||||||
# Flat 상태로 초기화
|
# Flat 상태로 초기화
|
||||||
self.current_trade_side = None
|
self.current_trade_side = None
|
||||||
self._entry_price = None
|
self._entry_price = None
|
||||||
self._entry_quantity = None
|
self._entry_quantity = None
|
||||||
self._entry_time_ms = None
|
self._entry_time_ms = None
|
||||||
|
self._sl_price = None
|
||||||
|
self._tp_price = None
|
||||||
|
|
||||||
_MONITOR_INTERVAL = 300 # 5분
|
_MONITOR_INTERVAL = 300 # 5분
|
||||||
|
|
||||||
@@ -665,10 +757,14 @@ class TradingBot:
|
|||||||
)
|
)
|
||||||
self._append_trade(net_pnl, "SYNC")
|
self._append_trade(net_pnl, "SYNC")
|
||||||
self._check_kill_switch()
|
self._check_kill_switch()
|
||||||
|
# 잔여 SL/TP 주문 취소
|
||||||
|
await self._cancel_remaining_orders("SYNC 폴백")
|
||||||
self.current_trade_side = None
|
self.current_trade_side = None
|
||||||
self._entry_price = None
|
self._entry_price = None
|
||||||
self._entry_quantity = None
|
self._entry_quantity = None
|
||||||
self._entry_time_ms = None
|
self._entry_time_ms = None
|
||||||
|
self._sl_price = None
|
||||||
|
self._tp_price = None
|
||||||
self._close_event.set()
|
self._close_event.set()
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -727,6 +823,11 @@ class TradingBot:
|
|||||||
self._entry_price = None
|
self._entry_price = None
|
||||||
self._entry_quantity = None
|
self._entry_quantity = None
|
||||||
self._entry_time_ms = None
|
self._entry_time_ms = None
|
||||||
|
self._sl_price = None
|
||||||
|
self._tp_price = None
|
||||||
|
|
||||||
|
# 잔여 SL/TP 주문 취소 확인 (_close_position에서 cancel_all 호출하지만 검증)
|
||||||
|
await self._cancel_remaining_orders("재진입 전 검증")
|
||||||
|
|
||||||
if self._killed:
|
if self._killed:
|
||||||
logger.info(f"[{self.symbol}] 킬스위치 활성 — 재진입 건너뜀 (청산만 수행)")
|
logger.info(f"[{self.symbol}] 킬스위치 활성 — 재진입 건너뜀 (청산만 수행)")
|
||||||
@@ -754,6 +855,9 @@ class TradingBot:
|
|||||||
self._is_reentering = False
|
self._is_reentering = False
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
|
if self.config.testnet:
|
||||||
|
logger.warning("⚠️ TESTNET MODE ENABLED — 실제 자금이 아닌 테스트넷에서 실행 중")
|
||||||
|
|
||||||
s = self.strategy
|
s = self.strategy
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[{self.symbol}] 봇 시작, 레버리지 {self.config.leverage}x | "
|
f"[{self.symbol}] 봇 시작, 레버리지 {self.config.leverage}x | "
|
||||||
@@ -763,6 +867,14 @@ class TradingBot:
|
|||||||
await self._recover_position()
|
await self._recover_position()
|
||||||
await self._init_oi_history()
|
await self._init_oi_history()
|
||||||
|
|
||||||
|
# 봇 시작 시 포지션 없으면 고아 주문 정리 (저장된 ID 없으므로 cancel_all 사용)
|
||||||
|
if self.current_trade_side is None:
|
||||||
|
try:
|
||||||
|
result = await self.exchange.cancel_all_orders()
|
||||||
|
logger.info(f"[{self.symbol}] 봇 시작 — cancel_all_orders 응답: {result}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[{self.symbol}] 봇 시작 — 주문 취소 실패: {e}")
|
||||||
|
|
||||||
user_stream = UserDataStream(
|
user_stream = UserDataStream(
|
||||||
symbol=self.symbol,
|
symbol=self.symbol,
|
||||||
on_order_filled=self._on_position_closed,
|
on_order_filled=self._on_position_closed,
|
||||||
@@ -772,10 +884,12 @@ class TradingBot:
|
|||||||
self.stream.start(
|
self.stream.start(
|
||||||
api_key=self.config.api_key,
|
api_key=self.config.api_key,
|
||||||
api_secret=self.config.api_secret,
|
api_secret=self.config.api_secret,
|
||||||
|
testnet=self.config.testnet,
|
||||||
),
|
),
|
||||||
user_stream.start(
|
user_stream.start(
|
||||||
api_key=self.config.api_key,
|
api_key=self.config.api_key,
|
||||||
api_secret=self.config.api_secret,
|
api_secret=self.config.api_secret,
|
||||||
|
testnet=self.config.testnet,
|
||||||
),
|
),
|
||||||
self._position_monitor(),
|
self._position_monitor(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,10 +35,18 @@ class Config:
|
|||||||
signal_threshold: int = 3
|
signal_threshold: int = 3
|
||||||
adx_threshold: float = 25.0
|
adx_threshold: float = 25.0
|
||||||
volume_multiplier: float = 2.5
|
volume_multiplier: float = 2.5
|
||||||
|
kline_interval: str = "15m"
|
||||||
|
testnet: bool = False
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
self.api_key = os.getenv("BINANCE_API_KEY", "")
|
self.testnet = os.getenv("BINANCE_TESTNET", "").lower() in ("true", "1", "yes")
|
||||||
self.api_secret = os.getenv("BINANCE_API_SECRET", "")
|
|
||||||
|
if self.testnet:
|
||||||
|
self.api_key = os.getenv("BINANCE_DEMO_API_KEY", "")
|
||||||
|
self.api_secret = os.getenv("BINANCE_DEMO_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.symbol = os.getenv("SYMBOL", "XRPUSDT")
|
||||||
self.leverage = int(os.getenv("LEVERAGE", "10"))
|
self.leverage = int(os.getenv("LEVERAGE", "10"))
|
||||||
self.discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL", "")
|
self.discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL", "")
|
||||||
@@ -52,6 +60,7 @@ class Config:
|
|||||||
self.signal_threshold = int(os.getenv("SIGNAL_THRESHOLD", "3"))
|
self.signal_threshold = int(os.getenv("SIGNAL_THRESHOLD", "3"))
|
||||||
self.adx_threshold = float(os.getenv("ADX_THRESHOLD", "25"))
|
self.adx_threshold = float(os.getenv("ADX_THRESHOLD", "25"))
|
||||||
self.volume_multiplier = float(os.getenv("VOL_MULTIPLIER", "2.5"))
|
self.volume_multiplier = float(os.getenv("VOL_MULTIPLIER", "2.5"))
|
||||||
|
self.kline_interval = os.getenv("KLINE_INTERVAL", "15m")
|
||||||
|
|
||||||
# symbols: SYMBOLS 환경변수 우선, 없으면 SYMBOL에서 변환
|
# symbols: SYMBOLS 환경변수 우선, 없으면 SYMBOL에서 변환
|
||||||
symbols_env = os.getenv("SYMBOLS", "")
|
symbols_env = os.getenv("SYMBOLS", "")
|
||||||
|
|||||||
@@ -77,10 +77,11 @@ class KlineStream:
|
|||||||
})
|
})
|
||||||
logger.info(f"과거 캔들 {len(self.buffer)}개 로드 완료 — 즉시 신호 계산 가능")
|
logger.info(f"과거 캔들 {len(self.buffer)}개 로드 완료 — 즉시 신호 계산 가능")
|
||||||
|
|
||||||
async def start(self, api_key: str, api_secret: str):
|
async def start(self, api_key: str, api_secret: str, testnet: bool = False):
|
||||||
client = await AsyncClient.create(
|
client = await AsyncClient.create(
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
api_secret=api_secret,
|
api_secret=api_secret,
|
||||||
|
demo=testnet,
|
||||||
)
|
)
|
||||||
await self._preload_history(client)
|
await self._preload_history(client)
|
||||||
bm = BinanceSocketManager(client)
|
bm = BinanceSocketManager(client)
|
||||||
@@ -189,10 +190,11 @@ class MultiSymbolStream:
|
|||||||
self._preload_one(client, symbol, limit) for symbol in self.symbols
|
self._preload_one(client, symbol, limit) for symbol in self.symbols
|
||||||
])
|
])
|
||||||
|
|
||||||
async def start(self, api_key: str, api_secret: str):
|
async def start(self, api_key: str, api_secret: str, testnet: bool = False):
|
||||||
client = await AsyncClient.create(
|
client = await AsyncClient.create(
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
api_secret=api_secret,
|
api_secret=api_secret,
|
||||||
|
demo=testnet,
|
||||||
)
|
)
|
||||||
await self._preload_history(client)
|
await self._preload_history(client)
|
||||||
bm = BinanceSocketManager(client)
|
bm = BinanceSocketManager(client)
|
||||||
|
|||||||
@@ -12,10 +12,19 @@ import pandas_ta as ta
|
|||||||
from src.ml_features import FEATURE_COLS
|
from src.ml_features import FEATURE_COLS
|
||||||
|
|
||||||
LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 뷰
|
LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 뷰
|
||||||
ATR_SL_MULT = 1.5
|
ATR_SL_MULT = 2.0 # config.py 기본값과 동일 (서빙 환경 일치)
|
||||||
ATR_TP_MULT = 2.0
|
ATR_TP_MULT = 2.0
|
||||||
WARMUP = 60 # 15분봉 기준 60캔들 = 15시간 (지표 안정화 충분)
|
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)
|
||||||
|
|
||||||
|
|
||||||
def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
"""전체 시계열에 기술 지표를 1회 계산한다."""
|
"""전체 시계열에 기술 지표를 1회 계산한다."""
|
||||||
@@ -56,9 +65,9 @@ def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
|||||||
|
|
||||||
def _calc_signals(
|
def _calc_signals(
|
||||||
d: pd.DataFrame,
|
d: pd.DataFrame,
|
||||||
signal_threshold: int = 3,
|
signal_threshold: int = TRAIN_SIGNAL_THRESHOLD,
|
||||||
adx_threshold: float = 25,
|
adx_threshold: float = TRAIN_ADX_THRESHOLD,
|
||||||
volume_multiplier: float = 2.5,
|
volume_multiplier: float = TRAIN_VOLUME_MULTIPLIER,
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
"""
|
"""
|
||||||
indicators.py get_signal() 로직을 numpy 배열 연산으로 재현한다.
|
indicators.py get_signal() 로직을 numpy 배열 연산으로 재현한다.
|
||||||
@@ -323,6 +332,8 @@ def _calc_labels_vectorized(
|
|||||||
d: pd.DataFrame,
|
d: pd.DataFrame,
|
||||||
feat: pd.DataFrame,
|
feat: pd.DataFrame,
|
||||||
sig_idx: np.ndarray,
|
sig_idx: np.ndarray,
|
||||||
|
atr_sl_mult: float = ATR_SL_MULT,
|
||||||
|
atr_tp_mult: float = ATR_TP_MULT,
|
||||||
) -> tuple[np.ndarray, np.ndarray]:
|
) -> tuple[np.ndarray, np.ndarray]:
|
||||||
"""
|
"""
|
||||||
label_builder.py build_labels() 로직을 numpy 2D 배열로 벡터화한다.
|
label_builder.py build_labels() 로직을 numpy 2D 배열로 벡터화한다.
|
||||||
@@ -348,11 +359,11 @@ def _calc_labels_vectorized(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if signal == "LONG":
|
if signal == "LONG":
|
||||||
sl = entry - atr * ATR_SL_MULT
|
sl = entry - atr * atr_sl_mult
|
||||||
tp = entry + atr * ATR_TP_MULT
|
tp = entry + atr * atr_tp_mult
|
||||||
else:
|
else:
|
||||||
sl = entry + atr * ATR_SL_MULT
|
sl = entry + atr * atr_sl_mult
|
||||||
tp = entry - atr * ATR_TP_MULT
|
tp = entry - atr * atr_tp_mult
|
||||||
|
|
||||||
end = min(idx + 1 + LOOKAHEAD, n_total)
|
end = min(idx + 1 + LOOKAHEAD, n_total)
|
||||||
fut_high = highs[idx + 1 : end]
|
fut_high = highs[idx + 1 : end]
|
||||||
@@ -387,10 +398,12 @@ def generate_dataset_vectorized(
|
|||||||
btc_df: pd.DataFrame | None = None,
|
btc_df: pd.DataFrame | None = None,
|
||||||
eth_df: pd.DataFrame | None = None,
|
eth_df: pd.DataFrame | None = None,
|
||||||
time_weight_decay: float = 0.0,
|
time_weight_decay: float = 0.0,
|
||||||
negative_ratio: int = 0,
|
negative_ratio: int = TRAIN_NEGATIVE_RATIO,
|
||||||
signal_threshold: int = 3,
|
signal_threshold: int = TRAIN_SIGNAL_THRESHOLD,
|
||||||
adx_threshold: float = 25,
|
adx_threshold: float = TRAIN_ADX_THRESHOLD,
|
||||||
volume_multiplier: float = 2.5,
|
volume_multiplier: float = TRAIN_VOLUME_MULTIPLIER,
|
||||||
|
atr_sl_mult: float = ATR_SL_MULT,
|
||||||
|
atr_tp_mult: float = ATR_TP_MULT,
|
||||||
) -> pd.DataFrame:
|
) -> pd.DataFrame:
|
||||||
"""
|
"""
|
||||||
전체 시계열을 1회 계산해 학습 데이터셋을 생성한다.
|
전체 시계열을 1회 계산해 학습 데이터셋을 생성한다.
|
||||||
@@ -435,7 +448,10 @@ def generate_dataset_vectorized(
|
|||||||
print(f" 신호 발생 인덱스: {len(sig_idx):,}개")
|
print(f" 신호 발생 인덱스: {len(sig_idx):,}개")
|
||||||
|
|
||||||
print(" [3/3] 레이블 계산...")
|
print(" [3/3] 레이블 계산...")
|
||||||
labels, valid_mask = _calc_labels_vectorized(d, feat_all, sig_idx)
|
labels, valid_mask = _calc_labels_vectorized(
|
||||||
|
d, feat_all, sig_idx,
|
||||||
|
atr_sl_mult=atr_sl_mult, atr_tp_mult=atr_tp_mult,
|
||||||
|
)
|
||||||
|
|
||||||
final_sig_idx = sig_idx[valid_mask]
|
final_sig_idx = sig_idx[valid_mask]
|
||||||
available_feature_cols = [c for c in FEATURE_COLS if c in feat_all.columns]
|
available_feature_cols = [c for c in FEATURE_COLS if c in feat_all.columns]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class BinanceFuturesClient:
|
|||||||
self.client = Client(
|
self.client = Client(
|
||||||
api_key=config.api_key,
|
api_key=config.api_key,
|
||||||
api_secret=config.api_secret,
|
api_secret=config.api_secret,
|
||||||
|
demo=config.testnet,
|
||||||
)
|
)
|
||||||
self._qty_precision: int | None = None
|
self._qty_precision: int | None = None
|
||||||
self._price_precision: int | None = None
|
self._price_precision: int | None = None
|
||||||
@@ -107,6 +108,21 @@ class BinanceFuturesClient:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def set_margin_type(self, margin_type: str = "ISOLATED") -> None:
|
||||||
|
"""마진 타입을 변경한다. 이미 동일 타입이면 무시."""
|
||||||
|
try:
|
||||||
|
await self._run_api(
|
||||||
|
lambda: self.client.futures_change_margin_type(
|
||||||
|
symbol=self.symbol, marginType=margin_type
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logger.info(f"[{self.symbol}] 마진 타입 변경: {margin_type}")
|
||||||
|
except BinanceAPIException as e:
|
||||||
|
if e.code == -4046: # "No need to change margin type."
|
||||||
|
logger.debug(f"[{self.symbol}] 마진 타입 이미 {margin_type}")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
async def get_balance(self) -> float:
|
async def get_balance(self) -> float:
|
||||||
balances = await self._run_api(self.client.futures_account_balance)
|
balances = await self._run_api(self.client.futures_account_balance)
|
||||||
for b in balances:
|
for b in balances:
|
||||||
@@ -155,18 +171,54 @@ class BinanceFuturesClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_open_orders(self) -> list[dict]:
|
async def get_open_orders(self) -> list[dict]:
|
||||||
"""현재 심볼의 오픈 주문 목록을 조회한다."""
|
"""현재 심볼의 오픈 주문 + algo 주문을 병합 반환한다."""
|
||||||
return await self._run_api(
|
orders = await self._run_api(
|
||||||
lambda: self.client.futures_get_open_orders(symbol=self.symbol),
|
lambda: self.client.futures_get_open_orders(symbol=self.symbol),
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
algo_orders = await self._run_api(
|
||||||
|
lambda: self.client.futures_get_open_algo_orders(symbol=self.symbol)
|
||||||
|
)
|
||||||
|
for ao in algo_orders.get("orders", []):
|
||||||
|
orders.append({
|
||||||
|
"orderId": ao.get("algoId"),
|
||||||
|
"type": ao.get("orderType"),
|
||||||
|
"stopPrice": ao.get("triggerPrice"),
|
||||||
|
"side": ao.get("side"),
|
||||||
|
"status": ao.get("algoStatus"),
|
||||||
|
"_is_algo": True,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass # algo 주문 없으면 실패 가능
|
||||||
|
return orders
|
||||||
|
|
||||||
async def cancel_all_orders(self):
|
async def cancel_all_orders(self):
|
||||||
"""오픈 주문을 모두 취소한다."""
|
"""일반 주문 + algo 주문을 모두 취소한다."""
|
||||||
await self._run_api(
|
await self._run_api(
|
||||||
lambda: self.client.futures_cancel_all_open_orders(
|
lambda: self.client.futures_cancel_all_open_orders(
|
||||||
symbol=self.symbol
|
symbol=self.symbol
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
await self._run_api(
|
||||||
|
lambda: self.client.futures_cancel_all_algo_open_orders(symbol=self.symbol)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # algo 주문 없으면 실패 가능
|
||||||
|
|
||||||
|
async def cancel_order(self, order_id: int):
|
||||||
|
"""개별 주문을 취소한다. 일반 주문 실패 시 algo 주문으로 재시도."""
|
||||||
|
try:
|
||||||
|
return await self._run_api(
|
||||||
|
lambda: self.client.futures_cancel_order(
|
||||||
|
symbol=self.symbol, orderId=order_id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Algo order (데모 API의 조건부 주문) 취소 시도
|
||||||
|
return await self._run_api(
|
||||||
|
lambda: self.client.futures_cancel_algo_order(algoId=order_id),
|
||||||
|
)
|
||||||
|
|
||||||
async def get_recent_income(self, limit: int = 5, start_time: int | None = None) -> tuple[list[dict], list[dict]]:
|
async def get_recent_income(self, limit: int = 5, start_time: int | None = None) -> tuple[list[dict], list[dict]]:
|
||||||
"""최근 REALIZED_PNL + COMMISSION 내역을 조회한다.
|
"""최근 REALIZED_PNL + COMMISSION 내역을 조회한다.
|
||||||
|
|||||||
@@ -155,6 +155,21 @@ class MLFilter:
|
|||||||
logger.warning(f"ML 필터 예측 오류 (진입 차단): {e}")
|
logger.warning(f"ML 필터 예측 오류 (진입 차단): {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
def reload_model(self):
|
def reload_model(self):
|
||||||
"""외부에서 강제 리로드할 때 사용 (하위 호환)."""
|
"""외부에서 강제 리로드할 때 사용 (하위 호환)."""
|
||||||
prev_backend = self.active_backend
|
prev_backend = self.active_backend
|
||||||
|
|||||||
@@ -141,18 +141,24 @@ class MLXFilter:
|
|||||||
X: pd.DataFrame,
|
X: pd.DataFrame,
|
||||||
y: pd.Series,
|
y: pd.Series,
|
||||||
sample_weight: np.ndarray | None = None,
|
sample_weight: np.ndarray | None = None,
|
||||||
|
normalize: bool = True,
|
||||||
) -> "MLXFilter":
|
) -> "MLXFilter":
|
||||||
X_np = X[FEATURE_COLS].values.astype(np.float32)
|
X_np = X[FEATURE_COLS].values.astype(np.float32)
|
||||||
y_np = y.values.astype(np.float32)
|
y_np = y.values.astype(np.float32)
|
||||||
|
|
||||||
# nan-safe 정규화: nanmean/nanstd로 통계 계산 후 nan → 0.0 대치
|
if normalize:
|
||||||
# (z-score 후 0.0 = 평균값, 신경망에 줄 수 있는 가장 무난한 결측 대치값)
|
# nan-safe 정규화: nanmean/nanstd로 통계 계산 후 nan → 0.0 대치
|
||||||
mean_vals = np.nanmean(X_np, axis=0)
|
# (z-score 후 0.0 = 평균값, 신경망에 줄 수 있는 가장 무난한 결측 대치값)
|
||||||
self._mean = np.nan_to_num(mean_vals, nan=0.0) # 전체-NaN 컬럼 → 평균 0.0
|
mean_vals = np.nanmean(X_np, axis=0)
|
||||||
std_vals = np.nanstd(X_np, axis=0)
|
self._mean = np.nan_to_num(mean_vals, nan=0.0) # 전체-NaN 컬럼 → 평균 0.0
|
||||||
self._std = np.nan_to_num(std_vals, nan=1.0) + 1e-8 # 전체-NaN 컬럼 → std 1.0
|
std_vals = np.nanstd(X_np, axis=0)
|
||||||
X_np = (X_np - self._mean) / self._std
|
self._std = np.nan_to_num(std_vals, nan=1.0) + 1e-8 # 전체-NaN 컬럼 → std 1.0
|
||||||
X_np = np.nan_to_num(X_np, nan=0.0)
|
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)
|
||||||
|
|
||||||
w_np = sample_weight.astype(np.float32) if sample_weight is not None else None
|
w_np = sample_weight.astype(np.float32) if sample_weight is not None else None
|
||||||
|
|
||||||
|
|||||||
982
src/mtf_bot.py
Normal file
982
src/mtf_bot.py
Normal file
@@ -0,0 +1,982 @@
|
|||||||
|
"""
|
||||||
|
MTF Pullback Bot — Module 1~4
|
||||||
|
──────────────────────────────
|
||||||
|
Module 1: TimeframeSync, DataFetcher (REST 폴링 기반)
|
||||||
|
Module 2: MetaFilter (1h EMA50/200 + ADX + ATR)
|
||||||
|
Module 3: TriggerStrategy (15m Volume-backed Pullback 3캔들 시퀀스)
|
||||||
|
Module 4: ExecutionManager (Dry-run 가상 주문 + SL/TP 관리)
|
||||||
|
|
||||||
|
핵심 원칙:
|
||||||
|
- Look-ahead bias 원천 차단: 완성된 캔들만 사용 ([:-1] 슬라이싱)
|
||||||
|
- Binance 서버 딜레이 고려: 캔들 판별 시 2~5초 range
|
||||||
|
- REST 폴링 기반 안정성: WebSocket 대신 30초 주기 폴링
|
||||||
|
- 메모리 최적화: deque(maxlen=250)
|
||||||
|
- Dry-run 모드: 4월 OOS 검증 기간, 실주문 API 주석 처리
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time as _time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from collections import deque
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, List
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pandas_ta as ta
|
||||||
|
import ccxt.async_support as ccxt
|
||||||
|
from loguru import logger
|
||||||
|
from src.notifier import DiscordNotifier
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Module 1: TimeframeSync
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TimeframeSync:
|
||||||
|
"""현재 시간이 15m/1h 캔들 종료 직후인지 판별 (Binance 서버 딜레이 2~5초 고려)."""
|
||||||
|
|
||||||
|
_15M_MINUTES = {0, 15, 30, 45}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_15m_candle_closed(current_ts: int) -> bool:
|
||||||
|
"""
|
||||||
|
15m 캔들 종료 판별.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_ts: Unix timestamp (밀리초)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if 분(minute)이 [0, 15, 30, 45] 중 하나이고 초(second)가 2~5초 사이
|
||||||
|
"""
|
||||||
|
dt = datetime.fromtimestamp(current_ts / 1000, tz=timezone.utc)
|
||||||
|
return dt.minute in TimeframeSync._15M_MINUTES and 2 <= dt.second <= 5
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_1h_candle_closed(current_ts: int) -> bool:
|
||||||
|
"""
|
||||||
|
1h 캔들 종료 판별.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_ts: Unix timestamp (밀리초)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if 분(minute)이 0이고 초(second)가 2~5초 사이
|
||||||
|
"""
|
||||||
|
dt = datetime.fromtimestamp(current_ts / 1000, tz=timezone.utc)
|
||||||
|
return dt.minute == 0 and 2 <= dt.second <= 5
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Module 1: DataFetcher
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class DataFetcher:
|
||||||
|
"""Binance Futures에서 15m/1h OHLCV 데이터 fetch 및 관리."""
|
||||||
|
|
||||||
|
def __init__(self, symbol: str = "XRP/USDT:USDT"):
|
||||||
|
self.symbol = symbol
|
||||||
|
self.exchange = ccxt.binance({
|
||||||
|
"enableRateLimit": True,
|
||||||
|
"options": {"defaultType": "future"},
|
||||||
|
})
|
||||||
|
self.klines_15m: deque = deque(maxlen=250)
|
||||||
|
self.klines_1h: deque = deque(maxlen=250)
|
||||||
|
self._last_15m_ts: int = 0 # 마지막으로 저장된 15m 캔들 timestamp
|
||||||
|
self._last_1h_ts: int = 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _remove_incomplete_candle(df: pd.DataFrame, interval_sec: int) -> pd.DataFrame:
|
||||||
|
"""미완성(진행 중) 캔들을 조건부로 제거. ccxt timestamp는 ms 단위."""
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
now_ms = int(_time.time() * 1000)
|
||||||
|
current_candle_start_ms = (now_ms // (interval_sec * 1000)) * (interval_sec * 1000)
|
||||||
|
# DataFrame index가 datetime인 경우 원본 timestamp 컬럼이 없으므로 index에서 추출
|
||||||
|
last_open_ms = int(df.index[-1].timestamp() * 1000)
|
||||||
|
if last_open_ms >= current_candle_start_ms:
|
||||||
|
return df.iloc[:-1].copy()
|
||||||
|
return df
|
||||||
|
|
||||||
|
async def fetch_ohlcv(self, symbol: str, timeframe: str, limit: int = 250) -> List[List]:
|
||||||
|
"""
|
||||||
|
ccxt를 통해 OHLCV 데이터 fetch.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[[timestamp, open, high, low, close, volume], ...]
|
||||||
|
"""
|
||||||
|
return await self.exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
"""봇 시작 시 초기 데이터 로드 (250개씩)."""
|
||||||
|
# 15m 캔들
|
||||||
|
raw_15m = await self.fetch_ohlcv(self.symbol, "15m", limit=250)
|
||||||
|
for candle in raw_15m:
|
||||||
|
self.klines_15m.append(candle)
|
||||||
|
if raw_15m:
|
||||||
|
self._last_15m_ts = raw_15m[-1][0]
|
||||||
|
|
||||||
|
# 1h 캔들
|
||||||
|
raw_1h = await self.fetch_ohlcv(self.symbol, "1h", limit=250)
|
||||||
|
for candle in raw_1h:
|
||||||
|
self.klines_1h.append(candle)
|
||||||
|
if raw_1h:
|
||||||
|
self._last_1h_ts = raw_1h[-1][0]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[DataFetcher] 초기화 완료: 15m={len(self.klines_15m)}개, 1h={len(self.klines_1h)}개"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_15m_dataframe(self) -> Optional[pd.DataFrame]:
|
||||||
|
"""완성된 15m 캔들을 DataFrame으로 반환 (미완성 캔들 조건부 제거)."""
|
||||||
|
if not self.klines_15m:
|
||||||
|
return None
|
||||||
|
data = list(self.klines_15m)
|
||||||
|
df = pd.DataFrame(data, columns=["timestamp", "open", "high", "low", "close", "volume"])
|
||||||
|
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
df = df.set_index("timestamp")
|
||||||
|
return self._remove_incomplete_candle(df, interval_sec=900)
|
||||||
|
|
||||||
|
def get_1h_dataframe_completed(self) -> Optional[pd.DataFrame]:
|
||||||
|
"""
|
||||||
|
'완성된' 1h 캔들만 반환.
|
||||||
|
|
||||||
|
조건부 슬라이싱: _remove_incomplete_candle()로 진행 중인 최신 1h 캔들 제외.
|
||||||
|
이유: Look-ahead bias 원천 차단 — 아직 완성되지 않은 캔들의
|
||||||
|
high/low/close는 미래 데이터이므로 지표 계산에 사용하면 안 됨.
|
||||||
|
"""
|
||||||
|
if len(self.klines_1h) < 2:
|
||||||
|
return None
|
||||||
|
data = list(self.klines_1h)
|
||||||
|
df = pd.DataFrame(data, columns=["timestamp", "open", "high", "low", "close", "volume"])
|
||||||
|
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
df = df.set_index("timestamp")
|
||||||
|
return self._remove_incomplete_candle(df, interval_sec=3600)
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""ccxt exchange 연결 정리."""
|
||||||
|
await self.exchange.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Module 2: MetaFilter
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class MetaFilter:
|
||||||
|
"""1시간봉 데이터로부터 거시 추세 판독."""
|
||||||
|
|
||||||
|
EMA_FAST = 50
|
||||||
|
EMA_SLOW = 200
|
||||||
|
ADX_THRESHOLD = 20
|
||||||
|
|
||||||
|
def __init__(self, data_fetcher: DataFetcher):
|
||||||
|
self.data_fetcher = data_fetcher
|
||||||
|
self._cached_indicators: Optional[pd.DataFrame] = None
|
||||||
|
self._cache_timestamp: Optional[pd.Timestamp] = None
|
||||||
|
|
||||||
|
def _calc_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""1h DataFrame에 EMA50, EMA200, ADX, ATR 계산 (캔들 단위 캐싱)."""
|
||||||
|
if df is None or df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
last_ts = df.index[-1]
|
||||||
|
if self._cached_indicators is not None and self._cache_timestamp == last_ts:
|
||||||
|
return self._cached_indicators
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
df["ema50"] = ta.ema(df["close"], length=self.EMA_FAST)
|
||||||
|
df["ema200"] = ta.ema(df["close"], length=self.EMA_SLOW)
|
||||||
|
adx_df = ta.adx(df["high"], df["low"], df["close"], length=14)
|
||||||
|
df["adx"] = adx_df["ADX_14"]
|
||||||
|
df["atr"] = ta.atr(df["high"], df["low"], df["close"], length=14)
|
||||||
|
|
||||||
|
self._cached_indicators = df
|
||||||
|
self._cache_timestamp = last_ts
|
||||||
|
return df
|
||||||
|
|
||||||
|
def get_market_state(self) -> str:
|
||||||
|
"""
|
||||||
|
1h 메타필터 상태 반환.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
'LONG_ALLOWED': EMA50 > EMA200 & ADX > 20 → 상승 추세, LONG 진입 허용
|
||||||
|
'SHORT_ALLOWED': EMA50 < EMA200 & ADX > 20 → 하락 추세, SHORT 진입 허용
|
||||||
|
'WAIT': 그 외 (추세 약하거나 데이터 부족)
|
||||||
|
"""
|
||||||
|
df = self.data_fetcher.get_1h_dataframe_completed()
|
||||||
|
if df is None or len(df) < self.EMA_SLOW:
|
||||||
|
return "WAIT"
|
||||||
|
|
||||||
|
df = self._calc_indicators(df)
|
||||||
|
last = df.iloc[-1]
|
||||||
|
|
||||||
|
if pd.isna(last["ema50"]) or pd.isna(last["ema200"]) or pd.isna(last["adx"]):
|
||||||
|
return "WAIT"
|
||||||
|
|
||||||
|
if last["adx"] < self.ADX_THRESHOLD:
|
||||||
|
return "WAIT"
|
||||||
|
|
||||||
|
if last["ema50"] > last["ema200"]:
|
||||||
|
return "LONG_ALLOWED"
|
||||||
|
elif last["ema50"] < last["ema200"]:
|
||||||
|
return "SHORT_ALLOWED"
|
||||||
|
|
||||||
|
return "WAIT"
|
||||||
|
|
||||||
|
def get_current_atr(self) -> Optional[float]:
|
||||||
|
"""현재 1h ATR 값 반환 (SL/TP 계산용)."""
|
||||||
|
df = self.data_fetcher.get_1h_dataframe_completed()
|
||||||
|
if df is None or len(df) < 15: # ATR(14) 최소 데이터
|
||||||
|
return None
|
||||||
|
|
||||||
|
df = self._calc_indicators(df)
|
||||||
|
atr = df["atr"].iloc[-1]
|
||||||
|
return float(atr) if not pd.isna(atr) else None
|
||||||
|
|
||||||
|
def get_meta_info(self) -> Dict:
|
||||||
|
"""전체 메타 정보 반환 (디버깅용)."""
|
||||||
|
df = self.data_fetcher.get_1h_dataframe_completed()
|
||||||
|
if df is None or len(df) < self.EMA_SLOW:
|
||||||
|
return {"state": "WAIT", "ema50": None, "ema200": None,
|
||||||
|
"adx": None, "atr": None, "timestamp": None}
|
||||||
|
|
||||||
|
df = self._calc_indicators(df)
|
||||||
|
last = df.iloc[-1]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"state": self.get_market_state(),
|
||||||
|
"ema50": float(last["ema50"]) if not pd.isna(last["ema50"]) else None,
|
||||||
|
"ema200": float(last["ema200"]) if not pd.isna(last["ema200"]) else None,
|
||||||
|
"adx": float(last["adx"]) if not pd.isna(last["adx"]) else None,
|
||||||
|
"atr": float(last["atr"]) if not pd.isna(last["atr"]) else None,
|
||||||
|
"timestamp": str(df.index[-1]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Module 3: TriggerStrategy
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TriggerStrategy:
|
||||||
|
"""
|
||||||
|
15분봉 Volume-backed Pullback 패턴을 3캔들 시퀀스로 인식.
|
||||||
|
|
||||||
|
3캔들 시퀀스:
|
||||||
|
t-2: 기준 캔들 (Vol_SMA20 산출 기준)
|
||||||
|
t-1: 풀백 캔들 (EMA 이탈 + 거래량 고갈 확인)
|
||||||
|
t : 돌파 캔들 (가장 최근 완성된 캔들, EMA 복귀 확인)
|
||||||
|
"""
|
||||||
|
|
||||||
|
EMA_PERIOD = 15
|
||||||
|
VOL_SMA_PERIOD = 20
|
||||||
|
VOL_THRESHOLD = 0.50 # vol < vol_sma20 * 0.50
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._last_info: Dict = {}
|
||||||
|
|
||||||
|
def _calc_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""15m DataFrame에 EMA15, Vol_SMA20 계산."""
|
||||||
|
df = df.copy()
|
||||||
|
df["ema15"] = ta.ema(df["close"], length=self.EMA_PERIOD)
|
||||||
|
df["vol_sma20"] = df["volume"].rolling(self.VOL_SMA_PERIOD).mean()
|
||||||
|
return df
|
||||||
|
|
||||||
|
def generate_signal(self, df_15m: pd.DataFrame, meta_state: str) -> str:
|
||||||
|
"""
|
||||||
|
3캔들 시퀀스 기반 진입 신호 생성.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df_15m: 15분봉 DataFrame (OHLCV)
|
||||||
|
meta_state: 'LONG_ALLOWED' | 'SHORT_ALLOWED' | 'WAIT'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
'EXECUTE_LONG' | 'EXECUTE_SHORT' | 'HOLD'
|
||||||
|
"""
|
||||||
|
# Step 1: 데이터 유효성
|
||||||
|
if meta_state == "WAIT":
|
||||||
|
self._last_info = {"signal": "HOLD", "reason": "meta_state=WAIT"}
|
||||||
|
return "HOLD"
|
||||||
|
|
||||||
|
if df_15m is None or len(df_15m) < 25:
|
||||||
|
self._last_info = {"signal": "HOLD", "reason": f"데이터 부족 ({len(df_15m) if df_15m is not None else 0}행)"}
|
||||||
|
return "HOLD"
|
||||||
|
|
||||||
|
df = self._calc_indicators(df_15m)
|
||||||
|
|
||||||
|
# Step 2: 캔들 인덱싱
|
||||||
|
t = df.iloc[-1] # 최근 완성 캔들 (돌파 확인)
|
||||||
|
t_1 = df.iloc[-2] # 직전 캔들 (풀백 확인)
|
||||||
|
t_2 = df.iloc[-3] # 그 이전 캔들 (Vol SMA 기준)
|
||||||
|
|
||||||
|
# NaN 체크
|
||||||
|
if (pd.isna(t["ema15"]) or pd.isna(t_1["ema15"])
|
||||||
|
or pd.isna(t_2["vol_sma20"])):
|
||||||
|
self._last_info = {"signal": "HOLD", "reason": "지표 NaN"}
|
||||||
|
return "HOLD"
|
||||||
|
|
||||||
|
vol_sma20_t2 = t_2["vol_sma20"]
|
||||||
|
vol_t1 = t_1["volume"]
|
||||||
|
vol_ratio = vol_t1 / vol_sma20_t2 if vol_sma20_t2 > 0 else float("inf")
|
||||||
|
vol_dry = vol_ratio < self.VOL_THRESHOLD
|
||||||
|
|
||||||
|
# 공통 info 구성
|
||||||
|
self._last_info = {
|
||||||
|
"ema15_t": float(t["ema15"]),
|
||||||
|
"ema15_t1": float(t_1["ema15"]),
|
||||||
|
"vol_sma20_t2": float(vol_sma20_t2),
|
||||||
|
"vol_t1": float(vol_t1),
|
||||||
|
"vol_ratio": round(vol_ratio, 4),
|
||||||
|
"close_t1": float(t_1["close"]),
|
||||||
|
"close_t": float(t["close"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 3: LONG 시그널
|
||||||
|
if meta_state == "LONG_ALLOWED":
|
||||||
|
pullback = t_1["close"] < t_1["ema15"] # t-1 EMA 아래로 이탈
|
||||||
|
resumption = t["close"] > t["ema15"] # t EMA 위로 복귀
|
||||||
|
|
||||||
|
if pullback and vol_dry and resumption:
|
||||||
|
self._last_info.update({
|
||||||
|
"signal": "EXECUTE_LONG",
|
||||||
|
"reason": f"풀백 이탈 + 거래량 고갈({vol_ratio:.2f}) + 돌파 복귀",
|
||||||
|
})
|
||||||
|
return "EXECUTE_LONG"
|
||||||
|
|
||||||
|
reasons = []
|
||||||
|
if not pullback:
|
||||||
|
reasons.append(f"이탈 없음(close_t1={t_1['close']:.4f} >= ema15={t_1['ema15']:.4f})")
|
||||||
|
if not vol_dry:
|
||||||
|
reasons.append(f"거래량 과다({vol_ratio:.2f} >= {self.VOL_THRESHOLD})")
|
||||||
|
if not resumption:
|
||||||
|
reasons.append(f"복귀 실패(close_t={t['close']:.4f} <= ema15={t['ema15']:.4f})")
|
||||||
|
self._last_info.update({"signal": "HOLD", "reason": " | ".join(reasons)})
|
||||||
|
return "HOLD"
|
||||||
|
|
||||||
|
# Step 4: SHORT 시그널
|
||||||
|
if meta_state == "SHORT_ALLOWED":
|
||||||
|
pullback = t_1["close"] > t_1["ema15"] # t-1 EMA 위로 이탈
|
||||||
|
resumption = t["close"] < t["ema15"] # t EMA 아래로 복귀
|
||||||
|
|
||||||
|
if pullback and vol_dry and resumption:
|
||||||
|
self._last_info.update({
|
||||||
|
"signal": "EXECUTE_SHORT",
|
||||||
|
"reason": f"풀백 이탈 + 거래량 고갈({vol_ratio:.2f}) + 돌파 복귀",
|
||||||
|
})
|
||||||
|
return "EXECUTE_SHORT"
|
||||||
|
|
||||||
|
reasons = []
|
||||||
|
if not pullback:
|
||||||
|
reasons.append(f"이탈 없음(close_t1={t_1['close']:.4f} <= ema15={t_1['ema15']:.4f})")
|
||||||
|
if not vol_dry:
|
||||||
|
reasons.append(f"거래량 과다({vol_ratio:.2f} >= {self.VOL_THRESHOLD})")
|
||||||
|
if not resumption:
|
||||||
|
reasons.append(f"복귀 실패(close_t={t['close']:.4f} >= ema15={t['ema15']:.4f})")
|
||||||
|
self._last_info.update({"signal": "HOLD", "reason": " | ".join(reasons)})
|
||||||
|
return "HOLD"
|
||||||
|
|
||||||
|
# Step 5: 기본값
|
||||||
|
self._last_info.update({"signal": "HOLD", "reason": f"미지원 meta_state={meta_state}"})
|
||||||
|
return "HOLD"
|
||||||
|
|
||||||
|
def get_trigger_info(self) -> Dict:
|
||||||
|
"""디버깅 및 로그용 트리거 상태 정보 반환."""
|
||||||
|
return self._last_info.copy()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Module 4: ExecutionManager
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
_MTF_TRADE_DIR = Path("data/trade_history")
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionManager:
|
||||||
|
"""
|
||||||
|
TriggerStrategy의 신호를 받아 포지션 상태를 관리하고
|
||||||
|
SL/TP를 계산하여 가상 주문을 실행한다 (Dry-run 모드).
|
||||||
|
"""
|
||||||
|
|
||||||
|
ATR_SL_MULT = 1.5
|
||||||
|
ATR_TP_MULT = 2.3
|
||||||
|
|
||||||
|
def __init__(self, symbol: str = "XRPUSDT"):
|
||||||
|
self.symbol = symbol
|
||||||
|
self.current_position: Optional[str] = None # None | 'LONG' | 'SHORT'
|
||||||
|
self._entry_price: Optional[float] = None
|
||||||
|
self._entry_ts: Optional[str] = None
|
||||||
|
self._sl_price: Optional[float] = None
|
||||||
|
self._tp_price: Optional[float] = None
|
||||||
|
self._atr_at_entry: Optional[float] = None
|
||||||
|
|
||||||
|
def execute(self, signal: str, current_price: float, atr_value: Optional[float]) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
신호에 따라 가상 주문 실행.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
signal: 'EXECUTE_LONG' | 'EXECUTE_SHORT' | 'HOLD'
|
||||||
|
current_price: 현재 시장가
|
||||||
|
atr_value: 1h ATR 값
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
주문 정보 Dict 또는 None (HOLD / 중복 포지션 / ATR 무효)
|
||||||
|
"""
|
||||||
|
if signal == "HOLD":
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.current_position is not None:
|
||||||
|
logger.debug(
|
||||||
|
f"[ExecutionManager] 포지션 중복 차단: "
|
||||||
|
f"현재={self.current_position}, 신호={signal}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if atr_value is None or atr_value <= 0 or pd.isna(atr_value):
|
||||||
|
logger.warning(f"[ExecutionManager] ATR 무효({atr_value}), 주문 차단")
|
||||||
|
return None
|
||||||
|
|
||||||
|
entry_price = current_price
|
||||||
|
|
||||||
|
if signal == "EXECUTE_LONG":
|
||||||
|
sl_price = entry_price - (atr_value * self.ATR_SL_MULT)
|
||||||
|
tp_price = entry_price + (atr_value * self.ATR_TP_MULT)
|
||||||
|
side = "LONG"
|
||||||
|
elif signal == "EXECUTE_SHORT":
|
||||||
|
sl_price = entry_price + (atr_value * self.ATR_SL_MULT)
|
||||||
|
tp_price = entry_price - (atr_value * self.ATR_TP_MULT)
|
||||||
|
side = "SHORT"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.current_position = side
|
||||||
|
self._entry_price = entry_price
|
||||||
|
self._entry_ts = datetime.now(timezone.utc).isoformat()
|
||||||
|
self._sl_price = sl_price
|
||||||
|
self._tp_price = tp_price
|
||||||
|
self._atr_at_entry = atr_value
|
||||||
|
|
||||||
|
sl_dist = abs(entry_price - sl_price)
|
||||||
|
tp_dist = abs(tp_price - entry_price)
|
||||||
|
rr_ratio = tp_dist / sl_dist if sl_dist > 0 else 0
|
||||||
|
|
||||||
|
# ── Dry-run 로그 ──
|
||||||
|
logger.info(
|
||||||
|
f"\n┌──────────────────────────────────────────────┐\n"
|
||||||
|
f"│ [DRY-RUN] 가상 주문 실행 │\n"
|
||||||
|
f"│ 방향: {side:<5} | 진입가: {entry_price:.4f} │\n"
|
||||||
|
f"│ SL: {sl_price:.4f} ({'-' if side == 'LONG' else '+'}{sl_dist:.4f}, ATR×{self.ATR_SL_MULT}) │\n"
|
||||||
|
f"│ TP: {tp_price:.4f} ({'+' if side == 'LONG' else '-'}{tp_dist:.4f}, ATR×{self.ATR_TP_MULT}) │\n"
|
||||||
|
f"│ R:R = 1:{rr_ratio:.1f} │\n"
|
||||||
|
f"└──────────────────────────────────────────────┘"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 실주문 (프로덕션 전환 시 주석 해제) ──
|
||||||
|
# if side == "LONG":
|
||||||
|
# await self.exchange.create_market_buy_order(symbol, amount)
|
||||||
|
# await self.exchange.create_order(symbol, 'stop_market', 'sell', amount, params={'stopPrice': sl_price})
|
||||||
|
# await self.exchange.create_order(symbol, 'take_profit_market', 'sell', amount, params={'stopPrice': tp_price})
|
||||||
|
# elif side == "SHORT":
|
||||||
|
# await self.exchange.create_market_sell_order(symbol, amount)
|
||||||
|
# await self.exchange.create_order(symbol, 'stop_market', 'buy', amount, params={'stopPrice': sl_price})
|
||||||
|
# await self.exchange.create_order(symbol, 'take_profit_market', 'buy', amount, params={'stopPrice': tp_price})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"action": side,
|
||||||
|
"entry_price": entry_price,
|
||||||
|
"sl_price": sl_price,
|
||||||
|
"tp_price": tp_price,
|
||||||
|
"atr": atr_value,
|
||||||
|
"risk_reward": round(rr_ratio, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
def close_position(self, reason: str, exit_price: float = 0.0, pnl_bps: float = 0.0) -> None:
|
||||||
|
"""포지션 청산 + JSONL 기록 (상태 초기화)."""
|
||||||
|
if self.current_position is None:
|
||||||
|
logger.debug("[ExecutionManager] 청산할 포지션 없음")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[ExecutionManager] 포지션 청산: {self.current_position} "
|
||||||
|
f"(진입: {self._entry_price:.4f}) | 사유: {reason}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# JSONL에 기록
|
||||||
|
self._save_trade(reason, exit_price, pnl_bps)
|
||||||
|
|
||||||
|
# ── 실주문 (프로덕션 전환 시 주석 해제) ──
|
||||||
|
# if self.current_position == "LONG":
|
||||||
|
# await self.exchange.create_market_sell_order(symbol, amount)
|
||||||
|
# elif self.current_position == "SHORT":
|
||||||
|
# await self.exchange.create_market_buy_order(symbol, amount)
|
||||||
|
|
||||||
|
self.current_position = None
|
||||||
|
self._entry_price = None
|
||||||
|
self._entry_ts = None
|
||||||
|
self._sl_price = None
|
||||||
|
self._tp_price = None
|
||||||
|
self._atr_at_entry = None
|
||||||
|
|
||||||
|
def _save_trade(self, reason: str, exit_price: float, pnl_bps: float) -> None:
|
||||||
|
"""거래 기록을 JSONL 파일에 append."""
|
||||||
|
record = {
|
||||||
|
"symbol": self.symbol,
|
||||||
|
"side": self.current_position,
|
||||||
|
"entry_price": self._entry_price,
|
||||||
|
"entry_ts": self._entry_ts,
|
||||||
|
"exit_price": exit_price,
|
||||||
|
"exit_ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"sl_price": self._sl_price,
|
||||||
|
"tp_price": self._tp_price,
|
||||||
|
"atr": self._atr_at_entry,
|
||||||
|
"pnl_bps": round(pnl_bps, 1),
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
_MTF_TRADE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = _MTF_TRADE_DIR / f"mtf_{self.symbol.replace('/', '').replace(':', '').lower()}.jsonl"
|
||||||
|
with open(path, "a") as f:
|
||||||
|
f.write(json.dumps(record) + "\n")
|
||||||
|
logger.info(f"[ExecutionManager] 거래 기록 저장: {path.name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ExecutionManager] 거래 기록 저장 실패: {e}")
|
||||||
|
|
||||||
|
def get_position_info(self) -> Dict:
|
||||||
|
"""현재 포지션 정보 반환."""
|
||||||
|
return {
|
||||||
|
"position": self.current_position,
|
||||||
|
"entry_price": self._entry_price,
|
||||||
|
"sl_price": self._sl_price,
|
||||||
|
"tp_price": self._tp_price,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# 검증 테스트
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Main Loop: OOS Dry-run
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class MTFPullbackBot:
|
||||||
|
"""MTF Pullback Bot 메인 루프 — Dry-run OOS 검증용."""
|
||||||
|
|
||||||
|
# TODO(LIVE): Kill switch 로직 구현 필요 (Fast Kill 8연패 + Slow Kill PF<0.75) — 2026-04-15 LIVE 전환 시
|
||||||
|
# TODO(LIVE): 글로벌 RiskManager 통합 필요 — 2026-04-15 LIVE 전환 시
|
||||||
|
|
||||||
|
LOOP_INTERVAL = 1 # 초 (TimeframeSync 4초 윈도우를 놓치지 않기 위해)
|
||||||
|
POLL_INTERVAL = 30 # 데이터 폴링 주기 (초)
|
||||||
|
|
||||||
|
def __init__(self, symbol: str = "XRP/USDT:USDT"):
|
||||||
|
self.symbol = symbol
|
||||||
|
self.fetcher = DataFetcher(symbol=symbol)
|
||||||
|
self.meta = MetaFilter(self.fetcher)
|
||||||
|
self.trigger = TriggerStrategy()
|
||||||
|
self.executor = ExecutionManager(symbol=symbol)
|
||||||
|
self.notifier = DiscordNotifier(
|
||||||
|
webhook_url=os.getenv("DISCORD_WEBHOOK_URL", ""),
|
||||||
|
)
|
||||||
|
self._last_15m_check_ts: int = 0 # 중복 체크 방지
|
||||||
|
self._last_poll_ts: float = 0 # 마지막 폴링 시각
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""메인 루프: 30초 폴링 → 15m 캔들 close 감지 → 신호 판정."""
|
||||||
|
logger.info(f"[MTFBot] 시작: {self.symbol} (Dry-run OOS 모드)")
|
||||||
|
|
||||||
|
await self.fetcher.initialize()
|
||||||
|
|
||||||
|
# 초기 상태 출력
|
||||||
|
meta_state = self.meta.get_market_state()
|
||||||
|
atr = self.meta.get_current_atr()
|
||||||
|
logger.info(f"[MTFBot] 초기 상태: Meta={meta_state}, ATR={atr}")
|
||||||
|
self.notifier.notify_info(
|
||||||
|
f"**[MTF Dry-run] 봇 시작**\n"
|
||||||
|
f"심볼: `{self.symbol}` | Meta: `{meta_state}` | ATR: `{atr:.6f}`" if atr else
|
||||||
|
f"**[MTF Dry-run] 봇 시작**\n심볼: `{self.symbol}` | Meta: `{meta_state}` | ATR: N/A"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(self.LOOP_INTERVAL)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 데이터 폴링 (30초마다)
|
||||||
|
now_mono = _time.monotonic()
|
||||||
|
if now_mono - self._last_poll_ts >= self.POLL_INTERVAL:
|
||||||
|
await self._poll_and_update()
|
||||||
|
self._last_poll_ts = now_mono
|
||||||
|
|
||||||
|
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
|
||||||
|
|
||||||
|
# 15m 캔들 close 감지
|
||||||
|
if TimeframeSync.is_15m_candle_closed(now_ms):
|
||||||
|
if now_ms - self._last_15m_check_ts > 60_000: # 1분 이내 중복 방지
|
||||||
|
self._last_15m_check_ts = now_ms
|
||||||
|
await self._poll_and_update() # 최신 데이터 보장
|
||||||
|
await self._on_15m_close()
|
||||||
|
|
||||||
|
# 포지션 보유 중이면 SL/TP 모니터링
|
||||||
|
if self.executor.current_position is not None:
|
||||||
|
self._check_sl_tp()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[MTFBot] 루프 에러: {e}")
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("[MTFBot] 종료 시그널 수신")
|
||||||
|
finally:
|
||||||
|
await self.fetcher.close()
|
||||||
|
logger.info("[MTFBot] 종료 완료")
|
||||||
|
|
||||||
|
async def _poll_and_update(self):
|
||||||
|
"""데이터 폴링 업데이트."""
|
||||||
|
# 15m
|
||||||
|
raw_15m = await self.fetcher.fetch_ohlcv(self.symbol, "15m", limit=3)
|
||||||
|
for candle in raw_15m:
|
||||||
|
if candle[0] > self.fetcher._last_15m_ts:
|
||||||
|
self.fetcher.klines_15m.append(candle)
|
||||||
|
self.fetcher._last_15m_ts = candle[0]
|
||||||
|
|
||||||
|
# 1h
|
||||||
|
raw_1h = await self.fetcher.fetch_ohlcv(self.symbol, "1h", limit=3)
|
||||||
|
for candle in raw_1h:
|
||||||
|
if candle[0] > self.fetcher._last_1h_ts:
|
||||||
|
self.fetcher.klines_1h.append(candle)
|
||||||
|
self.fetcher._last_1h_ts = candle[0]
|
||||||
|
|
||||||
|
async def _on_15m_close(self):
|
||||||
|
"""15m 캔들 종료 시 신호 판정."""
|
||||||
|
df_15m = self.fetcher.get_15m_dataframe()
|
||||||
|
meta_state = self.meta.get_market_state()
|
||||||
|
atr = self.meta.get_current_atr()
|
||||||
|
|
||||||
|
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
last_close = float(df_15m.iloc[-1]["close"]) if df_15m is not None and len(df_15m) > 0 else 0
|
||||||
|
pos_info = self.executor.current_position or "없음"
|
||||||
|
|
||||||
|
# Heartbeat: 15분마다 무조건 출력 (메타 지표 포함)
|
||||||
|
meta_info = self.meta.get_meta_info()
|
||||||
|
adx_val = meta_info.get("adx")
|
||||||
|
ema50_val = meta_info.get("ema50")
|
||||||
|
ema200_val = meta_info.get("ema200")
|
||||||
|
adx_str = f"{adx_val:.2f}" if adx_val is not None else "N/A"
|
||||||
|
ema50_str = f"{ema50_val:.4f}" if ema50_val is not None else "N/A"
|
||||||
|
ema200_str = f"{ema200_val:.4f}" if ema200_val is not None else "N/A"
|
||||||
|
atr_str = f"{atr:.6f}" if atr else "N/A"
|
||||||
|
logger.info(
|
||||||
|
f"[Heartbeat] 15m 마감 ({now_str}) | Meta: {meta_state} | "
|
||||||
|
f"ADX: {adx_str} | EMA50: {ema50_str} | EMA200: {ema200_str} | "
|
||||||
|
f"ATR: {atr_str} | Close: {last_close:.4f} | Pos: {pos_info}"
|
||||||
|
)
|
||||||
|
|
||||||
|
signal = self.trigger.generate_signal(df_15m, meta_state)
|
||||||
|
info = self.trigger.get_trigger_info()
|
||||||
|
|
||||||
|
if signal != "HOLD":
|
||||||
|
logger.info(f"[MTFBot] 신호: {signal} | {info.get('reason', '')}")
|
||||||
|
current_price = last_close
|
||||||
|
result = self.executor.execute(signal, current_price, atr)
|
||||||
|
if result:
|
||||||
|
logger.info(f"[MTFBot] 거래 기록: {result}")
|
||||||
|
side = result["action"]
|
||||||
|
sl_dist = abs(result["entry_price"] - result["sl_price"])
|
||||||
|
tp_dist = abs(result["tp_price"] - result["entry_price"])
|
||||||
|
self.notifier.notify_info(
|
||||||
|
f"**[MTF Dry-run] 가상 {side} 진입**\n"
|
||||||
|
f"진입가: `{result['entry_price']:.4f}` | ATR: `{result['atr']:.6f}`\n"
|
||||||
|
f"SL: `{result['sl_price']:.4f}` ({sl_dist:.4f}) | "
|
||||||
|
f"TP: `{result['tp_price']:.4f}` ({tp_dist:.4f})\n"
|
||||||
|
f"R:R = `1:{result['risk_reward']}` | Meta: `{meta_state}`\n"
|
||||||
|
f"사유: {info.get('reason', '')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(f"[MTFBot] HOLD | {info.get('reason', '')}")
|
||||||
|
|
||||||
|
def _check_sl_tp(self):
|
||||||
|
"""현재 가격으로 SL/TP 도달 여부 확인 (15m 캔들 high/low 기반)."""
|
||||||
|
df_15m = self.fetcher.get_15m_dataframe()
|
||||||
|
if df_15m is None or len(df_15m) < 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
last = df_15m.iloc[-1]
|
||||||
|
pos = self.executor.current_position
|
||||||
|
sl = self.executor._sl_price
|
||||||
|
tp = self.executor._tp_price
|
||||||
|
entry = self.executor._entry_price
|
||||||
|
|
||||||
|
if pos is None or sl is None or tp is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
hit_sl = hit_tp = False
|
||||||
|
if pos == "LONG":
|
||||||
|
hit_sl = last["low"] <= sl
|
||||||
|
hit_tp = last["high"] >= tp
|
||||||
|
else:
|
||||||
|
hit_sl = last["high"] >= sl
|
||||||
|
hit_tp = last["low"] <= tp
|
||||||
|
|
||||||
|
if hit_sl and hit_tp:
|
||||||
|
exit_price = sl
|
||||||
|
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
|
||||||
|
pnl_bps = pnl * 10000
|
||||||
|
logger.info(f"[MTFBot] SL+TP 동시 히트 → SL 우선 청산 | PnL: {pnl_bps:+.1f}bps")
|
||||||
|
self.executor.close_position(f"SL 히트 ({exit_price:.4f})", exit_price, pnl_bps)
|
||||||
|
self.notifier.notify_info(
|
||||||
|
f"**[MTF Dry-run] {pos} SL 청산**\n"
|
||||||
|
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
|
||||||
|
f"PnL: `{pnl_bps:+.1f}bps`"
|
||||||
|
)
|
||||||
|
elif hit_sl:
|
||||||
|
exit_price = sl
|
||||||
|
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
|
||||||
|
pnl_bps = pnl * 10000
|
||||||
|
logger.info(f"[MTFBot] SL 히트 | 청산가: {exit_price:.4f} | PnL: {pnl_bps:+.1f}bps")
|
||||||
|
self.executor.close_position(f"SL 히트 ({exit_price:.4f})", exit_price, pnl_bps)
|
||||||
|
self.notifier.notify_info(
|
||||||
|
f"**[MTF Dry-run] {pos} SL 청산**\n"
|
||||||
|
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
|
||||||
|
f"PnL: `{pnl_bps:+.1f}bps`"
|
||||||
|
)
|
||||||
|
elif hit_tp:
|
||||||
|
exit_price = tp
|
||||||
|
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
|
||||||
|
pnl_bps = pnl * 10000
|
||||||
|
logger.info(f"[MTFBot] TP 히트 | 청산가: {exit_price:.4f} | PnL: {pnl_bps:+.1f}bps")
|
||||||
|
self.executor.close_position(f"TP 히트 ({exit_price:.4f})", exit_price, pnl_bps)
|
||||||
|
self.notifier.notify_info(
|
||||||
|
f"**[MTF Dry-run] {pos} TP 청산**\n"
|
||||||
|
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
|
||||||
|
f"PnL: `{pnl_bps:+.1f}bps`"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# 검증 테스트
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def test_module_1_2():
|
||||||
|
"""Module 1 & 2 검증 테스트."""
|
||||||
|
print("=" * 60)
|
||||||
|
print(" MTF Bot Module 1 & 2 검증 테스트")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# ── 1. TimeframeSync 검증 ──
|
||||||
|
print("\n[1] TimeframeSync 검증")
|
||||||
|
# 2026-01-01 01:00:03 UTC (1h 캔들 close 직후)
|
||||||
|
ts_1h_close = int(datetime(2026, 1, 1, 1, 0, 3, tzinfo=timezone.utc).timestamp() * 1000)
|
||||||
|
# 2026-01-01 00:15:04 UTC (15m 캔들 close 직후)
|
||||||
|
ts_15m_close = int(datetime(2026, 1, 1, 0, 15, 4, tzinfo=timezone.utc).timestamp() * 1000)
|
||||||
|
# 2026-01-01 00:15:00 UTC (정각 — 아직 딜레이 전)
|
||||||
|
ts_too_early = int(datetime(2026, 1, 1, 0, 15, 0, tzinfo=timezone.utc).timestamp() * 1000)
|
||||||
|
# 2026-01-01 00:15:10 UTC (너무 늦음)
|
||||||
|
ts_too_late = int(datetime(2026, 1, 1, 0, 15, 10, tzinfo=timezone.utc).timestamp() * 1000)
|
||||||
|
# 2026-01-01 00:07:03 UTC (15m 경계 아님)
|
||||||
|
ts_not_boundary = int(datetime(2026, 1, 1, 0, 7, 3, tzinfo=timezone.utc).timestamp() * 1000)
|
||||||
|
|
||||||
|
assert TimeframeSync.is_1h_candle_closed(ts_1h_close) is True, "1h close 판별 실패"
|
||||||
|
assert TimeframeSync.is_15m_candle_closed(ts_15m_close) is True, "15m close 판별 실패"
|
||||||
|
assert TimeframeSync.is_15m_candle_closed(ts_too_early) is False, "정각(0초)에 True 반환"
|
||||||
|
assert TimeframeSync.is_15m_candle_closed(ts_too_late) is False, "10초에 True 반환"
|
||||||
|
assert TimeframeSync.is_15m_candle_closed(ts_not_boundary) is False, "비경계 시점에 True 반환"
|
||||||
|
assert TimeframeSync.is_1h_candle_closed(ts_15m_close) is False, "15분에 1h close True 반환"
|
||||||
|
print(" ✓ TimeframeSync: second 2~5 범위에서만 True 반환 확인")
|
||||||
|
|
||||||
|
# ── 2. DataFetcher 초기화 ──
|
||||||
|
print("\n[2] DataFetcher 초기화")
|
||||||
|
fetcher = DataFetcher(symbol="XRP/USDT:USDT")
|
||||||
|
try:
|
||||||
|
await fetcher.initialize()
|
||||||
|
|
||||||
|
assert len(fetcher.klines_15m) == 200, f"15m 캔들 {len(fetcher.klines_15m)}개 (200 예상)"
|
||||||
|
assert len(fetcher.klines_1h) == 200, f"1h 캔들 {len(fetcher.klines_1h)}개 (200 예상)"
|
||||||
|
print(f" ✓ 초기화 완료: 15m={len(fetcher.klines_15m)}개, 1h={len(fetcher.klines_1h)}개")
|
||||||
|
|
||||||
|
# ── 3. [:-1] 슬라이싱 검증 ──
|
||||||
|
print("\n[3] get_1h_dataframe_completed() [:-1] 검증")
|
||||||
|
df_1h = fetcher.get_1h_dataframe_completed()
|
||||||
|
assert df_1h is not None, "1h DataFrame이 None"
|
||||||
|
assert len(df_1h) == 199, f"1h completed 캔들 {len(df_1h)}개 (199 예상)"
|
||||||
|
|
||||||
|
# 마지막 완성 봉의 timestamp < 현재 진행 중 봉의 timestamp
|
||||||
|
last_completed_ts = df_1h.index[-1]
|
||||||
|
last_raw_ts = pd.to_datetime(fetcher.klines_1h[-1][0], unit="ms", utc=True)
|
||||||
|
assert last_completed_ts < last_raw_ts, "completed 봉이 진행 중 봉을 포함"
|
||||||
|
print(f" ✓ 1h completed: {len(df_1h)}개 (200 - 1 = 199, 미완성 봉 제외 확인)")
|
||||||
|
print(f" 마지막 완성 봉: {last_completed_ts}")
|
||||||
|
print(f" 진행 중 봉: {last_raw_ts} (제외됨)")
|
||||||
|
|
||||||
|
# 15m DataFrame 검증
|
||||||
|
df_15m = fetcher.get_15m_dataframe()
|
||||||
|
assert df_15m is not None and len(df_15m) == 200
|
||||||
|
print(f" ✓ 15m DataFrame: {len(df_15m)}개")
|
||||||
|
|
||||||
|
# ── 4. MetaFilter 검증 ──
|
||||||
|
print("\n[4] MetaFilter 검증")
|
||||||
|
meta = MetaFilter(fetcher)
|
||||||
|
|
||||||
|
state = meta.get_market_state()
|
||||||
|
assert state in ("LONG_ALLOWED", "SHORT_ALLOWED", "WAIT"), f"비정상 상태: {state}"
|
||||||
|
print(f" ✓ MetaFilter 상태: {state}")
|
||||||
|
|
||||||
|
atr = meta.get_current_atr()
|
||||||
|
assert atr is not None and atr > 0, f"ATR 비정상: {atr}"
|
||||||
|
print(f" ✓ ATR: {atr:.6f} (> 0 확인)")
|
||||||
|
|
||||||
|
info = meta.get_meta_info()
|
||||||
|
print(f" ✓ Meta Info: {info}")
|
||||||
|
|
||||||
|
# ATR 범위 검증 (XRP 기준 0.0001 ~ 0.1)
|
||||||
|
assert 0.0001 <= atr <= 0.1, f"ATR 범위 이탈: {atr}"
|
||||||
|
print(f" ✓ ATR 범위 정상: 0.0001 <= {atr:.6f} <= 0.1")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await fetcher.close()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(" 모든 검증 통과 ✓")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_module_3_4():
|
||||||
|
"""
|
||||||
|
Module 3 + 4 통합 테스트.
|
||||||
|
|
||||||
|
검증 항목:
|
||||||
|
[Module 3 - TriggerStrategy]
|
||||||
|
1. 신호 생성: 'EXECUTE_LONG' | 'EXECUTE_SHORT' | 'HOLD' 중 하나 반환
|
||||||
|
2. EMA15: NaN 아님, 양수, 현실적 범위
|
||||||
|
3. Vol_SMA20: NaN 아님, 양수
|
||||||
|
4. vol_ratio: 0.0 ~ 2.0+ 범위 내
|
||||||
|
5. 3캔들 시퀀스: t-2, t-1, t 인덱싱 정확성
|
||||||
|
6. meta_state 필터: 'LONG_ALLOWED'에서만 LONG, 'SHORT_ALLOWED'에서만 SHORT
|
||||||
|
|
||||||
|
[Module 4 - ExecutionManager]
|
||||||
|
7. 포지션 중복 방지
|
||||||
|
8. SL/TP 계산: ATR * 1.5 (SL), ATR * 2.3 (TP)
|
||||||
|
9. Dry-run 로그 출력
|
||||||
|
10. 청산 후 재진입 가능
|
||||||
|
"""
|
||||||
|
print("=" * 60)
|
||||||
|
print(" MTF Bot Module 3 & 4 통합 테스트")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# ── DataFetcher로 실제 데이터 로드 ──
|
||||||
|
fetcher = DataFetcher(symbol="XRP/USDT:USDT")
|
||||||
|
try:
|
||||||
|
await fetcher.initialize()
|
||||||
|
|
||||||
|
df_15m = fetcher.get_15m_dataframe()
|
||||||
|
assert df_15m is not None and len(df_15m) >= 25, "15m 데이터 부족"
|
||||||
|
|
||||||
|
meta = MetaFilter(fetcher)
|
||||||
|
meta_state = meta.get_market_state()
|
||||||
|
atr = meta.get_current_atr()
|
||||||
|
print(f"\n[환경] MetaFilter: {meta_state} | ATR: {atr}")
|
||||||
|
|
||||||
|
# ── [Module 3] TriggerStrategy 검증 ──
|
||||||
|
print("\n[1] TriggerStrategy 신호 생성")
|
||||||
|
trigger = TriggerStrategy()
|
||||||
|
|
||||||
|
# 테스트 1: 실제 데이터로 신호 생성
|
||||||
|
signal = trigger.generate_signal(df_15m, meta_state)
|
||||||
|
assert signal in ("EXECUTE_LONG", "EXECUTE_SHORT", "HOLD"), f"비정상 신호: {signal}"
|
||||||
|
print(f" ✓ 신호: {signal}")
|
||||||
|
|
||||||
|
info = trigger.get_trigger_info()
|
||||||
|
print(f" ✓ Trigger Info: {info}")
|
||||||
|
|
||||||
|
# 테스트 2: 지표 값 검증
|
||||||
|
if "ema15_t" in info:
|
||||||
|
assert not pd.isna(info["ema15_t"]) and info["ema15_t"] > 0, "EMA15 비정상"
|
||||||
|
assert not pd.isna(info["vol_sma20_t2"]) and info["vol_sma20_t2"] > 0, "Vol SMA20 비정상"
|
||||||
|
assert 0 <= info["vol_ratio"] <= 100, f"vol_ratio 비정상: {info['vol_ratio']}"
|
||||||
|
print(f" ✓ EMA15(t): {info['ema15_t']:.4f}")
|
||||||
|
print(f" ✓ Vol SMA20(t-2): {info['vol_sma20_t2']:.0f}")
|
||||||
|
print(f" ✓ Vol ratio: {info['vol_ratio']:.4f} ({'고갈' if info['vol_ratio'] < 0.5 else '정상'})")
|
||||||
|
|
||||||
|
# 테스트 3: meta_state=WAIT → 무조건 HOLD
|
||||||
|
signal_wait = trigger.generate_signal(df_15m, "WAIT")
|
||||||
|
assert signal_wait == "HOLD", "WAIT 상태에서 HOLD 아닌 신호 발생"
|
||||||
|
print(f" ✓ meta_state=WAIT → {signal_wait}")
|
||||||
|
|
||||||
|
# 테스트 4: 데이터 부족 → HOLD
|
||||||
|
signal_short = trigger.generate_signal(df_15m.iloc[:10], "LONG_ALLOWED")
|
||||||
|
assert signal_short == "HOLD", "데이터 부족에서 HOLD 아닌 신호 발생"
|
||||||
|
print(f" ✓ 데이터 부족(10행) → {signal_short}")
|
||||||
|
|
||||||
|
# 테스트 5: None DataFrame → HOLD
|
||||||
|
signal_none = trigger.generate_signal(None, "LONG_ALLOWED")
|
||||||
|
assert signal_none == "HOLD"
|
||||||
|
print(f" ✓ None DataFrame → HOLD")
|
||||||
|
|
||||||
|
# ── [Module 4] ExecutionManager 검증 ──
|
||||||
|
print(f"\n[2] ExecutionManager 검증")
|
||||||
|
executor = ExecutionManager()
|
||||||
|
|
||||||
|
# 테스트 6: HOLD → None
|
||||||
|
result = executor.execute("HOLD", 2.5, 0.01)
|
||||||
|
assert result is None, "HOLD에서 주문 실행됨"
|
||||||
|
print(f" ✓ HOLD → None")
|
||||||
|
|
||||||
|
# 테스트 7: ATR 무효 → None
|
||||||
|
result = executor.execute("EXECUTE_LONG", 2.5, None)
|
||||||
|
assert result is None, "ATR=None에서 주문 실행됨"
|
||||||
|
result = executor.execute("EXECUTE_LONG", 2.5, 0)
|
||||||
|
assert result is None, "ATR=0에서 주문 실행됨"
|
||||||
|
print(f" ✓ ATR 무효 → None")
|
||||||
|
|
||||||
|
# 테스트 8: 정상 LONG 주문
|
||||||
|
print(f"\n [LONG 가상 주문 테스트]")
|
||||||
|
test_atr = 0.01
|
||||||
|
result = executor.execute("EXECUTE_LONG", 2.5340, test_atr)
|
||||||
|
assert result is not None, "정상 주문이 None 반환"
|
||||||
|
assert result["action"] == "LONG"
|
||||||
|
assert abs(result["sl_price"] - (2.5340 - 0.01 * 1.5)) < 1e-8, "SL 계산 오류"
|
||||||
|
assert abs(result["tp_price"] - (2.5340 + 0.01 * 2.3)) < 1e-8, "TP 계산 오류"
|
||||||
|
assert result["risk_reward"] == 1.53, f"R:R 오류: {result['risk_reward']}"
|
||||||
|
print(f" ✓ LONG 주문: entry={result['entry_price']}, SL={result['sl_price']:.4f}, TP={result['tp_price']:.4f}")
|
||||||
|
print(f" ✓ R:R = 1:{result['risk_reward']}")
|
||||||
|
|
||||||
|
# 테스트 9: 포지션 중복 방지
|
||||||
|
result_dup = executor.execute("EXECUTE_SHORT", 2.5000, test_atr)
|
||||||
|
assert result_dup is None, "중복 포지션 허용됨"
|
||||||
|
assert executor.current_position == "LONG", "포지션 상태 변경됨"
|
||||||
|
print(f" ✓ 중복 차단: LONG 포지션 중 SHORT 신호 → None")
|
||||||
|
|
||||||
|
# 테스트 10: 청산 후 재진입
|
||||||
|
executor.close_position("테스트 청산")
|
||||||
|
assert executor.current_position is None, "청산 후 포지션 잔존"
|
||||||
|
print(f" ✓ 청산 완료, 포지션=None")
|
||||||
|
|
||||||
|
# 테스트 11: SHORT 주문
|
||||||
|
print(f"\n [SHORT 가상 주문 테스트]")
|
||||||
|
result_short = executor.execute("EXECUTE_SHORT", 2.5340, test_atr)
|
||||||
|
assert result_short is not None
|
||||||
|
assert result_short["action"] == "SHORT"
|
||||||
|
assert abs(result_short["sl_price"] - (2.5340 + 0.01 * 1.5)) < 1e-8, "SHORT SL 오류"
|
||||||
|
assert abs(result_short["tp_price"] - (2.5340 - 0.01 * 2.3)) < 1e-8, "SHORT TP 오류"
|
||||||
|
print(f" ✓ SHORT 주문: entry={result_short['entry_price']}, SL={result_short['sl_price']:.4f}, TP={result_short['tp_price']:.4f}")
|
||||||
|
|
||||||
|
executor.close_position("테스트 종료")
|
||||||
|
|
||||||
|
# 테스트 12: 빈 포지션 청산 → 에러 없이 처리
|
||||||
|
executor.close_position("이미 청산됨")
|
||||||
|
print(f" ✓ 빈 포지션 청산 → 에러 없음")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await fetcher.close()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(" Module 3 & 4 모든 검증 통과 ✓")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_all():
|
||||||
|
"""Module 1~4 전체 검증."""
|
||||||
|
await test_module_1_2()
|
||||||
|
print("\n")
|
||||||
|
await test_module_3_4()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(test_all())
|
||||||
@@ -6,12 +6,15 @@ from loguru import logger
|
|||||||
class DiscordNotifier:
|
class DiscordNotifier:
|
||||||
"""Discord 웹훅으로 거래 알림을 전송하는 노티파이어."""
|
"""Discord 웹훅으로 거래 알림을 전송하는 노티파이어."""
|
||||||
|
|
||||||
def __init__(self, webhook_url: str):
|
def __init__(self, webhook_url: str, testnet: bool = False):
|
||||||
self.webhook_url = webhook_url
|
self.webhook_url = webhook_url
|
||||||
self._enabled = bool(webhook_url)
|
self._enabled = bool(webhook_url)
|
||||||
|
self._testnet = testnet
|
||||||
|
|
||||||
def _send(self, content: str) -> None:
|
def _send(self, content: str) -> None:
|
||||||
"""알림 전송. 이벤트 루프 내에서는 백그라운드 스레드로 실행하여 블로킹 방지."""
|
"""알림 전송. 이벤트 루프 내에서는 백그라운드 스레드로 실행하여 블로킹 방지."""
|
||||||
|
if self._testnet:
|
||||||
|
content = f"[TESTNET] {content}"
|
||||||
if not self._enabled:
|
if not self._enabled:
|
||||||
logger.debug("Discord 웹훅 URL 미설정 - 알림 건너뜀")
|
logger.debug("Discord 웹훅 URL 미설정 - 알림 건너뜀")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class UserDataStream:
|
|||||||
"""
|
"""
|
||||||
Binance Futures User Data Stream을 구독하여 주문 체결 이벤트를 처리한다.
|
Binance Futures User Data Stream을 구독하여 주문 체결 이벤트를 처리한다.
|
||||||
|
|
||||||
- python-binance BinanceSocketManager의 내장 keepalive 활용
|
- 매 재연결마다 AsyncClient + BinanceSocketManager를 새로 생성 (listenKey 무효화 대응)
|
||||||
- 네트워크 단절 시 무한 재연결 루프
|
- 네트워크 단절 시 무한 재연결 루프
|
||||||
- ORDER_TRADE_UPDATE 이벤트에서 지정 심볼의 청산 주문만 필터링하여 콜백 호출
|
- ORDER_TRADE_UPDATE 이벤트에서 지정 심볼의 청산 주문만 필터링하여 콜백 호출
|
||||||
- 부분 체결(PARTIALLY_FILLED) 시 rp/commission을 누적하여 최종 FILLED에서 합산 콜백
|
- 부분 체결(PARTIALLY_FILLED) 시 rp/commission을 누적하여 최종 FILLED에서 합산 콜백
|
||||||
@@ -28,22 +28,26 @@ class UserDataStream:
|
|||||||
# 부분 체결 누적용: order_id → {rp, commission}
|
# 부분 체결 누적용: order_id → {rp, commission}
|
||||||
self._partial_fills: dict[int, dict[str, float]] = {}
|
self._partial_fills: dict[int, dict[str, float]] = {}
|
||||||
|
|
||||||
async def start(self, api_key: str, api_secret: str) -> None:
|
async def start(self, api_key: str, api_secret: str, testnet: bool = False) -> None:
|
||||||
"""User Data Stream 메인 루프 — 봇 종료 시까지 실행."""
|
"""User Data Stream 메인 루프 — 봇 종료 시까지 실행."""
|
||||||
client = await AsyncClient.create(
|
await self._run_loop(api_key, api_secret, testnet)
|
||||||
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:
|
async def _run_loop(self, api_key: str, api_secret: str, testnet: bool = False) -> None:
|
||||||
"""연결 → 재연결 무한 루프. BinanceSocketManager가 listenKey keepalive를 내부 처리한다."""
|
"""연결 → 재연결 무한 루프.
|
||||||
|
|
||||||
|
매 재연결마다 AsyncClient + BinanceSocketManager를 새로 생성한다.
|
||||||
|
keepalive ping timeout 후 기존 BinanceSocketManager의 listenKey가
|
||||||
|
무효화되면 재사용 시 이벤트를 수신하지 못하는 "조용한 실패"가 발생하므로,
|
||||||
|
반드시 새 인스턴스를 만들어야 한다.
|
||||||
|
"""
|
||||||
while True:
|
while True:
|
||||||
|
client = await AsyncClient.create(
|
||||||
|
api_key=api_key,
|
||||||
|
api_secret=api_secret,
|
||||||
|
demo=testnet,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
|
bm = BinanceSocketManager(client)
|
||||||
async with bm.futures_user_socket() as stream:
|
async with bm.futures_user_socket() as stream:
|
||||||
logger.info(f"User Data Stream 연결 완료 (심볼 필터: {self._symbol})")
|
logger.info(f"User Data Stream 연결 완료 (심볼 필터: {self._symbol})")
|
||||||
while True:
|
while True:
|
||||||
@@ -60,6 +64,10 @@ class UserDataStream:
|
|||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info("User Data Stream 정상 종료")
|
logger.info("User Data Stream 정상 종료")
|
||||||
|
try:
|
||||||
|
await client.close_connection()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
raise
|
raise
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -67,7 +75,13 @@ class UserDataStream:
|
|||||||
f"User Data Stream 끊김: {e} — "
|
f"User Data Stream 끊김: {e} — "
|
||||||
f"{_RECONNECT_DELAY}초 후 재연결"
|
f"{_RECONNECT_DELAY}초 후 재연결"
|
||||||
)
|
)
|
||||||
await asyncio.sleep(_RECONNECT_DELAY)
|
finally:
|
||||||
|
try:
|
||||||
|
await client.close_connection()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await asyncio.sleep(_RECONNECT_DELAY)
|
||||||
|
|
||||||
async def _handle_message(self, msg: dict) -> None:
|
async def _handle_message(self, msg: dict) -> None:
|
||||||
"""ORDER_TRADE_UPDATE 이벤트에서 청산 주문을 필터링하여 콜백을 호출한다."""
|
"""ORDER_TRADE_UPDATE 이벤트에서 청산 주문을 필터링하여 콜백을 호출한다."""
|
||||||
@@ -80,12 +94,19 @@ class UserDataStream:
|
|||||||
if order.get("s", "") != self._symbol:
|
if order.get("s", "") != self._symbol:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[{self._symbol}] UDS 원본: s={order.get('s')} o={order.get('o')} "
|
||||||
|
f"ot={order.get('ot')} x={order.get('x')} X={order.get('X')} "
|
||||||
|
f"R={order.get('R')} S={order.get('S')} ap={order.get('ap')} "
|
||||||
|
f"rp={order.get('rp')}"
|
||||||
|
)
|
||||||
|
|
||||||
# x: Execution Type — TRADE만 처리
|
# x: Execution Type — TRADE만 처리
|
||||||
if order.get("x") != "TRADE":
|
if order.get("x") != "TRADE":
|
||||||
return
|
return
|
||||||
|
|
||||||
order_status = order.get("X", "")
|
order_status = order.get("X", "")
|
||||||
order_type = order.get("o", "")
|
order_type = order.get("ot", order.get("o", ""))
|
||||||
is_reduce = order.get("R", False)
|
is_reduce = order.get("R", False)
|
||||||
order_id = order.get("i", 0)
|
order_id = order.get("i", 0)
|
||||||
|
|
||||||
@@ -94,6 +115,12 @@ class UserDataStream:
|
|||||||
if not is_close:
|
if not is_close:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[{self._symbol}] 청산 주문 상세: "
|
||||||
|
f"type={order_type}, status={order_status}, "
|
||||||
|
f"reduce={is_reduce}, id={order_id}"
|
||||||
|
)
|
||||||
|
|
||||||
fill_rp = float(order.get("rp", "0"))
|
fill_rp = float(order.get("rp", "0"))
|
||||||
fill_commission = abs(float(order.get("n", "0")))
|
fill_commission = abs(float(order.get("n", "0")))
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ def config():
|
|||||||
"NOTION_TOKEN": "secret_test",
|
"NOTION_TOKEN": "secret_test",
|
||||||
"NOTION_DATABASE_ID": "db_test",
|
"NOTION_DATABASE_ID": "db_test",
|
||||||
"DISCORD_WEBHOOK_URL": "",
|
"DISCORD_WEBHOOK_URL": "",
|
||||||
|
"BINANCE_TESTNET": "false",
|
||||||
})
|
})
|
||||||
return Config()
|
return Config()
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ def sample_df():
|
|||||||
"low": close * 0.995,
|
"low": close * 0.995,
|
||||||
"close": close,
|
"close": close,
|
||||||
"volume": np.random.randint(100000, 1000000, n).astype(float),
|
"volume": np.random.randint(100000, 1000000, n).astype(float),
|
||||||
|
"atr": np.full(n, 0.005),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -73,25 +73,6 @@ def test_generate_dataset_vectorized_with_btc_eth_has_21_feature_cols():
|
|||||||
assert "label" in result.columns
|
assert "label" in result.columns
|
||||||
|
|
||||||
|
|
||||||
def test_matches_original_generate_dataset(sample_df):
|
|
||||||
"""벡터화 버전과 기존 버전의 샘플 수가 유사해야 한다.
|
|
||||||
|
|
||||||
벡터화 버전은 전체 시계열로 지표를 1회 계산하고, 기존 버전은 61행 슬라이딩
|
|
||||||
윈도우로 매번 재계산한다. EMA 등 지수 이동평균은 초기값에 따라 수렴 속도가
|
|
||||||
달라지므로 두 방식의 신호 수는 완전히 동일하지 않을 수 있다. ±50% 범위를
|
|
||||||
허용한다.
|
|
||||||
"""
|
|
||||||
from scripts.train_model import generate_dataset
|
|
||||||
orig = generate_dataset(sample_df, n_jobs=1)
|
|
||||||
vec = generate_dataset_vectorized(sample_df)
|
|
||||||
if len(orig) == 0:
|
|
||||||
assert len(vec) == 0
|
|
||||||
return
|
|
||||||
ratio = len(vec) / len(orig)
|
|
||||||
assert 0.5 <= ratio <= 2.0, (
|
|
||||||
f"샘플 수 차이가 너무 큼: 벡터화={len(vec)}, 기존={len(orig)}, 비율={ratio:.2f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_epsilon_no_division_by_zero():
|
def test_epsilon_no_division_by_zero():
|
||||||
"""bb_range=0, close=0, vol_ma20=0 극단값에서 nan/inf가 발생하지 않아야 한다."""
|
"""bb_range=0, close=0, vol_ma20=0 극단값에서 nan/inf가 발생하지 않아야 한다."""
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ def test_no_model_should_enter_returns_true(tmp_path):
|
|||||||
assert f.should_enter(features) is True
|
assert f.should_enter(features) is True
|
||||||
|
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"NO_ML_FILTER": "false"})
|
||||||
def test_should_enter_above_threshold():
|
def test_should_enter_above_threshold():
|
||||||
"""확률 >= 0.60 이면 True"""
|
"""확률 >= 0.60 이면 True"""
|
||||||
f = MLFilter(threshold=0.60)
|
f = MLFilter(threshold=0.60)
|
||||||
@@ -40,6 +41,7 @@ def test_should_enter_above_threshold():
|
|||||||
assert f.should_enter(features) is True
|
assert f.should_enter(features) is True
|
||||||
|
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"NO_ML_FILTER": "false"})
|
||||||
def test_should_enter_below_threshold():
|
def test_should_enter_below_threshold():
|
||||||
"""확률 < 0.60 이면 False"""
|
"""확률 < 0.60 이면 False"""
|
||||||
f = MLFilter(threshold=0.60)
|
f = MLFilter(threshold=0.60)
|
||||||
|
|||||||
162
tests/test_ml_pipeline_fixes.py
Normal file
162
tests/test_ml_pipeline_fixes.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
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_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)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sltp_params_are_passed_through(signal_df):
|
||||||
|
"""SL/TP 배수가 generate_dataset_vectorized에 전달되어야 한다."""
|
||||||
|
# 파라미터가 수용되는지(TypeError 없이) 확인하는 것이 핵심
|
||||||
|
# negative_ratio=0으로 시그널 샘플만 비교 (HOLD 노이즈 제거)
|
||||||
|
r1 = generate_dataset_vectorized(
|
||||||
|
signal_df, atr_sl_mult=1.5, atr_tp_mult=2.0,
|
||||||
|
adx_threshold=0, volume_multiplier=1.5, negative_ratio=0,
|
||||||
|
)
|
||||||
|
r2 = generate_dataset_vectorized(
|
||||||
|
signal_df, atr_sl_mult=2.0, atr_tp_mult=2.0,
|
||||||
|
adx_threshold=0, volume_multiplier=1.5, negative_ratio=0,
|
||||||
|
)
|
||||||
|
# 두 결과 모두 DataFrame이어야 한다
|
||||||
|
assert isinstance(r1, pd.DataFrame)
|
||||||
|
assert isinstance(r2, pd.DataFrame)
|
||||||
|
# 신호가 충분히 많을 경우, 다른 SL 배수는 레이블 분포에 영향을 줄 수 있다
|
||||||
|
# 소규모 데이터에서는 동일한 결과가 나올 수 있으므로 50개 이상일 때만 검증
|
||||||
|
if len(r1) > 50 and len(r2) > 50:
|
||||||
|
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 파라미터 미지정 시 기본값(2.0, 2.0)으로 동작해야 한다."""
|
||||||
|
r_default = generate_dataset_vectorized(
|
||||||
|
signal_df, adx_threshold=0, volume_multiplier=1.5, negative_ratio=0,
|
||||||
|
)
|
||||||
|
r_explicit = generate_dataset_vectorized(
|
||||||
|
signal_df, atr_sl_mult=2.0, atr_tp_mult=2.0,
|
||||||
|
adx_threshold=0, volume_multiplier=1.5, negative_ratio=0,
|
||||||
|
)
|
||||||
|
if len(r_default) > 0:
|
||||||
|
assert len(r_default) == len(r_explicit)
|
||||||
|
assert (r_default["label"].values == r_explicit["label"].values).all()
|
||||||
|
|
||||||
|
|
||||||
|
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 = []
|
||||||
|
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,
|
||||||
|
)}
|
||||||
|
|
||||||
|
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']}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mlx_no_double_normalization():
|
||||||
|
"""MLXFilter.fit()에 normalize=False를 전달하면 내부 정규화를 건너뛰어야 한다."""
|
||||||
|
pytest.importorskip("mlx.core")
|
||||||
|
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)
|
||||||
|
|
||||||
|
assert np.allclose(model._mean, 0.0), "normalize=False시 mean은 0이어야 한다"
|
||||||
|
assert np.allclose(model._std, 1.0), "normalize=False시 std는 1이어야 한다"
|
||||||
|
|
||||||
|
|
||||||
|
def test_walk_forward_purged_gap():
|
||||||
|
"""Walk-Forward 검증에서 학습/검증 사이에 LOOKAHEAD 만큼의 gap이 존재해야 한다."""
|
||||||
|
from src.dataset_builder import LOOKAHEAD
|
||||||
|
|
||||||
|
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
|
||||||
|
val_end = val_start + step
|
||||||
|
if val_end > n:
|
||||||
|
break
|
||||||
|
|
||||||
|
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}"
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
423
tests/test_mtf_bot.py
Normal file
423
tests/test_mtf_bot.py
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
"""
|
||||||
|
MTF Pullback Bot 유닛 테스트
|
||||||
|
─────────────────────────────
|
||||||
|
합성 데이터 기반, 외부 API 호출 없음.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.mtf_bot import (
|
||||||
|
DataFetcher,
|
||||||
|
ExecutionManager,
|
||||||
|
MetaFilter,
|
||||||
|
TriggerStrategy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fixtures ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_1h_df():
|
||||||
|
"""EMA50/200, ADX, ATR 계산에 충분한 250개 1h 합성 캔들."""
|
||||||
|
np.random.seed(42)
|
||||||
|
n = 250
|
||||||
|
# 완만한 상승 추세 (EMA50 > EMA200이 되도록)
|
||||||
|
close = np.cumsum(np.random.randn(n) * 0.001 + 0.0005) + 2.0
|
||||||
|
high = close + np.abs(np.random.randn(n)) * 0.005
|
||||||
|
low = close - np.abs(np.random.randn(n)) * 0.005
|
||||||
|
open_ = close + np.random.randn(n) * 0.001
|
||||||
|
|
||||||
|
# 완성된 캔들 timestamp (1h 간격, 과거 시점)
|
||||||
|
base_ts = pd.Timestamp("2026-01-01", tz="UTC")
|
||||||
|
timestamps = pd.date_range(start=base_ts, periods=n, freq="1h")
|
||||||
|
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"open": open_,
|
||||||
|
"high": high,
|
||||||
|
"low": low,
|
||||||
|
"close": close,
|
||||||
|
"volume": np.random.randint(100000, 1000000, n).astype(float),
|
||||||
|
}, index=timestamps)
|
||||||
|
df.index.name = "timestamp"
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_15m_df():
|
||||||
|
"""TriggerStrategy용 50개 15m 합성 캔들."""
|
||||||
|
np.random.seed(99)
|
||||||
|
n = 50
|
||||||
|
close = np.cumsum(np.random.randn(n) * 0.001) + 0.5
|
||||||
|
high = close + np.abs(np.random.randn(n)) * 0.003
|
||||||
|
low = close - np.abs(np.random.randn(n)) * 0.003
|
||||||
|
open_ = close + np.random.randn(n) * 0.001
|
||||||
|
|
||||||
|
base_ts = pd.Timestamp("2026-01-01", tz="UTC")
|
||||||
|
timestamps = pd.date_range(start=base_ts, periods=n, freq="15min")
|
||||||
|
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"open": open_,
|
||||||
|
"high": high,
|
||||||
|
"low": low,
|
||||||
|
"close": close,
|
||||||
|
"volume": np.random.randint(100000, 1000000, n).astype(float),
|
||||||
|
}, index=timestamps)
|
||||||
|
df.index.name = "timestamp"
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Test 1: _remove_incomplete_candle
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoveIncompleteCandle:
|
||||||
|
"""DataFetcher._remove_incomplete_candle 정적 메서드 테스트."""
|
||||||
|
|
||||||
|
def test_removes_incomplete_15m_candle(self):
|
||||||
|
"""현재 15m 슬롯에 해당하는 미완성 캔들은 제거되어야 한다."""
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
current_slot_ms = (now_ms // (900 * 1000)) * (900 * 1000)
|
||||||
|
|
||||||
|
# 완성 캔들 2개 + 미완성 캔들 1개
|
||||||
|
timestamps = [
|
||||||
|
pd.Timestamp(current_slot_ms - 1800_000, unit="ms", tz="UTC"), # 2슬롯 전
|
||||||
|
pd.Timestamp(current_slot_ms - 900_000, unit="ms", tz="UTC"), # 1슬롯 전
|
||||||
|
pd.Timestamp(current_slot_ms, unit="ms", tz="UTC"), # 현재 슬롯 (미완성)
|
||||||
|
]
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"open": [1.0, 1.1, 1.2],
|
||||||
|
"high": [1.05, 1.15, 1.25],
|
||||||
|
"low": [0.95, 1.05, 1.15],
|
||||||
|
"close": [1.02, 1.12, 1.22],
|
||||||
|
"volume": [100.0, 200.0, 300.0],
|
||||||
|
}, index=timestamps)
|
||||||
|
|
||||||
|
result = DataFetcher._remove_incomplete_candle(df, interval_sec=900)
|
||||||
|
assert len(result) == 2, f"미완성 캔들 제거 실패: {len(result)}개 (2개 예상)"
|
||||||
|
|
||||||
|
def test_keeps_all_completed_candles(self):
|
||||||
|
"""모든 캔들이 완성된 경우 제거하지 않아야 한다."""
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
current_slot_ms = (now_ms // (900 * 1000)) * (900 * 1000)
|
||||||
|
|
||||||
|
# 모두 과거 슬롯의 완성 캔들
|
||||||
|
timestamps = [
|
||||||
|
pd.Timestamp(current_slot_ms - 2700_000, unit="ms", tz="UTC"),
|
||||||
|
pd.Timestamp(current_slot_ms - 1800_000, unit="ms", tz="UTC"),
|
||||||
|
pd.Timestamp(current_slot_ms - 900_000, unit="ms", tz="UTC"),
|
||||||
|
]
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"open": [1.0, 1.1, 1.2],
|
||||||
|
"high": [1.05, 1.15, 1.25],
|
||||||
|
"low": [0.95, 1.05, 1.15],
|
||||||
|
"close": [1.02, 1.12, 1.22],
|
||||||
|
"volume": [100.0, 200.0, 300.0],
|
||||||
|
}, index=timestamps)
|
||||||
|
|
||||||
|
result = DataFetcher._remove_incomplete_candle(df, interval_sec=900)
|
||||||
|
assert len(result) == 3, f"완성 캔들 유지 실패: {len(result)}개 (3개 예상)"
|
||||||
|
|
||||||
|
def test_empty_dataframe(self):
|
||||||
|
"""빈 DataFrame 입력 시 빈 DataFrame 반환."""
|
||||||
|
df = pd.DataFrame(columns=["open", "high", "low", "close", "volume"])
|
||||||
|
result = DataFetcher._remove_incomplete_candle(df, interval_sec=900)
|
||||||
|
assert result.empty
|
||||||
|
|
||||||
|
def test_1h_interval(self):
|
||||||
|
"""1h 간격에서도 정상 동작."""
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
current_slot_ms = (now_ms // (3600 * 1000)) * (3600 * 1000)
|
||||||
|
|
||||||
|
timestamps = [
|
||||||
|
pd.Timestamp(current_slot_ms - 7200_000, unit="ms", tz="UTC"),
|
||||||
|
pd.Timestamp(current_slot_ms - 3600_000, unit="ms", tz="UTC"),
|
||||||
|
pd.Timestamp(current_slot_ms, unit="ms", tz="UTC"), # 현재 슬롯 (미완성)
|
||||||
|
]
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"open": [1.0, 1.1, 1.2],
|
||||||
|
"high": [1.05, 1.15, 1.25],
|
||||||
|
"low": [0.95, 1.05, 1.15],
|
||||||
|
"close": [1.02, 1.12, 1.22],
|
||||||
|
"volume": [100.0, 200.0, 300.0],
|
||||||
|
}, index=timestamps)
|
||||||
|
|
||||||
|
result = DataFetcher._remove_incomplete_candle(df, interval_sec=3600)
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Test 2: MetaFilter
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestMetaFilter:
|
||||||
|
"""MetaFilter 상태 판별 로직 테스트."""
|
||||||
|
|
||||||
|
def _make_fetcher_with_df(self, df_1h):
|
||||||
|
"""Mock DataFetcher를 생성하여 특정 1h DataFrame을 반환하도록 설정."""
|
||||||
|
fetcher = DataFetcher.__new__(DataFetcher)
|
||||||
|
fetcher.klines_15m = []
|
||||||
|
fetcher.klines_1h = []
|
||||||
|
fetcher.data_fetcher = None
|
||||||
|
# get_1h_dataframe_completed 을 직접 패치
|
||||||
|
fetcher.get_1h_dataframe_completed = lambda: df_1h
|
||||||
|
return fetcher
|
||||||
|
|
||||||
|
def test_wait_when_adx_below_threshold(self, sample_1h_df):
|
||||||
|
"""ADX < 20이면 WAIT 상태."""
|
||||||
|
import pandas_ta as ta
|
||||||
|
|
||||||
|
df = sample_1h_df.copy()
|
||||||
|
# 변동성이 없는 flat 데이터 → ADX가 낮을 가능성 높음
|
||||||
|
df["close"] = 2.0 # 완전 flat
|
||||||
|
df["high"] = 2.001
|
||||||
|
df["low"] = 1.999
|
||||||
|
df["open"] = 2.0
|
||||||
|
|
||||||
|
fetcher = self._make_fetcher_with_df(df)
|
||||||
|
meta = MetaFilter(fetcher)
|
||||||
|
state = meta.get_market_state()
|
||||||
|
assert state == "WAIT", f"Flat 데이터에서 WAIT 아닌 상태: {state}"
|
||||||
|
|
||||||
|
def test_long_allowed_when_uptrend(self):
|
||||||
|
"""EMA50 > EMA200 + ADX > 20이면 LONG_ALLOWED."""
|
||||||
|
np.random.seed(10)
|
||||||
|
n = 250
|
||||||
|
# 강한 상승 추세
|
||||||
|
close = np.linspace(1.0, 3.0, n) + np.random.randn(n) * 0.01
|
||||||
|
high = close + 0.02
|
||||||
|
low = close - 0.02
|
||||||
|
open_ = close - 0.005
|
||||||
|
|
||||||
|
base_ts = pd.Timestamp("2025-01-01", tz="UTC")
|
||||||
|
timestamps = pd.date_range(start=base_ts, periods=n, freq="1h")
|
||||||
|
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"open": open_, "high": high, "low": low,
|
||||||
|
"close": close, "volume": np.ones(n) * 500000,
|
||||||
|
}, index=timestamps)
|
||||||
|
|
||||||
|
fetcher = self._make_fetcher_with_df(df)
|
||||||
|
meta = MetaFilter(fetcher)
|
||||||
|
state = meta.get_market_state()
|
||||||
|
assert state == "LONG_ALLOWED", f"강한 상승 추세에서 LONG_ALLOWED 아닌 상태: {state}"
|
||||||
|
|
||||||
|
def test_short_allowed_when_downtrend(self):
|
||||||
|
"""EMA50 < EMA200 + ADX > 20이면 SHORT_ALLOWED."""
|
||||||
|
np.random.seed(20)
|
||||||
|
n = 250
|
||||||
|
# 강한 하락 추세
|
||||||
|
close = np.linspace(3.0, 1.0, n) + np.random.randn(n) * 0.01
|
||||||
|
high = close + 0.02
|
||||||
|
low = close - 0.02
|
||||||
|
open_ = close + 0.005
|
||||||
|
|
||||||
|
base_ts = pd.Timestamp("2025-01-01", tz="UTC")
|
||||||
|
timestamps = pd.date_range(start=base_ts, periods=n, freq="1h")
|
||||||
|
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"open": open_, "high": high, "low": low,
|
||||||
|
"close": close, "volume": np.ones(n) * 500000,
|
||||||
|
}, index=timestamps)
|
||||||
|
|
||||||
|
fetcher = self._make_fetcher_with_df(df)
|
||||||
|
meta = MetaFilter(fetcher)
|
||||||
|
state = meta.get_market_state()
|
||||||
|
assert state == "SHORT_ALLOWED", f"강한 하락 추세에서 SHORT_ALLOWED 아닌 상태: {state}"
|
||||||
|
|
||||||
|
def test_indicator_caching(self, sample_1h_df):
|
||||||
|
"""동일 캔들에 대해 _calc_indicators가 캐시를 재사용하는지 확인."""
|
||||||
|
fetcher = self._make_fetcher_with_df(sample_1h_df)
|
||||||
|
meta = MetaFilter(fetcher)
|
||||||
|
|
||||||
|
# 첫 호출: 캐시 없음
|
||||||
|
df1 = meta._calc_indicators(sample_1h_df)
|
||||||
|
ts1 = meta._cache_timestamp
|
||||||
|
|
||||||
|
# 두 번째 호출: 동일 DataFrame → 캐시 히트
|
||||||
|
df2 = meta._calc_indicators(sample_1h_df)
|
||||||
|
assert df1 is df2, "동일 데이터에 대해 캐시가 재사용되지 않음"
|
||||||
|
assert meta._cache_timestamp == ts1
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Test 3: TriggerStrategy
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestTriggerStrategy:
|
||||||
|
"""15m 3-candle pullback 시퀀스 감지 테스트."""
|
||||||
|
|
||||||
|
def test_hold_when_meta_wait(self, sample_15m_df):
|
||||||
|
"""meta_state=WAIT이면 항상 HOLD."""
|
||||||
|
trigger = TriggerStrategy()
|
||||||
|
signal = trigger.generate_signal(sample_15m_df, "WAIT")
|
||||||
|
assert signal == "HOLD"
|
||||||
|
|
||||||
|
def test_hold_when_insufficient_data(self):
|
||||||
|
"""데이터가 25개 미만이면 HOLD."""
|
||||||
|
trigger = TriggerStrategy()
|
||||||
|
small_df = pd.DataFrame({
|
||||||
|
"open": [1.0] * 10,
|
||||||
|
"high": [1.1] * 10,
|
||||||
|
"low": [0.9] * 10,
|
||||||
|
"close": [1.0] * 10,
|
||||||
|
"volume": [100.0] * 10,
|
||||||
|
})
|
||||||
|
signal = trigger.generate_signal(small_df, "LONG_ALLOWED")
|
||||||
|
assert signal == "HOLD"
|
||||||
|
|
||||||
|
def test_long_pullback_signal(self):
|
||||||
|
"""LONG 풀백 시퀀스: t-1 EMA 아래 이탈 + 거래량 고갈 + t EMA 복귀."""
|
||||||
|
np.random.seed(42)
|
||||||
|
n = 30
|
||||||
|
# 기본 상승 추세
|
||||||
|
close = np.linspace(1.0, 1.1, n)
|
||||||
|
high = close + 0.005
|
||||||
|
low = close - 0.005
|
||||||
|
open_ = close - 0.001
|
||||||
|
volume = np.ones(n) * 100000
|
||||||
|
|
||||||
|
# t-1 (인덱스 -2): EMA 아래로 이탈 + 거래량 고갈
|
||||||
|
close[-2] = close[-3] - 0.02 # EMA 아래로 이탈
|
||||||
|
volume[-2] = 5000 # 매우 낮은 거래량
|
||||||
|
|
||||||
|
# t (인덱스 -1): EMA 위로 복귀
|
||||||
|
close[-1] = close[-3] + 0.01
|
||||||
|
|
||||||
|
base_ts = pd.Timestamp("2026-01-01", tz="UTC")
|
||||||
|
timestamps = pd.date_range(start=base_ts, periods=n, freq="15min")
|
||||||
|
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"open": open_, "high": high, "low": low,
|
||||||
|
"close": close, "volume": volume,
|
||||||
|
}, index=timestamps)
|
||||||
|
|
||||||
|
trigger = TriggerStrategy()
|
||||||
|
signal = trigger.generate_signal(df, "LONG_ALLOWED")
|
||||||
|
# 풀백 조건 충족 여부는 EMA 계산 결과에 따라 다를 수 있으므로
|
||||||
|
# 최소한 valid signal을 반환하는지 확인
|
||||||
|
assert signal in ("EXECUTE_LONG", "HOLD")
|
||||||
|
|
||||||
|
def test_short_pullback_signal(self):
|
||||||
|
"""SHORT 풀백 시퀀스: t-1 EMA 위로 이탈 + 거래량 고갈 + t EMA 아래 복귀."""
|
||||||
|
np.random.seed(42)
|
||||||
|
n = 30
|
||||||
|
# 하락 추세
|
||||||
|
close = np.linspace(1.1, 1.0, n)
|
||||||
|
high = close + 0.005
|
||||||
|
low = close - 0.005
|
||||||
|
open_ = close + 0.001
|
||||||
|
volume = np.ones(n) * 100000
|
||||||
|
|
||||||
|
# t-1: EMA 위로 이탈 + 거래량 고갈
|
||||||
|
close[-2] = close[-3] + 0.02
|
||||||
|
volume[-2] = 5000
|
||||||
|
|
||||||
|
# t: EMA 아래로 복귀
|
||||||
|
close[-1] = close[-3] - 0.01
|
||||||
|
|
||||||
|
base_ts = pd.Timestamp("2026-01-01", tz="UTC")
|
||||||
|
timestamps = pd.date_range(start=base_ts, periods=n, freq="15min")
|
||||||
|
|
||||||
|
df = pd.DataFrame({
|
||||||
|
"open": open_, "high": high, "low": low,
|
||||||
|
"close": close, "volume": volume,
|
||||||
|
}, index=timestamps)
|
||||||
|
|
||||||
|
trigger = TriggerStrategy()
|
||||||
|
signal = trigger.generate_signal(df, "SHORT_ALLOWED")
|
||||||
|
assert signal in ("EXECUTE_SHORT", "HOLD")
|
||||||
|
|
||||||
|
def test_trigger_info_populated(self, sample_15m_df):
|
||||||
|
"""generate_signal 후 get_trigger_info가 비어있지 않아야 한다."""
|
||||||
|
trigger = TriggerStrategy()
|
||||||
|
trigger.generate_signal(sample_15m_df, "LONG_ALLOWED")
|
||||||
|
info = trigger.get_trigger_info()
|
||||||
|
assert "signal" in info or "reason" in info
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Test 4: ExecutionManager (SL/TP 계산)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecutionManager:
|
||||||
|
"""ExecutionManager SL/TP 계산 및 포지션 관리 테스트."""
|
||||||
|
|
||||||
|
def test_long_sl_tp_calculation(self):
|
||||||
|
"""LONG 진입 시 SL = entry - ATR*1.5, TP = entry + ATR*2.3."""
|
||||||
|
em = ExecutionManager(symbol="XRPUSDT")
|
||||||
|
entry = 2.0
|
||||||
|
atr = 0.01
|
||||||
|
|
||||||
|
result = em.execute("EXECUTE_LONG", entry, atr)
|
||||||
|
assert result is not None
|
||||||
|
assert result["action"] == "LONG"
|
||||||
|
|
||||||
|
expected_sl = entry - (atr * 1.5)
|
||||||
|
expected_tp = entry + (atr * 2.3)
|
||||||
|
assert abs(result["sl_price"] - expected_sl) < 1e-8, f"SL: {result['sl_price']} != {expected_sl}"
|
||||||
|
assert abs(result["tp_price"] - expected_tp) < 1e-8, f"TP: {result['tp_price']} != {expected_tp}"
|
||||||
|
|
||||||
|
def test_short_sl_tp_calculation(self):
|
||||||
|
"""SHORT 진입 시 SL = entry + ATR*1.5, TP = entry - ATR*2.3."""
|
||||||
|
em = ExecutionManager(symbol="XRPUSDT")
|
||||||
|
entry = 2.0
|
||||||
|
atr = 0.01
|
||||||
|
|
||||||
|
result = em.execute("EXECUTE_SHORT", entry, atr)
|
||||||
|
assert result is not None
|
||||||
|
assert result["action"] == "SHORT"
|
||||||
|
|
||||||
|
expected_sl = entry + (atr * 1.5)
|
||||||
|
expected_tp = entry - (atr * 2.3)
|
||||||
|
assert abs(result["sl_price"] - expected_sl) < 1e-8
|
||||||
|
assert abs(result["tp_price"] - expected_tp) < 1e-8
|
||||||
|
|
||||||
|
def test_hold_returns_none(self):
|
||||||
|
"""HOLD 신호는 None 반환."""
|
||||||
|
em = ExecutionManager(symbol="XRPUSDT")
|
||||||
|
result = em.execute("HOLD", 2.0, 0.01)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_duplicate_position_blocked(self):
|
||||||
|
"""이미 포지션이 있으면 중복 진입 차단."""
|
||||||
|
em = ExecutionManager(symbol="XRPUSDT")
|
||||||
|
em.execute("EXECUTE_LONG", 2.0, 0.01)
|
||||||
|
|
||||||
|
result = em.execute("EXECUTE_SHORT", 2.1, 0.01)
|
||||||
|
assert result is None, "포지션 중복 차단 실패"
|
||||||
|
|
||||||
|
def test_reentry_after_close(self):
|
||||||
|
"""청산 후 재진입 가능."""
|
||||||
|
em = ExecutionManager(symbol="XRPUSDT")
|
||||||
|
em.execute("EXECUTE_LONG", 2.0, 0.01)
|
||||||
|
em.close_position("test", exit_price=2.01, pnl_bps=50)
|
||||||
|
|
||||||
|
result = em.execute("EXECUTE_SHORT", 2.05, 0.01)
|
||||||
|
assert result is not None, "청산 후 재진입 실패"
|
||||||
|
assert result["action"] == "SHORT"
|
||||||
|
|
||||||
|
def test_invalid_atr_blocked(self):
|
||||||
|
"""ATR이 None/0/NaN이면 주문 차단."""
|
||||||
|
em = ExecutionManager(symbol="XRPUSDT")
|
||||||
|
|
||||||
|
assert em.execute("EXECUTE_LONG", 2.0, None) is None
|
||||||
|
assert em.execute("EXECUTE_LONG", 2.0, 0) is None
|
||||||
|
assert em.execute("EXECUTE_LONG", 2.0, float("nan")) is None
|
||||||
|
|
||||||
|
def test_risk_reward_ratio(self):
|
||||||
|
"""R:R 비율이 올바르게 계산되는지 확인."""
|
||||||
|
em = ExecutionManager(symbol="XRPUSDT")
|
||||||
|
result = em.execute("EXECUTE_LONG", 2.0, 0.01)
|
||||||
|
# TP/SL = 2.3/1.5 = 1.533...
|
||||||
|
expected_rr = round(2.3 / 1.5, 2)
|
||||||
|
assert result["risk_reward"] == expected_rr
|
||||||
Reference in New Issue
Block a user