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개-레이어-상세) — 각 레이어의 역할과 동작 원리
4. [MLOps 파이프라인](#4-mlops-파이프라인) — ML 모델의 학습·배포·모니터링 전체 흐름
5. [핵심 동작 시나리오](#5-핵심-동작-시나리오) — 실제 상황별 봇의 동작 흐름도
5-1. [MTF Pullback Bot](#5-1-mtf-pullback-bot) — 멀티타임프레임 풀백 전략 Dry-run 봇
6. [테스트 커버리지](#6-테스트-커버리지) — 무엇을 어떻게 테스트하는지
7. [파일 구조](#7-파일-구조) — 전체 파일 역할 요약
@@ -329,7 +330,7 @@ ML 필터를 통과한 신호를 실제 주문으로 변환하고, 리스크 한
**주문 흐름:**
```
1. set_leverage(10x)
1. set_leverage(20x)
2. place_order(MARKET) ← 진입
3. place_order(STOP_MARKET) ← SL 설정 (3회 재시도)
4. place_order(TAKE_PROFIT_MARKET) ← TP 설정 (3회 재시도)
@@ -602,7 +603,7 @@ sequenceDiagram
BOT->>EX: get_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(STOP_MARKET, SELL, stop=2.3100)
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.1 테스트 실행
@@ -677,25 +875,29 @@ pytest tests/ -v # 전체 실행
bash scripts/run_tests.sh # 래퍼 스크립트 실행
```
`tests/` 폴더에 15개 테스트 파일, 총 **138개의 테스트 케이스**가 작성되어 있습니다.
`tests/` 폴더에 19개 테스트 파일, 총 **191개의 테스트 케이스**가 작성되어 있습니다.
### 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_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_risk_manager.py` | `src/risk_manager.py` | 13 | 일일 손실 한도, 동일 방향 제한, 동적 증거금 비율 |
| `test_exchange.py` | `src/exchange.py` | 8 | 수량 계산, OI·펀딩비 조회 정상/오류 |
| `test_data_stream.py` | `src/data_stream.py` | 6 | 3심볼 버퍼, 캔들 파싱, 프리로드 200개 |
| `test_risk_manager.py` | `src/risk_manager.py` | 15 | 일일 손실 한도, 동일 방향 제한, 동적 증거금 비율 |
| `test_exchange.py` | `src/exchange.py` | 12 | 수량 계산, OI·펀딩비 조회 정상/오류 |
| `test_data_stream.py` | `src/data_stream.py` | 7 | 3심볼 버퍼, 캔들 파싱, 프리로드 200개 |
| `test_label_builder.py` | `src/label_builder.py` | 4 | TP/SL 도달 레이블, 미결 → None |
| `test_dataset_builder.py` | `src/dataset_builder.py` | 9 | DataFrame 반환, 필수 컬럼, inf/NaN 없음 |
| `test_dataset_builder.py` | `src/dataset_builder.py` | 14 | DataFrame 반환, 필수 컬럼, inf/NaN 없음 |
| `test_mlx_filter.py` | `src/mlx_filter.py` | 5 | GPU 학습, 저장/로드 동일 예측 (Apple Silicon 전용) |
| `test_fetch_history.py` | `scripts/fetch_history.py` | 5 | OI=0 Upsert, 중복 방지, 타임스탬프 정렬 |
| `test_config.py` | `src/config.py` | 6 | 환경변수 로드, symbols 리스트 파싱 |
| `test_weekly_report.py` | `scripts/weekly_report.py` | 15 | 백테스트, 대시보드 API, 추이 분석, ML 트리거, 스윕 |
| `test_config.py` | `src/config.py` | 9 | 환경변수 로드, symbols 리스트 파싱 |
| `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` 패키지)이 없는 환경에서 자동 스킵됩니다.
@@ -720,6 +922,7 @@ bash scripts/run_tests.sh # 래퍼 스크립트 실행
| OI 변화율 계산 | ✅ | ✅ | `test_bot` |
| Parquet Upsert | ✅ | — | `test_fetch_history` |
| 주간 리포트 | ✅ | ✅ | `test_weekly_report` |
| MTF Pullback Bot | ✅ | ✅ | `test_mtf_bot` (20 cases) |
| User Data Stream TP/SL | ❌ | — | 미작성 (WebSocket 의존) |
| Discord 알림 전송 | ❌ | — | 미작성 (외부 웹훅 의존) |
@@ -750,6 +953,8 @@ bash scripts/run_tests.sh # 래퍼 스크립트 실행
| `src/label_builder.py` | MLOps | 학습 레이블 생성 (ATR SL/TP 룩어헤드) |
| `src/dataset_builder.py` | MLOps | 벡터화 데이터셋 빌더 (학습용) |
| `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 로거 설정 |
| `scripts/fetch_history.py` | MLOps | 과거 캔들 + OI/펀딩비 수집 |
| `scripts/train_model.py` | MLOps | LightGBM 모델 학습 |
@@ -762,4 +967,16 @@ bash scripts/run_tests.sh # 래퍼 스크립트 실행
| `scripts/compare_symbols.py` | MLOps | 종목 비교 백테스트 (심볼별 파라미터 sweep) |
| `scripts/position_sizing_analysis.py` | MLOps | Robust Monte Carlo 포지션 사이징 분석 |
| `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 파라미터 |

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-22 | `backtest-market-context` (design) | 설계 완료, 구현 대기 |
| 2026-03-22 | `testnet-uds-verification` (design) | 설계 완료, 구현 대기 |
| 2026-03-30 | `ls-ratio-backtest` (design + result) | Edge 없음 확정, 폐기 |
| 2026-03-30 | `fr-oi-backtest` (result) | SHORT PF=1.88이나 대칭성 실패(Case2), 폐기 |
| 2026-03-30 | `public-api-research-closed` | Binance 공개 API 전수 테스트 완료, 단독 edge 없음 |
| 2026-03-30 | `mtf-pullback-bot` | MTF Pullback Bot 배포, 4월 OOS Dry-run 검증 진행 중 |

20
Jenkinsfile vendored
View File

@@ -47,10 +47,15 @@ pipeline {
if (changes == 'ALL') {
// 첫 빌드이거나 diff 실패 시 전체 빌드
env.BOT_CHANGED = 'true'
env.MTF_CHANGED = 'true'
env.DASH_API_CHANGED = 'true'
env.DASH_UI_CHANGED = 'true'
} else {
env.BOT_CHANGED = (changes =~ /(?m)^(src\/|scripts\/|main\.py|requirements\.txt|Dockerfile)/).find() ? 'true' : 'false'
// 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_UI_CHANGED = (changes =~ /(?m)^dashboard\/ui\//).find() ? 'true' : 'false'
}
@@ -62,7 +67,7 @@ pipeline {
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') {
parallel {
stage('Bot') {
when { expression { env.BOT_CHANGED == 'true' } }
when { expression { env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true' } }
steps {
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')]) {
sh "echo \$GITEA_TOKEN | docker login ${REGISTRY} -u \$GITEA_USER --password-stdin"
script {
if (env.BOT_CHANGED == 'true') {
if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') {
sh "docker push ${FULL_IMAGE}"
sh "docker push ${LATEST_IMAGE}"
}
@@ -127,6 +132,9 @@ pipeline {
services.add('cointrader')
services.add('ls-ratio-collector')
}
if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') {
services.add('mtf-bot')
}
if (env.DASH_API_CHANGED == 'true') services.add('dashboard-api')
if (env.DASH_UI_CHANGED == 'true') services.add('dashboard-ui')
@@ -144,7 +152,7 @@ pipeline {
stage('Cleanup') {
steps {
script {
if (env.BOT_CHANGED == 'true') {
if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') {
sh "docker rmi ${FULL_IMAGE} || true"
sh "docker rmi ${LATEST_IMAGE} || true"
}
@@ -167,7 +175,7 @@ pipeline {
sh """
curl -H "Content-Type: application/json" \
-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}
"""
}

View File

@@ -25,6 +25,7 @@ Binance Futures 자동매매 봇. 복합 기술 지표와 킬스위치로 XRPUSD
- **모니터링 대시보드**: 거래 내역, 수익 통계, 차트를 웹에서 조회
- **주간 전략 리포트**: 자동 성능 측정, 추이 추적, 킬스위치 모니터링, ML 재학습 시점 판단
- **종목 비교 분석**: 심볼별 파라미터 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 # 학습 레이블 생성
│ ├── dataset_builder.py # 벡터화 데이터셋 빌더 (학습용)
│ ├── backtester.py # 백테스트 엔진 (단일 + Walk-Forward)
│ ├── mtf_bot.py # MTF Pullback Bot (1h MetaFilter + 15m 3캔들 풀백 + Dry-run)
│ ├── risk_manager.py # 공유 리스크 관리 (asyncio.Lock, 동일 방향 제한)
│ ├── notifier.py # Discord 웹훅 알림
│ └── logger_setup.py # Loguru 로거 설정

View File

@@ -63,6 +63,7 @@ services:
- PYTHONUNBUFFERED=1
volumes:
- ./logs:/app/logs
- ./data:/app/data
entrypoint: ["python", "main_mtf.py"]
logging:
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] 슬라이싱)
- Binance 서버 딜레이 고려: 캔들 판별 시 2~5초 range
- REST 폴링 기반 안정성: WebSocket 대신 30초 주기 폴링
- 메모리 최적화: deque(maxlen=200)
- 메모리 최적화: 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
@@ -78,12 +81,25 @@ class DataFetcher:
"enableRateLimit": True,
"options": {"defaultType": "future"},
})
self.klines_15m: deque = deque(maxlen=200)
self.klines_1h: deque = deque(maxlen=200)
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
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.
@@ -93,16 +109,16 @@ class DataFetcher:
return await self.exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
async def initialize(self):
"""봇 시작 시 초기 데이터 로드 (200개씩)."""
"""봇 시작 시 초기 데이터 로드 (250개씩)."""
# 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:
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=200)
raw_1h = await self.fetch_ohlcv(self.symbol, "1h", limit=250)
for candle in raw_1h:
self.klines_1h.append(candle)
if raw_1h:
@@ -112,69 +128,31 @@ class DataFetcher:
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]:
"""모든 15m 캔들을 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 df
return self._remove_incomplete_candle(df, interval_sec=900)
def get_1h_dataframe_completed(self) -> Optional[pd.DataFrame]:
"""
'완성된' 1h 캔들만 반환.
핵심: [:-1] 슬라이싱으로 진행 중인 최신 1h 캔들 제외.
조건부 슬라이싱: _remove_incomplete_candle()로 진행 중인 최신 1h 캔들 제외.
이유: Look-ahead bias 원천 차단 — 아직 완성되지 않은 캔들의
high/low/close는 미래 데이터이므로 지표 계산에 사용하면 안 됨.
"""
if len(self.klines_1h) < 2:
return None
completed = list(self.klines_1h)[:-1] # ← 핵심: 미완성 봉 제외
df = pd.DataFrame(completed, columns=["timestamp", "open", "high", "low", "close", "volume"])
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 df
return self._remove_incomplete_candle(df, interval_sec=3600)
async def close(self):
"""ccxt exchange 연결 정리."""
@@ -194,15 +172,27 @@ class MetaFilter:
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 계산."""
"""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:
@@ -398,6 +388,9 @@ class TriggerStrategy:
# Module 4: ExecutionManager
# ═══════════════════════════════════════════════════════════════════
_MTF_TRADE_DIR = Path("data/trade_history")
class ExecutionManager:
"""
TriggerStrategy의 신호를 받아 포지션 상태를 관리하고
@@ -407,11 +400,14 @@ class ExecutionManager:
ATR_SL_MULT = 1.5
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._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]:
"""
@@ -454,8 +450,10 @@ class ExecutionManager:
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)
@@ -491,8 +489,8 @@ class ExecutionManager:
"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:
logger.debug("[ExecutionManager] 청산할 포지션 없음")
return
@@ -502,6 +500,9 @@ class ExecutionManager:
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)
@@ -510,8 +511,34 @@ class ExecutionManager:
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:
"""현재 포지션 정보 반환."""
@@ -534,18 +561,23 @@ class ExecutionManager:
class MTFPullbackBot:
"""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"):
self.symbol = symbol
self.fetcher = DataFetcher(symbol=symbol)
self.meta = MetaFilter(self.fetcher)
self.trigger = TriggerStrategy()
self.executor = ExecutionManager()
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 감지 → 신호 판정."""
@@ -565,16 +597,22 @@ class MTFPullbackBot:
try:
while True:
await asyncio.sleep(self.POLL_INTERVAL)
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 모니터링
@@ -613,23 +651,38 @@ class MTFPullbackBot:
atr = self.meta.get_current_atr()
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
logger.info(f"[MTFBot] ── 15m 캔들 close ({now_str}) ──")
logger.info(f"[MTFBot] Meta: {meta_state} | ATR: {atr:.6f}" if atr else f"[MTFBot] Meta: {meta_state} | ATR: N/A")
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 = float(df_15m.iloc[-1]["close"])
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._send(
f"📌 **[MTF Dry-run] 가상 {side} 진입**\n"
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"
@@ -637,7 +690,7 @@ class MTFPullbackBot:
f"사유: {info.get('reason', '')}"
)
else:
logger.debug(f"[MTFBot] HOLD | {info.get('reason', '')}")
logger.info(f"[MTFBot] HOLD | {info.get('reason', '')}")
def _check_sl_tp(self):
"""현재 가격으로 SL/TP 도달 여부 확인 (15m 캔들 high/low 기반)."""
@@ -665,32 +718,35 @@ class MTFPullbackBot:
if hit_sl and hit_tp:
exit_price = sl
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")
self.executor.close_position(f"SL 히트 ({exit_price:.4f})")
self.notifier._send(
f"❌ **[MTF Dry-run] {pos} SL 청산**\n"
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*10000:+.1f}bps`"
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
logger.info(f"[MTFBot] SL 히트 | 청산가: {exit_price:.4f} | PnL: {pnl*10000:+.1f}bps")
self.executor.close_position(f"SL 히트 ({exit_price:.4f})")
self.notifier._send(
f"❌ **[MTF Dry-run] {pos} SL 청산**\n"
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*10000:+.1f}bps`"
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
logger.info(f"[MTFBot] TP 히트 | 청산가: {exit_price:.4f} | PnL: {pnl*10000:+.1f}bps")
self.executor.close_position(f"TP 히트 ({exit_price:.4f})")
self.notifier._send(
f"✅ **[MTF Dry-run] {pos} TP 청산**\n"
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*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