Compare commits
9 Commits
af865c3db2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6ba45f8de | ||
|
|
295ed7db76 | ||
|
|
ec7a6e427c | ||
|
|
f488720ca2 | ||
|
|
930d4d2c7a | ||
|
|
e31c4bf080 | ||
|
|
b8a371992f | ||
|
|
1cfb1b322a | ||
|
|
75b5c5d7fe |
239
ARCHITECTURE.md
239
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-파일-구조) — 전체 파일 역할 요약
|
||||||
|
|
||||||
@@ -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 파라미터 |
|
||||||
|
|||||||
@@ -151,3 +151,7 @@ All design documents and implementation plans are stored in `docs/plans/` with t
|
|||||||
| 2026-03-21 | `ml-validation-pipeline` (plan) | Completed |
|
| 2026-03-21 | `ml-validation-pipeline` (plan) | Completed |
|
||||||
| 2026-03-22 | `backtest-market-context` (design) | 설계 완료, 구현 대기 |
|
| 2026-03-22 | `backtest-market-context` (design) | 설계 완료, 구현 대기 |
|
||||||
| 2026-03-22 | `testnet-uds-verification` (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 검증 진행 중 |
|
||||||
|
|||||||
19
Jenkinsfile
vendored
19
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\/|scripts\/|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}"
|
||||||
}
|
}
|
||||||
@@ -126,6 +131,8 @@ pipeline {
|
|||||||
if (env.BOT_CHANGED == 'true') {
|
if (env.BOT_CHANGED == 'true') {
|
||||||
services.add('cointrader')
|
services.add('cointrader')
|
||||||
services.add('ls-ratio-collector')
|
services.add('ls-ratio-collector')
|
||||||
|
}
|
||||||
|
if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') {
|
||||||
services.add('mtf-bot')
|
services.add('mtf-bot')
|
||||||
}
|
}
|
||||||
if (env.DASH_API_CHANGED == 'true') services.add('dashboard-api')
|
if (env.DASH_API_CHANGED == 'true') services.add('dashboard-api')
|
||||||
@@ -145,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"
|
||||||
}
|
}
|
||||||
@@ -168,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}
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ Binance Futures 자동매매 봇. 복합 기술 지표와 킬스위치로 XRPUSD
|
|||||||
- **모니터링 대시보드**: 거래 내역, 수익 통계, 차트를 웹에서 조회
|
- **모니터링 대시보드**: 거래 내역, 수익 통계, 차트를 웹에서 조회
|
||||||
- **주간 전략 리포트**: 자동 성능 측정, 추이 추적, 킬스위치 모니터링, ML 재학습 시점 판단
|
- **주간 전략 리포트**: 자동 성능 측정, 추이 추적, 킬스위치 모니터링, ML 재학습 시점 판단
|
||||||
- **종목 비교 분석**: 심볼별 파라미터 sweep + Robust Monte Carlo 포지션 사이징
|
- **종목 비교 분석**: 심볼별 파라미터 sweep + Robust Monte Carlo 포지션 사이징
|
||||||
|
- **MTF Pullback Bot**: 1h MetaFilter(EMA50/200 + ADX) + 15m 3캔들 풀백 시퀀스 기반 Dry-run 봇 (OOS 검증용)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -278,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 로거 설정
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ services:
|
|||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
volumes:
|
volumes:
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
|
- ./data:/app/data
|
||||||
entrypoint: ["python", "main_mtf.py"]
|
entrypoint: ["python", "main_mtf.py"]
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
|
|||||||
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()
|
||||||
193
src/mtf_bot.py
193
src/mtf_bot.py
@@ -10,15 +10,17 @@ Module 4: ExecutionManager (Dry-run 가상 주문 + SL/TP 관리)
|
|||||||
- Look-ahead bias 원천 차단: 완성된 캔들만 사용 ([:-1] 슬라이싱)
|
- Look-ahead bias 원천 차단: 완성된 캔들만 사용 ([:-1] 슬라이싱)
|
||||||
- Binance 서버 딜레이 고려: 캔들 판별 시 2~5초 range
|
- Binance 서버 딜레이 고려: 캔들 판별 시 2~5초 range
|
||||||
- REST 폴링 기반 안정성: WebSocket 대신 30초 주기 폴링
|
- REST 폴링 기반 안정성: WebSocket 대신 30초 주기 폴링
|
||||||
- 메모리 최적화: deque(maxlen=200)
|
- 메모리 최적화: deque(maxlen=250)
|
||||||
- Dry-run 모드: 4월 OOS 검증 기간, 실주문 API 주석 처리
|
- Dry-run 모드: 4월 OOS 검증 기간, 실주문 API 주석 처리
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import time as _time
|
import time as _time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, List
|
from typing import Optional, Dict, List
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -79,12 +81,25 @@ class DataFetcher:
|
|||||||
"enableRateLimit": True,
|
"enableRateLimit": True,
|
||||||
"options": {"defaultType": "future"},
|
"options": {"defaultType": "future"},
|
||||||
})
|
})
|
||||||
self.klines_15m: deque = deque(maxlen=200)
|
self.klines_15m: deque = deque(maxlen=250)
|
||||||
self.klines_1h: deque = deque(maxlen=200)
|
self.klines_1h: deque = deque(maxlen=250)
|
||||||
self._last_15m_ts: int = 0 # 마지막으로 저장된 15m 캔들 timestamp
|
self._last_15m_ts: int = 0 # 마지막으로 저장된 15m 캔들 timestamp
|
||||||
self._last_1h_ts: int = 0
|
self._last_1h_ts: int = 0
|
||||||
|
|
||||||
async def fetch_ohlcv(self, symbol: str, timeframe: str, limit: int = 200) -> List[List]:
|
@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.
|
ccxt를 통해 OHLCV 데이터 fetch.
|
||||||
|
|
||||||
@@ -94,16 +109,16 @@ class DataFetcher:
|
|||||||
return await self.exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
|
return await self.exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
"""봇 시작 시 초기 데이터 로드 (200개씩)."""
|
"""봇 시작 시 초기 데이터 로드 (250개씩)."""
|
||||||
# 15m 캔들
|
# 15m 캔들
|
||||||
raw_15m = await self.fetch_ohlcv(self.symbol, "15m", limit=200)
|
raw_15m = await self.fetch_ohlcv(self.symbol, "15m", limit=250)
|
||||||
for candle in raw_15m:
|
for candle in raw_15m:
|
||||||
self.klines_15m.append(candle)
|
self.klines_15m.append(candle)
|
||||||
if raw_15m:
|
if raw_15m:
|
||||||
self._last_15m_ts = raw_15m[-1][0]
|
self._last_15m_ts = raw_15m[-1][0]
|
||||||
|
|
||||||
# 1h 캔들
|
# 1h 캔들
|
||||||
raw_1h = await self.fetch_ohlcv(self.symbol, "1h", limit=200)
|
raw_1h = await self.fetch_ohlcv(self.symbol, "1h", limit=250)
|
||||||
for candle in raw_1h:
|
for candle in raw_1h:
|
||||||
self.klines_1h.append(candle)
|
self.klines_1h.append(candle)
|
||||||
if raw_1h:
|
if raw_1h:
|
||||||
@@ -113,69 +128,31 @@ class DataFetcher:
|
|||||||
f"[DataFetcher] 초기화 완료: 15m={len(self.klines_15m)}개, 1h={len(self.klines_1h)}개"
|
f"[DataFetcher] 초기화 완료: 15m={len(self.klines_15m)}개, 1h={len(self.klines_1h)}개"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def poll_update(self, interval: int = 30):
|
|
||||||
"""
|
|
||||||
30초 주기로 REST API 폴링. 새 캔들이 나오면 deque에 append.
|
|
||||||
무한 루프 — 백그라운드 태스크로 실행.
|
|
||||||
"""
|
|
||||||
logger.info(f"[DataFetcher] 폴링 시작 (interval={interval}s)")
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
|
|
||||||
# 15m 업데이트: 최근 3개 fetch (중복 방지)
|
|
||||||
raw_15m = await self.fetch_ohlcv(self.symbol, "15m", limit=3)
|
|
||||||
new_15m = 0
|
|
||||||
for candle in raw_15m:
|
|
||||||
if candle[0] > self._last_15m_ts:
|
|
||||||
self.klines_15m.append(candle)
|
|
||||||
self._last_15m_ts = candle[0]
|
|
||||||
new_15m += 1
|
|
||||||
|
|
||||||
# 1h 업데이트: 최근 3개 fetch
|
|
||||||
raw_1h = await self.fetch_ohlcv(self.symbol, "1h", limit=3)
|
|
||||||
new_1h = 0
|
|
||||||
for candle in raw_1h:
|
|
||||||
if candle[0] > self._last_1h_ts:
|
|
||||||
self.klines_1h.append(candle)
|
|
||||||
self._last_1h_ts = candle[0]
|
|
||||||
new_1h += 1
|
|
||||||
|
|
||||||
if new_15m > 0 or new_1h > 0:
|
|
||||||
logger.info(
|
|
||||||
f"[DataFetcher] 캔들 업데이트: 15m +{new_15m} (총 {len(self.klines_15m)}), "
|
|
||||||
f"1h +{new_1h} (총 {len(self.klines_1h)})"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[DataFetcher] 폴링 에러: {e}")
|
|
||||||
await asyncio.sleep(5) # 에러 시 짧은 대기 후 재시도
|
|
||||||
|
|
||||||
def get_15m_dataframe(self) -> Optional[pd.DataFrame]:
|
def get_15m_dataframe(self) -> Optional[pd.DataFrame]:
|
||||||
"""모든 15m 캔들을 DataFrame으로 반환."""
|
"""완성된 15m 캔들을 DataFrame으로 반환 (미완성 캔들 조건부 제거)."""
|
||||||
if not self.klines_15m:
|
if not self.klines_15m:
|
||||||
return None
|
return None
|
||||||
data = list(self.klines_15m)
|
data = list(self.klines_15m)
|
||||||
df = pd.DataFrame(data, columns=["timestamp", "open", "high", "low", "close", "volume"])
|
df = pd.DataFrame(data, columns=["timestamp", "open", "high", "low", "close", "volume"])
|
||||||
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
df = df.set_index("timestamp")
|
df = df.set_index("timestamp")
|
||||||
return df
|
return self._remove_incomplete_candle(df, interval_sec=900)
|
||||||
|
|
||||||
def get_1h_dataframe_completed(self) -> Optional[pd.DataFrame]:
|
def get_1h_dataframe_completed(self) -> Optional[pd.DataFrame]:
|
||||||
"""
|
"""
|
||||||
'완성된' 1h 캔들만 반환.
|
'완성된' 1h 캔들만 반환.
|
||||||
|
|
||||||
핵심: [:-1] 슬라이싱으로 진행 중인 최신 1h 캔들 제외.
|
조건부 슬라이싱: _remove_incomplete_candle()로 진행 중인 최신 1h 캔들 제외.
|
||||||
이유: Look-ahead bias 원천 차단 — 아직 완성되지 않은 캔들의
|
이유: Look-ahead bias 원천 차단 — 아직 완성되지 않은 캔들의
|
||||||
high/low/close는 미래 데이터이므로 지표 계산에 사용하면 안 됨.
|
high/low/close는 미래 데이터이므로 지표 계산에 사용하면 안 됨.
|
||||||
"""
|
"""
|
||||||
if len(self.klines_1h) < 2:
|
if len(self.klines_1h) < 2:
|
||||||
return None
|
return None
|
||||||
completed = list(self.klines_1h)[:-1] # ← 핵심: 미완성 봉 제외
|
data = list(self.klines_1h)
|
||||||
df = pd.DataFrame(completed, columns=["timestamp", "open", "high", "low", "close", "volume"])
|
df = pd.DataFrame(data, columns=["timestamp", "open", "high", "low", "close", "volume"])
|
||||||
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
df = df.set_index("timestamp")
|
df = df.set_index("timestamp")
|
||||||
return df
|
return self._remove_incomplete_candle(df, interval_sec=3600)
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
"""ccxt exchange 연결 정리."""
|
"""ccxt exchange 연결 정리."""
|
||||||
@@ -195,15 +172,27 @@ class MetaFilter:
|
|||||||
|
|
||||||
def __init__(self, data_fetcher: DataFetcher):
|
def __init__(self, data_fetcher: DataFetcher):
|
||||||
self.data_fetcher = data_fetcher
|
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:
|
def _calc_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||||
"""1h DataFrame에 EMA50, EMA200, ADX, ATR 계산."""
|
"""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 = df.copy()
|
||||||
df["ema50"] = ta.ema(df["close"], length=self.EMA_FAST)
|
df["ema50"] = ta.ema(df["close"], length=self.EMA_FAST)
|
||||||
df["ema200"] = ta.ema(df["close"], length=self.EMA_SLOW)
|
df["ema200"] = ta.ema(df["close"], length=self.EMA_SLOW)
|
||||||
adx_df = ta.adx(df["high"], df["low"], df["close"], length=14)
|
adx_df = ta.adx(df["high"], df["low"], df["close"], length=14)
|
||||||
df["adx"] = adx_df["ADX_14"]
|
df["adx"] = adx_df["ADX_14"]
|
||||||
df["atr"] = ta.atr(df["high"], df["low"], df["close"], length=14)
|
df["atr"] = ta.atr(df["high"], df["low"], df["close"], length=14)
|
||||||
|
|
||||||
|
self._cached_indicators = df
|
||||||
|
self._cache_timestamp = last_ts
|
||||||
return df
|
return df
|
||||||
|
|
||||||
def get_market_state(self) -> str:
|
def get_market_state(self) -> str:
|
||||||
@@ -399,6 +388,9 @@ class TriggerStrategy:
|
|||||||
# Module 4: ExecutionManager
|
# Module 4: ExecutionManager
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
_MTF_TRADE_DIR = Path("data/trade_history")
|
||||||
|
|
||||||
|
|
||||||
class ExecutionManager:
|
class ExecutionManager:
|
||||||
"""
|
"""
|
||||||
TriggerStrategy의 신호를 받아 포지션 상태를 관리하고
|
TriggerStrategy의 신호를 받아 포지션 상태를 관리하고
|
||||||
@@ -408,11 +400,14 @@ class ExecutionManager:
|
|||||||
ATR_SL_MULT = 1.5
|
ATR_SL_MULT = 1.5
|
||||||
ATR_TP_MULT = 2.3
|
ATR_TP_MULT = 2.3
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, symbol: str = "XRPUSDT"):
|
||||||
|
self.symbol = symbol
|
||||||
self.current_position: Optional[str] = None # None | 'LONG' | 'SHORT'
|
self.current_position: Optional[str] = None # None | 'LONG' | 'SHORT'
|
||||||
self._entry_price: Optional[float] = None
|
self._entry_price: Optional[float] = None
|
||||||
|
self._entry_ts: Optional[str] = None
|
||||||
self._sl_price: Optional[float] = None
|
self._sl_price: Optional[float] = None
|
||||||
self._tp_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]:
|
def execute(self, signal: str, current_price: float, atr_value: Optional[float]) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
@@ -455,8 +450,10 @@ class ExecutionManager:
|
|||||||
|
|
||||||
self.current_position = side
|
self.current_position = side
|
||||||
self._entry_price = entry_price
|
self._entry_price = entry_price
|
||||||
|
self._entry_ts = datetime.now(timezone.utc).isoformat()
|
||||||
self._sl_price = sl_price
|
self._sl_price = sl_price
|
||||||
self._tp_price = tp_price
|
self._tp_price = tp_price
|
||||||
|
self._atr_at_entry = atr_value
|
||||||
|
|
||||||
sl_dist = abs(entry_price - sl_price)
|
sl_dist = abs(entry_price - sl_price)
|
||||||
tp_dist = abs(tp_price - entry_price)
|
tp_dist = abs(tp_price - entry_price)
|
||||||
@@ -492,8 +489,8 @@ class ExecutionManager:
|
|||||||
"risk_reward": round(rr_ratio, 2),
|
"risk_reward": round(rr_ratio, 2),
|
||||||
}
|
}
|
||||||
|
|
||||||
def close_position(self, reason: str) -> None:
|
def close_position(self, reason: str, exit_price: float = 0.0, pnl_bps: float = 0.0) -> None:
|
||||||
"""포지션 청산 (상태 초기화)."""
|
"""포지션 청산 + JSONL 기록 (상태 초기화)."""
|
||||||
if self.current_position is None:
|
if self.current_position is None:
|
||||||
logger.debug("[ExecutionManager] 청산할 포지션 없음")
|
logger.debug("[ExecutionManager] 청산할 포지션 없음")
|
||||||
return
|
return
|
||||||
@@ -503,6 +500,9 @@ class ExecutionManager:
|
|||||||
f"(진입: {self._entry_price:.4f}) | 사유: {reason}"
|
f"(진입: {self._entry_price:.4f}) | 사유: {reason}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# JSONL에 기록
|
||||||
|
self._save_trade(reason, exit_price, pnl_bps)
|
||||||
|
|
||||||
# ── 실주문 (프로덕션 전환 시 주석 해제) ──
|
# ── 실주문 (프로덕션 전환 시 주석 해제) ──
|
||||||
# if self.current_position == "LONG":
|
# if self.current_position == "LONG":
|
||||||
# await self.exchange.create_market_sell_order(symbol, amount)
|
# await self.exchange.create_market_sell_order(symbol, amount)
|
||||||
@@ -511,8 +511,34 @@ class ExecutionManager:
|
|||||||
|
|
||||||
self.current_position = None
|
self.current_position = None
|
||||||
self._entry_price = None
|
self._entry_price = None
|
||||||
|
self._entry_ts = None
|
||||||
self._sl_price = None
|
self._sl_price = None
|
||||||
self._tp_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:
|
def get_position_info(self) -> Dict:
|
||||||
"""현재 포지션 정보 반환."""
|
"""현재 포지션 정보 반환."""
|
||||||
@@ -535,6 +561,9 @@ class ExecutionManager:
|
|||||||
class MTFPullbackBot:
|
class MTFPullbackBot:
|
||||||
"""MTF Pullback Bot 메인 루프 — Dry-run OOS 검증용."""
|
"""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초 윈도우를 놓치지 않기 위해)
|
LOOP_INTERVAL = 1 # 초 (TimeframeSync 4초 윈도우를 놓치지 않기 위해)
|
||||||
POLL_INTERVAL = 30 # 데이터 폴링 주기 (초)
|
POLL_INTERVAL = 30 # 데이터 폴링 주기 (초)
|
||||||
|
|
||||||
@@ -543,7 +572,7 @@ class MTFPullbackBot:
|
|||||||
self.fetcher = DataFetcher(symbol=symbol)
|
self.fetcher = DataFetcher(symbol=symbol)
|
||||||
self.meta = MetaFilter(self.fetcher)
|
self.meta = MetaFilter(self.fetcher)
|
||||||
self.trigger = TriggerStrategy()
|
self.trigger = TriggerStrategy()
|
||||||
self.executor = ExecutionManager()
|
self.executor = ExecutionManager(symbol=symbol)
|
||||||
self.notifier = DiscordNotifier(
|
self.notifier = DiscordNotifier(
|
||||||
webhook_url=os.getenv("DISCORD_WEBHOOK_URL", ""),
|
webhook_url=os.getenv("DISCORD_WEBHOOK_URL", ""),
|
||||||
)
|
)
|
||||||
@@ -625,12 +654,19 @@ class MTFPullbackBot:
|
|||||||
last_close = float(df_15m.iloc[-1]["close"]) if df_15m is not None and len(df_15m) > 0 else 0
|
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 "없음"
|
pos_info = self.executor.current_position or "없음"
|
||||||
|
|
||||||
# Heartbeat: 15분마다 무조건 출력
|
# 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(
|
logger.info(
|
||||||
f"[Heartbeat] 15m 마감 ({now_str}) | Meta: {meta_state} | "
|
f"[Heartbeat] 15m 마감 ({now_str}) | Meta: {meta_state} | "
|
||||||
f"ATR: {atr:.6f} | Close: {last_close:.4f} | Pos: {pos_info}" if atr else
|
f"ADX: {adx_str} | EMA50: {ema50_str} | EMA200: {ema200_str} | "
|
||||||
f"[Heartbeat] 15m 마감 ({now_str}) | Meta: {meta_state} | "
|
f"ATR: {atr_str} | Close: {last_close:.4f} | Pos: {pos_info}"
|
||||||
f"ATR: N/A | Close: {last_close:.4f} | Pos: {pos_info}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
signal = self.trigger.generate_signal(df_15m, meta_state)
|
signal = self.trigger.generate_signal(df_15m, meta_state)
|
||||||
@@ -645,8 +681,8 @@ class MTFPullbackBot:
|
|||||||
side = result["action"]
|
side = result["action"]
|
||||||
sl_dist = abs(result["entry_price"] - result["sl_price"])
|
sl_dist = abs(result["entry_price"] - result["sl_price"])
|
||||||
tp_dist = abs(result["tp_price"] - result["entry_price"])
|
tp_dist = abs(result["tp_price"] - result["entry_price"])
|
||||||
self.notifier._send(
|
self.notifier.notify_info(
|
||||||
f"📌 **[MTF Dry-run] 가상 {side} 진입**\n"
|
f"**[MTF Dry-run] 가상 {side} 진입**\n"
|
||||||
f"진입가: `{result['entry_price']:.4f}` | ATR: `{result['atr']:.6f}`\n"
|
f"진입가: `{result['entry_price']:.4f}` | ATR: `{result['atr']:.6f}`\n"
|
||||||
f"SL: `{result['sl_price']:.4f}` ({sl_dist:.4f}) | "
|
f"SL: `{result['sl_price']:.4f}` ({sl_dist:.4f}) | "
|
||||||
f"TP: `{result['tp_price']:.4f}` ({tp_dist:.4f})\n"
|
f"TP: `{result['tp_price']:.4f}` ({tp_dist:.4f})\n"
|
||||||
@@ -682,32 +718,35 @@ class MTFPullbackBot:
|
|||||||
if hit_sl and hit_tp:
|
if hit_sl and hit_tp:
|
||||||
exit_price = sl
|
exit_price = sl
|
||||||
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
|
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
|
||||||
logger.info(f"[MTFBot] SL+TP 동시 히트 → SL 우선 청산 | PnL: {pnl*10000:+.1f}bps")
|
pnl_bps = pnl * 10000
|
||||||
self.executor.close_position(f"SL 히트 ({exit_price:.4f})")
|
logger.info(f"[MTFBot] SL+TP 동시 히트 → SL 우선 청산 | PnL: {pnl_bps:+.1f}bps")
|
||||||
self.notifier._send(
|
self.executor.close_position(f"SL 히트 ({exit_price:.4f})", exit_price, pnl_bps)
|
||||||
f"❌ **[MTF Dry-run] {pos} SL 청산**\n"
|
self.notifier.notify_info(
|
||||||
|
f"**[MTF Dry-run] {pos} SL 청산**\n"
|
||||||
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
|
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
|
||||||
f"PnL: `{pnl*10000:+.1f}bps`"
|
f"PnL: `{pnl_bps:+.1f}bps`"
|
||||||
)
|
)
|
||||||
elif hit_sl:
|
elif hit_sl:
|
||||||
exit_price = sl
|
exit_price = sl
|
||||||
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
|
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
|
||||||
logger.info(f"[MTFBot] SL 히트 | 청산가: {exit_price:.4f} | PnL: {pnl*10000:+.1f}bps")
|
pnl_bps = pnl * 10000
|
||||||
self.executor.close_position(f"SL 히트 ({exit_price:.4f})")
|
logger.info(f"[MTFBot] SL 히트 | 청산가: {exit_price:.4f} | PnL: {pnl_bps:+.1f}bps")
|
||||||
self.notifier._send(
|
self.executor.close_position(f"SL 히트 ({exit_price:.4f})", exit_price, pnl_bps)
|
||||||
f"❌ **[MTF Dry-run] {pos} SL 청산**\n"
|
self.notifier.notify_info(
|
||||||
|
f"**[MTF Dry-run] {pos} SL 청산**\n"
|
||||||
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
|
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
|
||||||
f"PnL: `{pnl*10000:+.1f}bps`"
|
f"PnL: `{pnl_bps:+.1f}bps`"
|
||||||
)
|
)
|
||||||
elif hit_tp:
|
elif hit_tp:
|
||||||
exit_price = tp
|
exit_price = tp
|
||||||
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
|
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
|
||||||
logger.info(f"[MTFBot] TP 히트 | 청산가: {exit_price:.4f} | PnL: {pnl*10000:+.1f}bps")
|
pnl_bps = pnl * 10000
|
||||||
self.executor.close_position(f"TP 히트 ({exit_price:.4f})")
|
logger.info(f"[MTFBot] TP 히트 | 청산가: {exit_price:.4f} | PnL: {pnl_bps:+.1f}bps")
|
||||||
self.notifier._send(
|
self.executor.close_position(f"TP 히트 ({exit_price:.4f})", exit_price, pnl_bps)
|
||||||
f"✅ **[MTF Dry-run] {pos} TP 청산**\n"
|
self.notifier.notify_info(
|
||||||
|
f"**[MTF Dry-run] {pos} TP 청산**\n"
|
||||||
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
|
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
|
||||||
f"PnL: `{pnl*10000:+.1f}bps`"
|
f"PnL: `{pnl_bps:+.1f}bps`"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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