14 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
21in7
a0990c5fd5 feat: add Discord webhook notifications to MTF dry-run bot
Sends alerts for: bot start, virtual entry (LONG/SHORT with SL/TP),
and SL/TP exits with PnL. Uses existing DiscordNotifier via
DISCORD_WEBHOOK_URL from .env. Also added env_file to mtf-bot
docker-compose service.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:31:12 +09:00
21in7
82f4977dff fix: upgrade aiohttp pin to resolve ccxt dependency conflict
ccxt requires aiohttp>=3.10.11, was pinned to ==3.9.3.
python-binance has no upper bound on aiohttp, so this is safe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:05:08 +09:00
21in7
4c40516559 feat: add MTF pullback bot for OOS dry-run verification
Volume-backed pullback strategy with 1h meta filter (EMA50/200 + ADX)
and 15m 3-candle trigger sequence. Deployed as separate mtf-bot container
alongside existing cointrader. All orders are dry-run (logged only).

- src/mtf_bot.py: Module 1-4 (DataFetcher, MetaFilter, TriggerStrategy, ExecutionManager)
- main_mtf.py: OOS dry-run entry point
- docker-compose.yml: mtf-bot service added
- requirements.txt: ccxt dependency added
- scripts/mtf_backtest.py: backtest script (Phase 1 robustness: SHORT PF≥1.5 in 7/9 combos)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:58:40 +09:00
11 changed files with 2235 additions and 18 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

@@ -52,6 +52,25 @@ services:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
mtf-bot:
image: git.gihyeon.com/gihyeon/cointrader:latest
container_name: mtf-bot
restart: unless-stopped
env_file:
- .env
environment:
- TZ=Asia/Seoul
- PYTHONUNBUFFERED=1
volumes:
- ./logs:/app/logs
- ./data:/app/data
entrypoint: ["python", "main_mtf.py"]
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
ls-ratio-collector: ls-ratio-collector:
image: git.gihyeon.com/gihyeon/cointrader:latest image: git.gihyeon.com/gihyeon/cointrader:latest
container_name: ls-ratio-collector container_name: ls-ratio-collector

44
main_mtf.py Normal file
View File

@@ -0,0 +1,44 @@
"""MTF Pullback Bot — OOS Dry-run Entry Point."""
import asyncio
import signal as sig
from dotenv import load_dotenv
from loguru import logger
from src.mtf_bot import MTFPullbackBot
from src.logger_setup import setup_logger
load_dotenv()
async def main():
setup_logger(log_level="INFO")
logger.info("MTF Pullback Bot 시작 (Dry-run OOS 모드)")
bot = MTFPullbackBot(symbol="XRP/USDT:USDT")
loop = asyncio.get_running_loop()
shutdown = asyncio.Event()
def _on_signal():
logger.warning("종료 시그널 수신 (SIGTERM/SIGINT)")
shutdown.set()
for s in (sig.SIGTERM, sig.SIGINT):
loop.add_signal_handler(s, _on_signal)
bot_task = asyncio.create_task(bot.run(), name="mtf-bot")
shutdown_task = asyncio.create_task(shutdown.wait(), name="shutdown-wait")
done, pending = await asyncio.wait(
[bot_task, shutdown_task],
return_when=asyncio.FIRST_COMPLETED,
)
bot_task.cancel()
shutdown_task.cancel()
await asyncio.gather(bot_task, shutdown_task, return_exceptions=True)
logger.info("MTF Pullback Bot 종료")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -5,7 +5,7 @@ python-dotenv==1.0.0
httpx>=0.27.0 httpx>=0.27.0
pytest>=8.1.0 pytest>=8.1.0
pytest-asyncio>=0.24.0 pytest-asyncio>=0.24.0
aiohttp==3.9.3 aiohttp>=3.10.11
websockets==12.0 websockets==12.0
loguru==0.7.2 loguru==0.7.2
lightgbm>=4.3.0 lightgbm>=4.3.0
@@ -15,3 +15,4 @@ pyarrow>=15.0.0
onnxruntime>=1.18.0 onnxruntime>=1.18.0
optuna>=3.6.0 optuna>=3.6.0
quantstats>=0.0.81 quantstats>=0.0.81
ccxt>=4.5.0

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

342
scripts/mtf_backtest.py Normal file
View File

@@ -0,0 +1,342 @@
"""
MTF Pullback Backtest
─────────────────────
Trigger: 1h 추세 방향으로 15m 눌림목 진입
LONG: 1h Meta=LONG + 15m close < EMA20 + vol < SMA20*0.5 → 다음 봉 close > EMA20 시 진입
SHORT: 1h Meta=SHORT + 15m close > EMA20 + vol < SMA20*0.5 → 다음 봉 close < EMA20 시 진입
SL/TP: 1h ATR 기반 (진입 시점 직전 완성된 1h 캔들)
Look-ahead bias 방지: 1h 지표는 직전 완성 봉만 사용
"""
import pandas as pd
import pandas_ta as ta
import numpy as np
from pathlib import Path
from dataclasses import dataclass
# ─── 설정 ────────────────────────────────────────────────────────
SYMBOL = "xrpusdt"
DATA_PATH = Path(f"data/{SYMBOL}/combined_15m.parquet")
START = "2026-02-01"
END = "2026-03-30"
ATR_SL_MULT = 1.5
ATR_TP_MULT = 2.3
FEE_RATE = 0.0004 # 0.04% per side
# 1h 메타필터
MTF_EMA_FAST = 50
MTF_EMA_SLOW = 200
MTF_ADX_THRESHOLD = 20
# 15m Trigger
EMA_PULLBACK_LEN = 20
VOL_DRY_RATIO = 0.5 # volume < vol_ma20 * 0.5
@dataclass
class Trade:
entry_time: pd.Timestamp
entry_price: float
side: str
sl: float
tp: float
exit_time: pd.Timestamp | None = None
exit_price: float | None = None
pnl_pct: float | None = None
def build_1h_data(df_15m: pd.DataFrame) -> pd.DataFrame:
"""15m → 1h 리샘플링 + EMA50, EMA200, ADX, ATR."""
df_1h = df_15m[["open", "high", "low", "close", "volume"]].resample("1h").agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
).dropna()
df_1h["ema50_1h"] = ta.ema(df_1h["close"], length=MTF_EMA_FAST)
df_1h["ema200_1h"] = ta.ema(df_1h["close"], length=MTF_EMA_SLOW)
adx_df = ta.adx(df_1h["high"], df_1h["low"], df_1h["close"], length=14)
df_1h["adx_1h"] = adx_df["ADX_14"]
df_1h["atr_1h"] = ta.atr(df_1h["high"], df_1h["low"], df_1h["close"], length=14)
return df_1h[["ema50_1h", "ema200_1h", "adx_1h", "atr_1h"]]
def merge_1h_to_15m(df_15m: pd.DataFrame, df_1h: pd.DataFrame) -> pd.DataFrame:
"""Look-ahead bias 방지: 1h 봉 완성 시점(+1h) 기준 backward merge."""
df_1h_shifted = df_1h.copy()
df_1h_shifted.index = df_1h_shifted.index + pd.Timedelta(hours=1)
df_15m_reset = df_15m.reset_index()
df_1h_reset = df_1h_shifted.reset_index()
df_1h_reset.rename(columns={"index": "timestamp"}, inplace=True)
if "timestamp" not in df_15m_reset.columns:
df_15m_reset.rename(columns={df_15m_reset.columns[0]: "timestamp"}, inplace=True)
df_15m_reset["timestamp"] = pd.to_datetime(df_15m_reset["timestamp"]).astype("datetime64[us]")
df_1h_reset["timestamp"] = pd.to_datetime(df_1h_reset["timestamp"]).astype("datetime64[us]")
merged = pd.merge_asof(
df_15m_reset.sort_values("timestamp"),
df_1h_reset.sort_values("timestamp"),
on="timestamp",
direction="backward",
)
return merged.set_index("timestamp")
def get_1h_meta(row) -> str:
"""1h 메타필터: EMA50/200 방향 + ADX > 20."""
ema50 = row.get("ema50_1h")
ema200 = row.get("ema200_1h")
adx = row.get("adx_1h")
if pd.isna(ema50) or pd.isna(ema200) or pd.isna(adx):
return "HOLD"
if adx < MTF_ADX_THRESHOLD:
return "HOLD"
if ema50 > ema200:
return "LONG"
elif ema50 < ema200:
return "SHORT"
return "HOLD"
def calc_metrics(trades: list[Trade]) -> dict:
if not trades:
return {"trades": 0, "win_rate": 0, "pf": 0, "pnl_bps": 0, "max_dd_bps": 0,
"avg_win_bps": 0, "avg_loss_bps": 0, "long_trades": 0, "short_trades": 0}
pnls = [t.pnl_pct for t in trades]
wins = [p for p in pnls if p > 0]
losses = [p for p in pnls if p <= 0]
gross_profit = sum(wins) if wins else 0
gross_loss = abs(sum(losses)) if losses else 0
pf = gross_profit / gross_loss if gross_loss > 0 else float("inf")
cumulative = np.cumsum(pnls)
peak = np.maximum.accumulate(cumulative)
dd = cumulative - peak
max_dd = abs(dd.min()) if len(dd) > 0 else 0
return {
"trades": len(trades),
"win_rate": len(wins) / len(trades) * 100,
"pf": round(pf, 2),
"pnl_bps": round(sum(pnls) * 10000, 1),
"max_dd_bps": round(max_dd * 10000, 1),
"avg_win_bps": round(np.mean(wins) * 10000, 1) if wins else 0,
"avg_loss_bps": round(np.mean(losses) * 10000, 1) if losses else 0,
"long_trades": sum(1 for t in trades if t.side == "LONG"),
"short_trades": sum(1 for t in trades if t.side == "SHORT"),
}
def main():
print("=" * 70)
print(" MTF Pullback Backtest")
print(f" {SYMBOL.upper()} | {START} ~ {END}")
print(f" SL: 1h ATR×{ATR_SL_MULT} | TP: 1h ATR×{ATR_TP_MULT} | Fee: {FEE_RATE*100:.2f}%/side")
print(f" Pullback: EMA{EMA_PULLBACK_LEN} | Vol dry: <{VOL_DRY_RATIO*100:.0f}% of SMA20")
print("=" * 70)
# ── 데이터 로드 ──
df_raw = pd.read_parquet(DATA_PATH)
if df_raw.index.tz is not None:
df_raw.index = df_raw.index.tz_localize(None)
# 1h EMA200 워밍업 (200h = 800 bars)
warmup_start = pd.Timestamp(START) - pd.Timedelta(hours=250)
df_full = df_raw[df_raw.index >= warmup_start].copy()
print(f"\n데이터: {len(df_full)} bars (워밍업 포함)")
# ── 15m 지표: EMA20, vol_ma20 ──
df_full["ema20"] = ta.ema(df_full["close"], length=EMA_PULLBACK_LEN)
df_full["vol_ma20"] = ta.sma(df_full["volume"], length=20)
# ── 1h 지표 ──
df_1h = build_1h_data(df_full)
print(f"1h 캔들: {len(df_1h)} bars")
# ── 병합 ──
df_merged = merge_1h_to_15m(df_full, df_1h)
# ── 분석 기간 ──
df = df_merged[(df_merged.index >= START) & (df_merged.index <= END)].copy()
print(f"분석 기간: {len(df)} bars ({df.index.min()} ~ {df.index.max()})")
# ── 신호 스캔 & 시뮬레이션 ──
trades: list[Trade] = []
in_trade = False
current_trade: Trade | None = None
pullback_ready = False # 눌림 감지 상태
pullback_side = ""
# 디버그 카운터
meta_long_count = 0
meta_short_count = 0
pullback_detected = 0
entry_triggered = 0
for i in range(1, len(df)):
row = df.iloc[i]
prev = df.iloc[i - 1]
# ── 기존 포지션 SL/TP 체크 ──
if in_trade and current_trade is not None:
hit_sl = False
hit_tp = False
if current_trade.side == "LONG":
if row["low"] <= current_trade.sl:
hit_sl = True
if row["high"] >= current_trade.tp:
hit_tp = True
else:
if row["high"] >= current_trade.sl:
hit_sl = True
if row["low"] <= current_trade.tp:
hit_tp = True
if hit_sl or hit_tp:
exit_price = current_trade.sl if hit_sl else current_trade.tp
if hit_sl and hit_tp:
exit_price = current_trade.sl # 보수적
if current_trade.side == "LONG":
raw_pnl = (exit_price - current_trade.entry_price) / current_trade.entry_price
else:
raw_pnl = (current_trade.entry_price - exit_price) / current_trade.entry_price
current_trade.exit_time = df.index[i]
current_trade.exit_price = exit_price
current_trade.pnl_pct = raw_pnl - FEE_RATE * 2
trades.append(current_trade)
in_trade = False
current_trade = None
# ── 포지션 중이면 새 진입 스킵 ──
if in_trade:
continue
# NaN 체크
if pd.isna(row.get("ema20")) or pd.isna(row.get("vol_ma20")) or pd.isna(row.get("atr_1h")):
pullback_ready = False
continue
# ── Step 1: 1h Meta Filter ──
meta = get_1h_meta(row)
if meta == "LONG":
meta_long_count += 1
elif meta == "SHORT":
meta_short_count += 1
if meta == "HOLD":
pullback_ready = False
continue
# ── Step 2: 눌림(Pullback) 감지 ──
# 이전 봉이 눌림 조건을 충족했는지 확인
if pullback_ready and pullback_side == meta:
# ── Step 4: 추세 재개 확인 (현재 봉 close 기준) ──
if pullback_side == "LONG" and row["close"] > row["ema20"]:
# 진입: 이 봉의 open (추세 재개 확인된 봉)
# 실제로는 close 시점에 확인하므로 다음 봉 open에 진입해야 look-ahead 방지
# 하지만 사양서에 "직후 캔들의 종가가 EMA20 상향 돌파한 첫 번째 캔들의 시가"라고 되어 있으므로
# → 이 봉(close > EMA20)의 open에서 진입은 look-ahead bias
# → 정확히는: prev가 pullback, 현재 봉 close > EMA20 확인 → 다음 봉 open 진입
# 여기서는 다음 봉 open으로 처리
if i + 1 < len(df):
next_row = df.iloc[i + 1]
entry_price = next_row["open"]
atr_1h = row["atr_1h"]
sl = entry_price - atr_1h * ATR_SL_MULT
tp = entry_price + atr_1h * ATR_TP_MULT
current_trade = Trade(
entry_time=df.index[i + 1],
entry_price=entry_price,
side="LONG",
sl=sl, tp=tp,
)
in_trade = True
pullback_ready = False
entry_triggered += 1
continue
elif pullback_side == "SHORT" and row["close"] < row["ema20"]:
if i + 1 < len(df):
next_row = df.iloc[i + 1]
entry_price = next_row["open"]
atr_1h = row["atr_1h"]
sl = entry_price + atr_1h * ATR_SL_MULT
tp = entry_price - atr_1h * ATR_TP_MULT
current_trade = Trade(
entry_time=df.index[i + 1],
entry_price=entry_price,
side="SHORT",
sl=sl, tp=tp,
)
in_trade = True
pullback_ready = False
entry_triggered += 1
continue
# ── Step 2+3: 눌림 + 거래량 고갈 감지 (다음 봉에서 재개 확인) ──
vol_dry = row["volume"] < row["vol_ma20"] * VOL_DRY_RATIO
if meta == "LONG" and row["close"] < row["ema20"] and vol_dry:
pullback_ready = True
pullback_side = "LONG"
pullback_detected += 1
elif meta == "SHORT" and row["close"] > row["ema20"] and vol_dry:
pullback_ready = True
pullback_side = "SHORT"
pullback_detected += 1
else:
# 조건 불충족 시 pullback 상태 리셋
# 단, 연속 pullback 허용 (여러 봉 동안 눌림 지속 가능)
if not (meta == pullback_side):
pullback_ready = False
# ── 결과 출력 ──
m = calc_metrics(trades)
long_trades = [t for t in trades if t.side == "LONG"]
short_trades = [t for t in trades if t.side == "SHORT"]
lm = calc_metrics(long_trades)
sm = calc_metrics(short_trades)
print(f"\n─── 신호 파이프라인 ───")
print(f"1h Meta LONG: {meta_long_count} bars | SHORT: {meta_short_count} bars")
print(f"Pullback 감지: {pullback_detected}")
print(f"진입 트리거: {entry_triggered}")
print(f"실제 거래: {m['trades']}건 (L:{m['long_trades']} / S:{m['short_trades']})")
print(f"\n{'=' * 70}")
print(f" 결과")
print(f"{'=' * 70}")
header = f"{'구분':<10} {'Trades':>7} {'WinRate':>8} {'PF':>6} {'PnL(bps)':>10} {'MaxDD(bps)':>11} {'AvgWin':>8} {'AvgLoss':>8}"
print(header)
print("-" * len(header))
print(f"{'전체':<10} {m['trades']:>7} {m['win_rate']:>7.1f}% {m['pf']:>6.2f} {m['pnl_bps']:>10.1f} {m['max_dd_bps']:>11.1f} {m['avg_win_bps']:>8.1f} {m['avg_loss_bps']:>8.1f}")
print(f"{'LONG':<10} {lm['trades']:>7} {lm['win_rate']:>7.1f}% {lm['pf']:>6.2f} {lm['pnl_bps']:>10.1f} {lm['max_dd_bps']:>11.1f} {lm['avg_win_bps']:>8.1f} {lm['avg_loss_bps']:>8.1f}")
print(f"{'SHORT':<10} {sm['trades']:>7} {sm['win_rate']:>7.1f}% {sm['pf']:>6.2f} {sm['pnl_bps']:>10.1f} {sm['max_dd_bps']:>11.1f} {sm['avg_win_bps']:>8.1f} {sm['avg_loss_bps']:>8.1f}")
# 개별 거래 목록
if trades:
print(f"\n─── 개별 거래 ───")
print(f"{'#':>3} {'Side':<6} {'Entry Time':<20} {'Entry':>10} {'Exit':>10} {'PnL(bps)':>10} {'Result':>8}")
print("-" * 75)
for idx, t in enumerate(trades, 1):
result = "WIN" if t.pnl_pct > 0 else "LOSS"
pnl_bps = t.pnl_pct * 10000
print(f"{idx:>3} {t.side:<6} {str(t.entry_time):<20} {t.entry_price:>10.4f} {t.exit_price:>10.4f} {pnl_bps:>+10.1f} {result:>8}")
if __name__ == "__main__":
main()

982
src/mtf_bot.py Normal file
View File

@@ -0,0 +1,982 @@
"""
MTF Pullback Bot — Module 1~4
──────────────────────────────
Module 1: TimeframeSync, DataFetcher (REST 폴링 기반)
Module 2: MetaFilter (1h EMA50/200 + ADX + ATR)
Module 3: TriggerStrategy (15m Volume-backed Pullback 3캔들 시퀀스)
Module 4: ExecutionManager (Dry-run 가상 주문 + SL/TP 관리)
핵심 원칙:
- Look-ahead bias 원천 차단: 완성된 캔들만 사용 ([:-1] 슬라이싱)
- Binance 서버 딜레이 고려: 캔들 판별 시 2~5초 range
- REST 폴링 기반 안정성: WebSocket 대신 30초 주기 폴링
- 메모리 최적화: deque(maxlen=250)
- Dry-run 모드: 4월 OOS 검증 기간, 실주문 API 주석 처리
"""
import asyncio
import json
import os
import time as _time
from datetime import datetime, timezone
from collections import deque
from pathlib import Path
from typing import Optional, Dict, List
import pandas as pd
import pandas_ta as ta
import ccxt.async_support as ccxt
from loguru import logger
from src.notifier import DiscordNotifier
# ═══════════════════════════════════════════════════════════════════
# Module 1: TimeframeSync
# ═══════════════════════════════════════════════════════════════════
class TimeframeSync:
"""현재 시간이 15m/1h 캔들 종료 직후인지 판별 (Binance 서버 딜레이 2~5초 고려)."""
_15M_MINUTES = {0, 15, 30, 45}
@staticmethod
def is_15m_candle_closed(current_ts: int) -> bool:
"""
15m 캔들 종료 판별.
Args:
current_ts: Unix timestamp (밀리초)
Returns:
True if 분(minute)이 [0, 15, 30, 45] 중 하나이고 초(second)가 2~5초 사이
"""
dt = datetime.fromtimestamp(current_ts / 1000, tz=timezone.utc)
return dt.minute in TimeframeSync._15M_MINUTES and 2 <= dt.second <= 5
@staticmethod
def is_1h_candle_closed(current_ts: int) -> bool:
"""
1h 캔들 종료 판별.
Args:
current_ts: Unix timestamp (밀리초)
Returns:
True if 분(minute)이 0이고 초(second)가 2~5초 사이
"""
dt = datetime.fromtimestamp(current_ts / 1000, tz=timezone.utc)
return dt.minute == 0 and 2 <= dt.second <= 5
# ═══════════════════════════════════════════════════════════════════
# Module 1: DataFetcher
# ═══════════════════════════════════════════════════════════════════
class DataFetcher:
"""Binance Futures에서 15m/1h OHLCV 데이터 fetch 및 관리."""
def __init__(self, symbol: str = "XRP/USDT:USDT"):
self.symbol = symbol
self.exchange = ccxt.binance({
"enableRateLimit": True,
"options": {"defaultType": "future"},
})
self.klines_15m: deque = deque(maxlen=250)
self.klines_1h: deque = deque(maxlen=250)
self._last_15m_ts: int = 0 # 마지막으로 저장된 15m 캔들 timestamp
self._last_1h_ts: int = 0
@staticmethod
def _remove_incomplete_candle(df: pd.DataFrame, interval_sec: int) -> pd.DataFrame:
"""미완성(진행 중) 캔들을 조건부로 제거. ccxt timestamp는 ms 단위."""
if df.empty:
return df
now_ms = int(_time.time() * 1000)
current_candle_start_ms = (now_ms // (interval_sec * 1000)) * (interval_sec * 1000)
# DataFrame index가 datetime인 경우 원본 timestamp 컬럼이 없으므로 index에서 추출
last_open_ms = int(df.index[-1].timestamp() * 1000)
if last_open_ms >= current_candle_start_ms:
return df.iloc[:-1].copy()
return df
async def fetch_ohlcv(self, symbol: str, timeframe: str, limit: int = 250) -> List[List]:
"""
ccxt를 통해 OHLCV 데이터 fetch.
Returns:
[[timestamp, open, high, low, close, volume], ...]
"""
return await self.exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
async def initialize(self):
"""봇 시작 시 초기 데이터 로드 (250개씩)."""
# 15m 캔들
raw_15m = await self.fetch_ohlcv(self.symbol, "15m", limit=250)
for candle in raw_15m:
self.klines_15m.append(candle)
if raw_15m:
self._last_15m_ts = raw_15m[-1][0]
# 1h 캔들
raw_1h = await self.fetch_ohlcv(self.symbol, "1h", limit=250)
for candle in raw_1h:
self.klines_1h.append(candle)
if raw_1h:
self._last_1h_ts = raw_1h[-1][0]
logger.info(
f"[DataFetcher] 초기화 완료: 15m={len(self.klines_15m)}개, 1h={len(self.klines_1h)}"
)
def get_15m_dataframe(self) -> Optional[pd.DataFrame]:
"""완성된 15m 캔들을 DataFrame으로 반환 (미완성 캔들 조건부 제거)."""
if not self.klines_15m:
return None
data = list(self.klines_15m)
df = pd.DataFrame(data, columns=["timestamp", "open", "high", "low", "close", "volume"])
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
df = df.set_index("timestamp")
return self._remove_incomplete_candle(df, interval_sec=900)
def get_1h_dataframe_completed(self) -> Optional[pd.DataFrame]:
"""
'완성된' 1h 캔들만 반환.
조건부 슬라이싱: _remove_incomplete_candle()로 진행 중인 최신 1h 캔들 제외.
이유: Look-ahead bias 원천 차단 — 아직 완성되지 않은 캔들의
high/low/close는 미래 데이터이므로 지표 계산에 사용하면 안 됨.
"""
if len(self.klines_1h) < 2:
return None
data = list(self.klines_1h)
df = pd.DataFrame(data, columns=["timestamp", "open", "high", "low", "close", "volume"])
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
df = df.set_index("timestamp")
return self._remove_incomplete_candle(df, interval_sec=3600)
async def close(self):
"""ccxt exchange 연결 정리."""
await self.exchange.close()
# ═══════════════════════════════════════════════════════════════════
# Module 2: MetaFilter
# ═══════════════════════════════════════════════════════════════════
class MetaFilter:
"""1시간봉 데이터로부터 거시 추세 판독."""
EMA_FAST = 50
EMA_SLOW = 200
ADX_THRESHOLD = 20
def __init__(self, data_fetcher: DataFetcher):
self.data_fetcher = data_fetcher
self._cached_indicators: Optional[pd.DataFrame] = None
self._cache_timestamp: Optional[pd.Timestamp] = None
def _calc_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
"""1h DataFrame에 EMA50, EMA200, ADX, ATR 계산 (캔들 단위 캐싱)."""
if df is None or df.empty:
return df
last_ts = df.index[-1]
if self._cached_indicators is not None and self._cache_timestamp == last_ts:
return self._cached_indicators
df = df.copy()
df["ema50"] = ta.ema(df["close"], length=self.EMA_FAST)
df["ema200"] = ta.ema(df["close"], length=self.EMA_SLOW)
adx_df = ta.adx(df["high"], df["low"], df["close"], length=14)
df["adx"] = adx_df["ADX_14"]
df["atr"] = ta.atr(df["high"], df["low"], df["close"], length=14)
self._cached_indicators = df
self._cache_timestamp = last_ts
return df
def get_market_state(self) -> str:
"""
1h 메타필터 상태 반환.
Returns:
'LONG_ALLOWED': EMA50 > EMA200 & ADX > 20 → 상승 추세, LONG 진입 허용
'SHORT_ALLOWED': EMA50 < EMA200 & ADX > 20 → 하락 추세, SHORT 진입 허용
'WAIT': 그 외 (추세 약하거나 데이터 부족)
"""
df = self.data_fetcher.get_1h_dataframe_completed()
if df is None or len(df) < self.EMA_SLOW:
return "WAIT"
df = self._calc_indicators(df)
last = df.iloc[-1]
if pd.isna(last["ema50"]) or pd.isna(last["ema200"]) or pd.isna(last["adx"]):
return "WAIT"
if last["adx"] < self.ADX_THRESHOLD:
return "WAIT"
if last["ema50"] > last["ema200"]:
return "LONG_ALLOWED"
elif last["ema50"] < last["ema200"]:
return "SHORT_ALLOWED"
return "WAIT"
def get_current_atr(self) -> Optional[float]:
"""현재 1h ATR 값 반환 (SL/TP 계산용)."""
df = self.data_fetcher.get_1h_dataframe_completed()
if df is None or len(df) < 15: # ATR(14) 최소 데이터
return None
df = self._calc_indicators(df)
atr = df["atr"].iloc[-1]
return float(atr) if not pd.isna(atr) else None
def get_meta_info(self) -> Dict:
"""전체 메타 정보 반환 (디버깅용)."""
df = self.data_fetcher.get_1h_dataframe_completed()
if df is None or len(df) < self.EMA_SLOW:
return {"state": "WAIT", "ema50": None, "ema200": None,
"adx": None, "atr": None, "timestamp": None}
df = self._calc_indicators(df)
last = df.iloc[-1]
return {
"state": self.get_market_state(),
"ema50": float(last["ema50"]) if not pd.isna(last["ema50"]) else None,
"ema200": float(last["ema200"]) if not pd.isna(last["ema200"]) else None,
"adx": float(last["adx"]) if not pd.isna(last["adx"]) else None,
"atr": float(last["atr"]) if not pd.isna(last["atr"]) else None,
"timestamp": str(df.index[-1]),
}
# ═══════════════════════════════════════════════════════════════════
# Module 3: TriggerStrategy
# ═══════════════════════════════════════════════════════════════════
class TriggerStrategy:
"""
15분봉 Volume-backed Pullback 패턴을 3캔들 시퀀스로 인식.
3캔들 시퀀스:
t-2: 기준 캔들 (Vol_SMA20 산출 기준)
t-1: 풀백 캔들 (EMA 이탈 + 거래량 고갈 확인)
t : 돌파 캔들 (가장 최근 완성된 캔들, EMA 복귀 확인)
"""
EMA_PERIOD = 15
VOL_SMA_PERIOD = 20
VOL_THRESHOLD = 0.50 # vol < vol_sma20 * 0.50
def __init__(self):
self._last_info: Dict = {}
def _calc_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
"""15m DataFrame에 EMA15, Vol_SMA20 계산."""
df = df.copy()
df["ema15"] = ta.ema(df["close"], length=self.EMA_PERIOD)
df["vol_sma20"] = df["volume"].rolling(self.VOL_SMA_PERIOD).mean()
return df
def generate_signal(self, df_15m: pd.DataFrame, meta_state: str) -> str:
"""
3캔들 시퀀스 기반 진입 신호 생성.
Args:
df_15m: 15분봉 DataFrame (OHLCV)
meta_state: 'LONG_ALLOWED' | 'SHORT_ALLOWED' | 'WAIT'
Returns:
'EXECUTE_LONG' | 'EXECUTE_SHORT' | 'HOLD'
"""
# Step 1: 데이터 유효성
if meta_state == "WAIT":
self._last_info = {"signal": "HOLD", "reason": "meta_state=WAIT"}
return "HOLD"
if df_15m is None or len(df_15m) < 25:
self._last_info = {"signal": "HOLD", "reason": f"데이터 부족 ({len(df_15m) if df_15m is not None else 0}행)"}
return "HOLD"
df = self._calc_indicators(df_15m)
# Step 2: 캔들 인덱싱
t = df.iloc[-1] # 최근 완성 캔들 (돌파 확인)
t_1 = df.iloc[-2] # 직전 캔들 (풀백 확인)
t_2 = df.iloc[-3] # 그 이전 캔들 (Vol SMA 기준)
# NaN 체크
if (pd.isna(t["ema15"]) or pd.isna(t_1["ema15"])
or pd.isna(t_2["vol_sma20"])):
self._last_info = {"signal": "HOLD", "reason": "지표 NaN"}
return "HOLD"
vol_sma20_t2 = t_2["vol_sma20"]
vol_t1 = t_1["volume"]
vol_ratio = vol_t1 / vol_sma20_t2 if vol_sma20_t2 > 0 else float("inf")
vol_dry = vol_ratio < self.VOL_THRESHOLD
# 공통 info 구성
self._last_info = {
"ema15_t": float(t["ema15"]),
"ema15_t1": float(t_1["ema15"]),
"vol_sma20_t2": float(vol_sma20_t2),
"vol_t1": float(vol_t1),
"vol_ratio": round(vol_ratio, 4),
"close_t1": float(t_1["close"]),
"close_t": float(t["close"]),
}
# Step 3: LONG 시그널
if meta_state == "LONG_ALLOWED":
pullback = t_1["close"] < t_1["ema15"] # t-1 EMA 아래로 이탈
resumption = t["close"] > t["ema15"] # t EMA 위로 복귀
if pullback and vol_dry and resumption:
self._last_info.update({
"signal": "EXECUTE_LONG",
"reason": f"풀백 이탈 + 거래량 고갈({vol_ratio:.2f}) + 돌파 복귀",
})
return "EXECUTE_LONG"
reasons = []
if not pullback:
reasons.append(f"이탈 없음(close_t1={t_1['close']:.4f} >= ema15={t_1['ema15']:.4f})")
if not vol_dry:
reasons.append(f"거래량 과다({vol_ratio:.2f} >= {self.VOL_THRESHOLD})")
if not resumption:
reasons.append(f"복귀 실패(close_t={t['close']:.4f} <= ema15={t['ema15']:.4f})")
self._last_info.update({"signal": "HOLD", "reason": " | ".join(reasons)})
return "HOLD"
# Step 4: SHORT 시그널
if meta_state == "SHORT_ALLOWED":
pullback = t_1["close"] > t_1["ema15"] # t-1 EMA 위로 이탈
resumption = t["close"] < t["ema15"] # t EMA 아래로 복귀
if pullback and vol_dry and resumption:
self._last_info.update({
"signal": "EXECUTE_SHORT",
"reason": f"풀백 이탈 + 거래량 고갈({vol_ratio:.2f}) + 돌파 복귀",
})
return "EXECUTE_SHORT"
reasons = []
if not pullback:
reasons.append(f"이탈 없음(close_t1={t_1['close']:.4f} <= ema15={t_1['ema15']:.4f})")
if not vol_dry:
reasons.append(f"거래량 과다({vol_ratio:.2f} >= {self.VOL_THRESHOLD})")
if not resumption:
reasons.append(f"복귀 실패(close_t={t['close']:.4f} >= ema15={t['ema15']:.4f})")
self._last_info.update({"signal": "HOLD", "reason": " | ".join(reasons)})
return "HOLD"
# Step 5: 기본값
self._last_info.update({"signal": "HOLD", "reason": f"미지원 meta_state={meta_state}"})
return "HOLD"
def get_trigger_info(self) -> Dict:
"""디버깅 및 로그용 트리거 상태 정보 반환."""
return self._last_info.copy()
# ═══════════════════════════════════════════════════════════════════
# Module 4: ExecutionManager
# ═══════════════════════════════════════════════════════════════════
_MTF_TRADE_DIR = Path("data/trade_history")
class ExecutionManager:
"""
TriggerStrategy의 신호를 받아 포지션 상태를 관리하고
SL/TP를 계산하여 가상 주문을 실행한다 (Dry-run 모드).
"""
ATR_SL_MULT = 1.5
ATR_TP_MULT = 2.3
def __init__(self, symbol: str = "XRPUSDT"):
self.symbol = symbol
self.current_position: Optional[str] = None # None | 'LONG' | 'SHORT'
self._entry_price: Optional[float] = None
self._entry_ts: Optional[str] = None
self._sl_price: Optional[float] = None
self._tp_price: Optional[float] = None
self._atr_at_entry: Optional[float] = None
def execute(self, signal: str, current_price: float, atr_value: Optional[float]) -> Optional[Dict]:
"""
신호에 따라 가상 주문 실행.
Args:
signal: 'EXECUTE_LONG' | 'EXECUTE_SHORT' | 'HOLD'
current_price: 현재 시장가
atr_value: 1h ATR 값
Returns:
주문 정보 Dict 또는 None (HOLD / 중복 포지션 / ATR 무효)
"""
if signal == "HOLD":
return None
if self.current_position is not None:
logger.debug(
f"[ExecutionManager] 포지션 중복 차단: "
f"현재={self.current_position}, 신호={signal}"
)
return None
if atr_value is None or atr_value <= 0 or pd.isna(atr_value):
logger.warning(f"[ExecutionManager] ATR 무효({atr_value}), 주문 차단")
return None
entry_price = current_price
if signal == "EXECUTE_LONG":
sl_price = entry_price - (atr_value * self.ATR_SL_MULT)
tp_price = entry_price + (atr_value * self.ATR_TP_MULT)
side = "LONG"
elif signal == "EXECUTE_SHORT":
sl_price = entry_price + (atr_value * self.ATR_SL_MULT)
tp_price = entry_price - (atr_value * self.ATR_TP_MULT)
side = "SHORT"
else:
return None
self.current_position = side
self._entry_price = entry_price
self._entry_ts = datetime.now(timezone.utc).isoformat()
self._sl_price = sl_price
self._tp_price = tp_price
self._atr_at_entry = atr_value
sl_dist = abs(entry_price - sl_price)
tp_dist = abs(tp_price - entry_price)
rr_ratio = tp_dist / sl_dist if sl_dist > 0 else 0
# ── Dry-run 로그 ──
logger.info(
f"\n┌──────────────────────────────────────────────┐\n"
f"│ [DRY-RUN] 가상 주문 실행 │\n"
f"│ 방향: {side:<5} | 진입가: {entry_price:.4f}\n"
f"│ SL: {sl_price:.4f} ({'-' if side == 'LONG' else '+'}{sl_dist:.4f}, ATR×{self.ATR_SL_MULT}) │\n"
f"│ TP: {tp_price:.4f} ({'+' if side == 'LONG' else '-'}{tp_dist:.4f}, ATR×{self.ATR_TP_MULT}) │\n"
f"│ R:R = 1:{rr_ratio:.1f}\n"
f"└──────────────────────────────────────────────┘"
)
# ── 실주문 (프로덕션 전환 시 주석 해제) ──
# if side == "LONG":
# await self.exchange.create_market_buy_order(symbol, amount)
# await self.exchange.create_order(symbol, 'stop_market', 'sell', amount, params={'stopPrice': sl_price})
# await self.exchange.create_order(symbol, 'take_profit_market', 'sell', amount, params={'stopPrice': tp_price})
# elif side == "SHORT":
# await self.exchange.create_market_sell_order(symbol, amount)
# await self.exchange.create_order(symbol, 'stop_market', 'buy', amount, params={'stopPrice': sl_price})
# await self.exchange.create_order(symbol, 'take_profit_market', 'buy', amount, params={'stopPrice': tp_price})
return {
"action": side,
"entry_price": entry_price,
"sl_price": sl_price,
"tp_price": tp_price,
"atr": atr_value,
"risk_reward": round(rr_ratio, 2),
}
def close_position(self, reason: str, exit_price: float = 0.0, pnl_bps: float = 0.0) -> None:
"""포지션 청산 + JSONL 기록 (상태 초기화)."""
if self.current_position is None:
logger.debug("[ExecutionManager] 청산할 포지션 없음")
return
logger.info(
f"[ExecutionManager] 포지션 청산: {self.current_position} "
f"(진입: {self._entry_price:.4f}) | 사유: {reason}"
)
# JSONL에 기록
self._save_trade(reason, exit_price, pnl_bps)
# ── 실주문 (프로덕션 전환 시 주석 해제) ──
# if self.current_position == "LONG":
# await self.exchange.create_market_sell_order(symbol, amount)
# elif self.current_position == "SHORT":
# await self.exchange.create_market_buy_order(symbol, amount)
self.current_position = None
self._entry_price = None
self._entry_ts = None
self._sl_price = None
self._tp_price = None
self._atr_at_entry = None
def _save_trade(self, reason: str, exit_price: float, pnl_bps: float) -> None:
"""거래 기록을 JSONL 파일에 append."""
record = {
"symbol": self.symbol,
"side": self.current_position,
"entry_price": self._entry_price,
"entry_ts": self._entry_ts,
"exit_price": exit_price,
"exit_ts": datetime.now(timezone.utc).isoformat(),
"sl_price": self._sl_price,
"tp_price": self._tp_price,
"atr": self._atr_at_entry,
"pnl_bps": round(pnl_bps, 1),
"reason": reason,
}
try:
_MTF_TRADE_DIR.mkdir(parents=True, exist_ok=True)
path = _MTF_TRADE_DIR / f"mtf_{self.symbol.replace('/', '').replace(':', '').lower()}.jsonl"
with open(path, "a") as f:
f.write(json.dumps(record) + "\n")
logger.info(f"[ExecutionManager] 거래 기록 저장: {path.name}")
except Exception as e:
logger.warning(f"[ExecutionManager] 거래 기록 저장 실패: {e}")
def get_position_info(self) -> Dict:
"""현재 포지션 정보 반환."""
return {
"position": self.current_position,
"entry_price": self._entry_price,
"sl_price": self._sl_price,
"tp_price": self._tp_price,
}
# ═══════════════════════════════════════════════════════════════════
# 검증 테스트
# ═══════════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════════
# Main Loop: OOS Dry-run
# ═══════════════════════════════════════════════════════════════════
class MTFPullbackBot:
"""MTF Pullback Bot 메인 루프 — Dry-run OOS 검증용."""
# TODO(LIVE): Kill switch 로직 구현 필요 (Fast Kill 8연패 + Slow Kill PF<0.75) — 2026-04-15 LIVE 전환 시
# TODO(LIVE): 글로벌 RiskManager 통합 필요 — 2026-04-15 LIVE 전환 시
LOOP_INTERVAL = 1 # 초 (TimeframeSync 4초 윈도우를 놓치지 않기 위해)
POLL_INTERVAL = 30 # 데이터 폴링 주기 (초)
def __init__(self, symbol: str = "XRP/USDT:USDT"):
self.symbol = symbol
self.fetcher = DataFetcher(symbol=symbol)
self.meta = MetaFilter(self.fetcher)
self.trigger = TriggerStrategy()
self.executor = ExecutionManager(symbol=symbol)
self.notifier = DiscordNotifier(
webhook_url=os.getenv("DISCORD_WEBHOOK_URL", ""),
)
self._last_15m_check_ts: int = 0 # 중복 체크 방지
self._last_poll_ts: float = 0 # 마지막 폴링 시각
async def run(self):
"""메인 루프: 30초 폴링 → 15m 캔들 close 감지 → 신호 판정."""
logger.info(f"[MTFBot] 시작: {self.symbol} (Dry-run OOS 모드)")
await self.fetcher.initialize()
# 초기 상태 출력
meta_state = self.meta.get_market_state()
atr = self.meta.get_current_atr()
logger.info(f"[MTFBot] 초기 상태: Meta={meta_state}, ATR={atr}")
self.notifier.notify_info(
f"**[MTF Dry-run] 봇 시작**\n"
f"심볼: `{self.symbol}` | Meta: `{meta_state}` | ATR: `{atr:.6f}`" if atr else
f"**[MTF Dry-run] 봇 시작**\n심볼: `{self.symbol}` | Meta: `{meta_state}` | ATR: N/A"
)
try:
while True:
await asyncio.sleep(self.LOOP_INTERVAL)
try:
# 데이터 폴링 (30초마다)
now_mono = _time.monotonic()
if now_mono - self._last_poll_ts >= self.POLL_INTERVAL:
await self._poll_and_update()
self._last_poll_ts = now_mono
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
# 15m 캔들 close 감지
if TimeframeSync.is_15m_candle_closed(now_ms):
if now_ms - self._last_15m_check_ts > 60_000: # 1분 이내 중복 방지
self._last_15m_check_ts = now_ms
await self._poll_and_update() # 최신 데이터 보장
await self._on_15m_close()
# 포지션 보유 중이면 SL/TP 모니터링
if self.executor.current_position is not None:
self._check_sl_tp()
except Exception as e:
logger.error(f"[MTFBot] 루프 에러: {e}")
except asyncio.CancelledError:
logger.info("[MTFBot] 종료 시그널 수신")
finally:
await self.fetcher.close()
logger.info("[MTFBot] 종료 완료")
async def _poll_and_update(self):
"""데이터 폴링 업데이트."""
# 15m
raw_15m = await self.fetcher.fetch_ohlcv(self.symbol, "15m", limit=3)
for candle in raw_15m:
if candle[0] > self.fetcher._last_15m_ts:
self.fetcher.klines_15m.append(candle)
self.fetcher._last_15m_ts = candle[0]
# 1h
raw_1h = await self.fetcher.fetch_ohlcv(self.symbol, "1h", limit=3)
for candle in raw_1h:
if candle[0] > self.fetcher._last_1h_ts:
self.fetcher.klines_1h.append(candle)
self.fetcher._last_1h_ts = candle[0]
async def _on_15m_close(self):
"""15m 캔들 종료 시 신호 판정."""
df_15m = self.fetcher.get_15m_dataframe()
meta_state = self.meta.get_market_state()
atr = self.meta.get_current_atr()
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
last_close = float(df_15m.iloc[-1]["close"]) if df_15m is not None and len(df_15m) > 0 else 0
pos_info = self.executor.current_position or "없음"
# Heartbeat: 15분마다 무조건 출력 (메타 지표 포함)
meta_info = self.meta.get_meta_info()
adx_val = meta_info.get("adx")
ema50_val = meta_info.get("ema50")
ema200_val = meta_info.get("ema200")
adx_str = f"{adx_val:.2f}" if adx_val is not None else "N/A"
ema50_str = f"{ema50_val:.4f}" if ema50_val is not None else "N/A"
ema200_str = f"{ema200_val:.4f}" if ema200_val is not None else "N/A"
atr_str = f"{atr:.6f}" if atr else "N/A"
logger.info(
f"[Heartbeat] 15m 마감 ({now_str}) | Meta: {meta_state} | "
f"ADX: {adx_str} | EMA50: {ema50_str} | EMA200: {ema200_str} | "
f"ATR: {atr_str} | Close: {last_close:.4f} | Pos: {pos_info}"
)
signal = self.trigger.generate_signal(df_15m, meta_state)
info = self.trigger.get_trigger_info()
if signal != "HOLD":
logger.info(f"[MTFBot] 신호: {signal} | {info.get('reason', '')}")
current_price = last_close
result = self.executor.execute(signal, current_price, atr)
if result:
logger.info(f"[MTFBot] 거래 기록: {result}")
side = result["action"]
sl_dist = abs(result["entry_price"] - result["sl_price"])
tp_dist = abs(result["tp_price"] - result["entry_price"])
self.notifier.notify_info(
f"**[MTF Dry-run] 가상 {side} 진입**\n"
f"진입가: `{result['entry_price']:.4f}` | ATR: `{result['atr']:.6f}`\n"
f"SL: `{result['sl_price']:.4f}` ({sl_dist:.4f}) | "
f"TP: `{result['tp_price']:.4f}` ({tp_dist:.4f})\n"
f"R:R = `1:{result['risk_reward']}` | Meta: `{meta_state}`\n"
f"사유: {info.get('reason', '')}"
)
else:
logger.info(f"[MTFBot] HOLD | {info.get('reason', '')}")
def _check_sl_tp(self):
"""현재 가격으로 SL/TP 도달 여부 확인 (15m 캔들 high/low 기반)."""
df_15m = self.fetcher.get_15m_dataframe()
if df_15m is None or len(df_15m) < 1:
return
last = df_15m.iloc[-1]
pos = self.executor.current_position
sl = self.executor._sl_price
tp = self.executor._tp_price
entry = self.executor._entry_price
if pos is None or sl is None or tp is None:
return
hit_sl = hit_tp = False
if pos == "LONG":
hit_sl = last["low"] <= sl
hit_tp = last["high"] >= tp
else:
hit_sl = last["high"] >= sl
hit_tp = last["low"] <= tp
if hit_sl and hit_tp:
exit_price = sl
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
pnl_bps = pnl * 10000
logger.info(f"[MTFBot] SL+TP 동시 히트 → SL 우선 청산 | PnL: {pnl_bps:+.1f}bps")
self.executor.close_position(f"SL 히트 ({exit_price:.4f})", exit_price, pnl_bps)
self.notifier.notify_info(
f"**[MTF Dry-run] {pos} SL 청산**\n"
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
f"PnL: `{pnl_bps:+.1f}bps`"
)
elif hit_sl:
exit_price = sl
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
pnl_bps = pnl * 10000
logger.info(f"[MTFBot] SL 히트 | 청산가: {exit_price:.4f} | PnL: {pnl_bps:+.1f}bps")
self.executor.close_position(f"SL 히트 ({exit_price:.4f})", exit_price, pnl_bps)
self.notifier.notify_info(
f"**[MTF Dry-run] {pos} SL 청산**\n"
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
f"PnL: `{pnl_bps:+.1f}bps`"
)
elif hit_tp:
exit_price = tp
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
pnl_bps = pnl * 10000
logger.info(f"[MTFBot] TP 히트 | 청산가: {exit_price:.4f} | PnL: {pnl_bps:+.1f}bps")
self.executor.close_position(f"TP 히트 ({exit_price:.4f})", exit_price, pnl_bps)
self.notifier.notify_info(
f"**[MTF Dry-run] {pos} TP 청산**\n"
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
f"PnL: `{pnl_bps:+.1f}bps`"
)
# ═══════════════════════════════════════════════════════════════════
# 검증 테스트
# ═══════════════════════════════════════════════════════════════════
async def test_module_1_2():
"""Module 1 & 2 검증 테스트."""
print("=" * 60)
print(" MTF Bot Module 1 & 2 검증 테스트")
print("=" * 60)
# ── 1. TimeframeSync 검증 ──
print("\n[1] TimeframeSync 검증")
# 2026-01-01 01:00:03 UTC (1h 캔들 close 직후)
ts_1h_close = int(datetime(2026, 1, 1, 1, 0, 3, tzinfo=timezone.utc).timestamp() * 1000)
# 2026-01-01 00:15:04 UTC (15m 캔들 close 직후)
ts_15m_close = int(datetime(2026, 1, 1, 0, 15, 4, tzinfo=timezone.utc).timestamp() * 1000)
# 2026-01-01 00:15:00 UTC (정각 — 아직 딜레이 전)
ts_too_early = int(datetime(2026, 1, 1, 0, 15, 0, tzinfo=timezone.utc).timestamp() * 1000)
# 2026-01-01 00:15:10 UTC (너무 늦음)
ts_too_late = int(datetime(2026, 1, 1, 0, 15, 10, tzinfo=timezone.utc).timestamp() * 1000)
# 2026-01-01 00:07:03 UTC (15m 경계 아님)
ts_not_boundary = int(datetime(2026, 1, 1, 0, 7, 3, tzinfo=timezone.utc).timestamp() * 1000)
assert TimeframeSync.is_1h_candle_closed(ts_1h_close) is True, "1h close 판별 실패"
assert TimeframeSync.is_15m_candle_closed(ts_15m_close) is True, "15m close 판별 실패"
assert TimeframeSync.is_15m_candle_closed(ts_too_early) is False, "정각(0초)에 True 반환"
assert TimeframeSync.is_15m_candle_closed(ts_too_late) is False, "10초에 True 반환"
assert TimeframeSync.is_15m_candle_closed(ts_not_boundary) is False, "비경계 시점에 True 반환"
assert TimeframeSync.is_1h_candle_closed(ts_15m_close) is False, "15분에 1h close True 반환"
print(" ✓ TimeframeSync: second 2~5 범위에서만 True 반환 확인")
# ── 2. DataFetcher 초기화 ──
print("\n[2] DataFetcher 초기화")
fetcher = DataFetcher(symbol="XRP/USDT:USDT")
try:
await fetcher.initialize()
assert len(fetcher.klines_15m) == 200, f"15m 캔들 {len(fetcher.klines_15m)}개 (200 예상)"
assert len(fetcher.klines_1h) == 200, f"1h 캔들 {len(fetcher.klines_1h)}개 (200 예상)"
print(f" ✓ 초기화 완료: 15m={len(fetcher.klines_15m)}개, 1h={len(fetcher.klines_1h)}")
# ── 3. [:-1] 슬라이싱 검증 ──
print("\n[3] get_1h_dataframe_completed() [:-1] 검증")
df_1h = fetcher.get_1h_dataframe_completed()
assert df_1h is not None, "1h DataFrame이 None"
assert len(df_1h) == 199, f"1h completed 캔들 {len(df_1h)}개 (199 예상)"
# 마지막 완성 봉의 timestamp < 현재 진행 중 봉의 timestamp
last_completed_ts = df_1h.index[-1]
last_raw_ts = pd.to_datetime(fetcher.klines_1h[-1][0], unit="ms", utc=True)
assert last_completed_ts < last_raw_ts, "completed 봉이 진행 중 봉을 포함"
print(f" ✓ 1h completed: {len(df_1h)}개 (200 - 1 = 199, 미완성 봉 제외 확인)")
print(f" 마지막 완성 봉: {last_completed_ts}")
print(f" 진행 중 봉: {last_raw_ts} (제외됨)")
# 15m DataFrame 검증
df_15m = fetcher.get_15m_dataframe()
assert df_15m is not None and len(df_15m) == 200
print(f" ✓ 15m DataFrame: {len(df_15m)}")
# ── 4. MetaFilter 검증 ──
print("\n[4] MetaFilter 검증")
meta = MetaFilter(fetcher)
state = meta.get_market_state()
assert state in ("LONG_ALLOWED", "SHORT_ALLOWED", "WAIT"), f"비정상 상태: {state}"
print(f" ✓ MetaFilter 상태: {state}")
atr = meta.get_current_atr()
assert atr is not None and atr > 0, f"ATR 비정상: {atr}"
print(f" ✓ ATR: {atr:.6f} (> 0 확인)")
info = meta.get_meta_info()
print(f" ✓ Meta Info: {info}")
# ATR 범위 검증 (XRP 기준 0.0001 ~ 0.1)
assert 0.0001 <= atr <= 0.1, f"ATR 범위 이탈: {atr}"
print(f" ✓ ATR 범위 정상: 0.0001 <= {atr:.6f} <= 0.1")
finally:
await fetcher.close()
print("\n" + "=" * 60)
print(" 모든 검증 통과 ✓")
print("=" * 60)
async def test_module_3_4():
"""
Module 3 + 4 통합 테스트.
검증 항목:
[Module 3 - TriggerStrategy]
1. 신호 생성: 'EXECUTE_LONG' | 'EXECUTE_SHORT' | 'HOLD' 중 하나 반환
2. EMA15: NaN 아님, 양수, 현실적 범위
3. Vol_SMA20: NaN 아님, 양수
4. vol_ratio: 0.0 ~ 2.0+ 범위 내
5. 3캔들 시퀀스: t-2, t-1, t 인덱싱 정확성
6. meta_state 필터: 'LONG_ALLOWED'에서만 LONG, 'SHORT_ALLOWED'에서만 SHORT
[Module 4 - ExecutionManager]
7. 포지션 중복 방지
8. SL/TP 계산: ATR * 1.5 (SL), ATR * 2.3 (TP)
9. Dry-run 로그 출력
10. 청산 후 재진입 가능
"""
print("=" * 60)
print(" MTF Bot Module 3 & 4 통합 테스트")
print("=" * 60)
# ── DataFetcher로 실제 데이터 로드 ──
fetcher = DataFetcher(symbol="XRP/USDT:USDT")
try:
await fetcher.initialize()
df_15m = fetcher.get_15m_dataframe()
assert df_15m is not None and len(df_15m) >= 25, "15m 데이터 부족"
meta = MetaFilter(fetcher)
meta_state = meta.get_market_state()
atr = meta.get_current_atr()
print(f"\n[환경] MetaFilter: {meta_state} | ATR: {atr}")
# ── [Module 3] TriggerStrategy 검증 ──
print("\n[1] TriggerStrategy 신호 생성")
trigger = TriggerStrategy()
# 테스트 1: 실제 데이터로 신호 생성
signal = trigger.generate_signal(df_15m, meta_state)
assert signal in ("EXECUTE_LONG", "EXECUTE_SHORT", "HOLD"), f"비정상 신호: {signal}"
print(f" ✓ 신호: {signal}")
info = trigger.get_trigger_info()
print(f" ✓ Trigger Info: {info}")
# 테스트 2: 지표 값 검증
if "ema15_t" in info:
assert not pd.isna(info["ema15_t"]) and info["ema15_t"] > 0, "EMA15 비정상"
assert not pd.isna(info["vol_sma20_t2"]) and info["vol_sma20_t2"] > 0, "Vol SMA20 비정상"
assert 0 <= info["vol_ratio"] <= 100, f"vol_ratio 비정상: {info['vol_ratio']}"
print(f" ✓ EMA15(t): {info['ema15_t']:.4f}")
print(f" ✓ Vol SMA20(t-2): {info['vol_sma20_t2']:.0f}")
print(f" ✓ Vol ratio: {info['vol_ratio']:.4f} ({'고갈' if info['vol_ratio'] < 0.5 else '정상'})")
# 테스트 3: meta_state=WAIT → 무조건 HOLD
signal_wait = trigger.generate_signal(df_15m, "WAIT")
assert signal_wait == "HOLD", "WAIT 상태에서 HOLD 아닌 신호 발생"
print(f" ✓ meta_state=WAIT → {signal_wait}")
# 테스트 4: 데이터 부족 → HOLD
signal_short = trigger.generate_signal(df_15m.iloc[:10], "LONG_ALLOWED")
assert signal_short == "HOLD", "데이터 부족에서 HOLD 아닌 신호 발생"
print(f" ✓ 데이터 부족(10행) → {signal_short}")
# 테스트 5: None DataFrame → HOLD
signal_none = trigger.generate_signal(None, "LONG_ALLOWED")
assert signal_none == "HOLD"
print(f" ✓ None DataFrame → HOLD")
# ── [Module 4] ExecutionManager 검증 ──
print(f"\n[2] ExecutionManager 검증")
executor = ExecutionManager()
# 테스트 6: HOLD → None
result = executor.execute("HOLD", 2.5, 0.01)
assert result is None, "HOLD에서 주문 실행됨"
print(f" ✓ HOLD → None")
# 테스트 7: ATR 무효 → None
result = executor.execute("EXECUTE_LONG", 2.5, None)
assert result is None, "ATR=None에서 주문 실행됨"
result = executor.execute("EXECUTE_LONG", 2.5, 0)
assert result is None, "ATR=0에서 주문 실행됨"
print(f" ✓ ATR 무효 → None")
# 테스트 8: 정상 LONG 주문
print(f"\n [LONG 가상 주문 테스트]")
test_atr = 0.01
result = executor.execute("EXECUTE_LONG", 2.5340, test_atr)
assert result is not None, "정상 주문이 None 반환"
assert result["action"] == "LONG"
assert abs(result["sl_price"] - (2.5340 - 0.01 * 1.5)) < 1e-8, "SL 계산 오류"
assert abs(result["tp_price"] - (2.5340 + 0.01 * 2.3)) < 1e-8, "TP 계산 오류"
assert result["risk_reward"] == 1.53, f"R:R 오류: {result['risk_reward']}"
print(f" ✓ LONG 주문: entry={result['entry_price']}, SL={result['sl_price']:.4f}, TP={result['tp_price']:.4f}")
print(f" ✓ R:R = 1:{result['risk_reward']}")
# 테스트 9: 포지션 중복 방지
result_dup = executor.execute("EXECUTE_SHORT", 2.5000, test_atr)
assert result_dup is None, "중복 포지션 허용됨"
assert executor.current_position == "LONG", "포지션 상태 변경됨"
print(f" ✓ 중복 차단: LONG 포지션 중 SHORT 신호 → None")
# 테스트 10: 청산 후 재진입
executor.close_position("테스트 청산")
assert executor.current_position is None, "청산 후 포지션 잔존"
print(f" ✓ 청산 완료, 포지션=None")
# 테스트 11: SHORT 주문
print(f"\n [SHORT 가상 주문 테스트]")
result_short = executor.execute("EXECUTE_SHORT", 2.5340, test_atr)
assert result_short is not None
assert result_short["action"] == "SHORT"
assert abs(result_short["sl_price"] - (2.5340 + 0.01 * 1.5)) < 1e-8, "SHORT SL 오류"
assert abs(result_short["tp_price"] - (2.5340 - 0.01 * 2.3)) < 1e-8, "SHORT TP 오류"
print(f" ✓ SHORT 주문: entry={result_short['entry_price']}, SL={result_short['sl_price']:.4f}, TP={result_short['tp_price']:.4f}")
executor.close_position("테스트 종료")
# 테스트 12: 빈 포지션 청산 → 에러 없이 처리
executor.close_position("이미 청산됨")
print(f" ✓ 빈 포지션 청산 → 에러 없음")
finally:
await fetcher.close()
print("\n" + "=" * 60)
print(" Module 3 & 4 모든 검증 통과 ✓")
print("=" * 60)
async def test_all():
"""Module 1~4 전체 검증."""
await test_module_1_2()
print("\n")
await test_module_3_4()
if __name__ == "__main__":
asyncio.run(test_all())

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