11 Commits

Author SHA1 Message Date
21in7
b6ba45f8de docs: add MTF bot motivation and background to ARCHITECTURE.md
메인 봇 PF 0.89, ML/멀티심볼/공개API 피처 전수 테스트 실패 이력을 정리하고,
피처 추가가 아닌 접근 방식 전환(MTF 풀백)으로의 의사결정 맥락을 기술.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:57:27 +09:00
21in7
295ed7db76 docs: add detailed MTF Pullback Bot operation guide to ARCHITECTURE.md
전략 핵심 아이디어, Module 1~4 작동 원리 상세, 메인 루프 흐름도,
메인 봇과의 차이점 비교표를 추가. 테스트 수치도 최신화(183→191).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:42:15 +09:00
21in7
ec7a6e427c docs: sync ARCHITECTURE.md with current codebase
- Fix leverage value 10x → 20x (2 places)
- Update test counts: 15 files/138 cases → 19 files/183 cases
- Add 4 missing test modules to table (dashboard, log_parser, ml_pipeline, mtf_bot)
- Add 14 missing files to structure (1 src + 13 scripts)
- Add MTF Pullback Bot to coverage matrix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:40:34 +09:00
21in7
f488720ca2 fix: MTF bot code review — conditional slicing, caching, tests
- Add _remove_incomplete_candle() for timestamp-based conditional
  slicing on both 15m and 1h data (replaces hardcoded [:-1])
- Add MetaFilter indicator caching to eliminate 3x duplicate calc
- Fix notifier encapsulation (_send → notify_info public API)
- Remove DataFetcher.poll_update() dead code
- Fix evaluate_oos.py symbol typo (xrpusdtusdt → xrpusdt)
- Add 20 pytest unit tests for MetaFilter, TriggerStrategy,
  ExecutionManager, and _remove_incomplete_candle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:11:26 +09:00
21in7
930d4d2c7a feat: add OOS dry-run evaluation script (evaluate_oos.py)
Fetches MTF trade JSONL from prod server via scp, calculates
win rate / PF / cumulative PnL / avg duration by Total/LONG/SHORT,
and outputs LIVE deploy go/no-go verdict (trades >= 5 AND PF >= 1.0).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:10:54 +09:00
21in7
e31c4bf080 feat: add JSONL trade persistence + separate MTF deploy pipeline
- mtf_bot: ExecutionManager saves entry/exit records to
  data/trade_history/mtf_{symbol}.jsonl on every close
- Jenkinsfile: split MTF_CHANGED from BOT_CHANGED so mtf_bot.py-only
  changes restart mtf-bot service without touching cointrader
- docker-compose: mount ./data:/app/data on mtf-bot for persistence

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:07:05 +09:00
21in7
b8a371992f docs: add MTF Pullback Bot to README, ARCHITECTURE, and CLAUDE.md
- README: add MTF bot to feature list and project structure
- ARCHITECTURE: add section 5-1 with MTF bot architecture, data flow,
  and design principles (4-module structure, 250 candle buffer, etc.)
- CLAUDE.md: add mtf-pullback-bot to plan history table
- mtf_bot.py: fix stale comment (maxlen=200 → 250)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:53:23 +09:00
21in7
1cfb1b322a feat: add ADX/EMA50/EMA200 values to heartbeat log for diagnosis
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:22:44 +09:00
21in7
75b5c5d7fe fix: increase kline buffer to 250 to prevent EMA 200 NaN on 1h candles
deque(maxlen=200) + [:-1] slice left only 199 completed candles,
causing EMA 200 to return NaN and the bot to stay in WAIT forever.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:59:07 +09:00
21in7
af865c3db2 fix: reduce loop interval to 1s and add heartbeat logging
- Loop sleep 30s → 1s to never miss the 4-second TimeframeSync window
- Data polling remains at 30s intervals via monotonic timer
- Force poll before signal check to ensure fresh data
- Add [Heartbeat] log every 15m with Meta/ATR/Close/Position
- HOLD reasons now logged at INFO level (was DEBUG)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:47:26 +09:00
21in7
c94c605f3e chore: add mtf-bot to Jenkins deploy service list
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:41:17 +09:00
8 changed files with 983 additions and 97 deletions

View File

@@ -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 파라미터 |

View File

@@ -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 검증 진행 중 |

20
Jenkinsfile vendored
View File

@@ -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}"
} }
@@ -127,6 +132,9 @@ pipeline {
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')
}
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')
@@ -144,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"
} }
@@ -167,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}
""" """
} }

View File

@@ -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 로거 설정

View File

@@ -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
View 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()

View File

@@ -10,14 +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
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
@@ -78,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.
@@ -93,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:
@@ -112,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 연결 정리."""
@@ -194,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:
@@ -398,6 +388,9 @@ class TriggerStrategy:
# Module 4: ExecutionManager # Module 4: ExecutionManager
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
_MTF_TRADE_DIR = Path("data/trade_history")
class ExecutionManager: class ExecutionManager:
""" """
TriggerStrategy의 신호를 받아 포지션 상태를 관리하고 TriggerStrategy의 신호를 받아 포지션 상태를 관리하고
@@ -407,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]:
""" """
@@ -454,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)
@@ -491,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
@@ -502,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)
@@ -510,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:
"""현재 포지션 정보 반환.""" """현재 포지션 정보 반환."""
@@ -534,18 +561,23 @@ class ExecutionManager:
class MTFPullbackBot: class MTFPullbackBot:
"""MTF Pullback Bot 메인 루프 — Dry-run OOS 검증용.""" """MTF Pullback Bot 메인 루프 — Dry-run OOS 검증용."""
POLL_INTERVAL = 30 # 초 # 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"): def __init__(self, symbol: str = "XRP/USDT:USDT"):
self.symbol = symbol self.symbol = symbol
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", ""),
) )
self._last_15m_check_ts: int = 0 # 중복 체크 방지 self._last_15m_check_ts: int = 0 # 중복 체크 방지
self._last_poll_ts: float = 0 # 마지막 폴링 시각
async def run(self): async def run(self):
"""메인 루프: 30초 폴링 → 15m 캔들 close 감지 → 신호 판정.""" """메인 루프: 30초 폴링 → 15m 캔들 close 감지 → 신호 판정."""
@@ -565,16 +597,22 @@ class MTFPullbackBot:
try: try:
while True: while True:
await asyncio.sleep(self.POLL_INTERVAL) await asyncio.sleep(self.LOOP_INTERVAL)
try: try:
await self._poll_and_update() # 데이터 폴링 (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) now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
# 15m 캔들 close 감지 # 15m 캔들 close 감지
if TimeframeSync.is_15m_candle_closed(now_ms): if TimeframeSync.is_15m_candle_closed(now_ms):
if now_ms - self._last_15m_check_ts > 60_000: # 1분 이내 중복 방지 if now_ms - self._last_15m_check_ts > 60_000: # 1분 이내 중복 방지
self._last_15m_check_ts = now_ms self._last_15m_check_ts = now_ms
await self._poll_and_update() # 최신 데이터 보장
await self._on_15m_close() await self._on_15m_close()
# 포지션 보유 중이면 SL/TP 모니터링 # 포지션 보유 중이면 SL/TP 모니터링
@@ -613,23 +651,38 @@ class MTFPullbackBot:
atr = self.meta.get_current_atr() atr = self.meta.get_current_atr()
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
logger.info(f"[MTFBot] ── 15m 캔들 close ({now_str}) ──") last_close = float(df_15m.iloc[-1]["close"]) if df_15m is not None and len(df_15m) > 0 else 0
logger.info(f"[MTFBot] Meta: {meta_state} | ATR: {atr:.6f}" if atr else f"[MTFBot] Meta: {meta_state} | ATR: N/A") 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) signal = self.trigger.generate_signal(df_15m, meta_state)
info = self.trigger.get_trigger_info() info = self.trigger.get_trigger_info()
if signal != "HOLD": if signal != "HOLD":
logger.info(f"[MTFBot] 신호: {signal} | {info.get('reason', '')}") logger.info(f"[MTFBot] 신호: {signal} | {info.get('reason', '')}")
current_price = float(df_15m.iloc[-1]["close"]) current_price = last_close
result = self.executor.execute(signal, current_price, atr) result = self.executor.execute(signal, current_price, atr)
if result: if result:
logger.info(f"[MTFBot] 거래 기록: {result}") logger.info(f"[MTFBot] 거래 기록: {result}")
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"
@@ -637,7 +690,7 @@ class MTFPullbackBot:
f"사유: {info.get('reason', '')}" f"사유: {info.get('reason', '')}"
) )
else: else:
logger.debug(f"[MTFBot] HOLD | {info.get('reason', '')}") logger.info(f"[MTFBot] HOLD | {info.get('reason', '')}")
def _check_sl_tp(self): def _check_sl_tp(self):
"""현재 가격으로 SL/TP 도달 여부 확인 (15m 캔들 high/low 기반).""" """현재 가격으로 SL/TP 도달 여부 확인 (15m 캔들 high/low 기반)."""
@@ -665,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
View 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