2 Commits

Author SHA1 Message Date
21in7
09ae926f06 docs: 전략 post-mortem — 7전 7패 분석 및 다음 방향 제안
- 공통 실패 패턴 3가지 (IS/OOS 격차, 대칭성 실패, 필터 역효과)
- 근본 원인: 공개 시그널 방향 예측 패러다임 한계
- 다음 방향: 패러다임 변경(stat arb), 비용 절감(maker), 데이터 확장

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-04 09:30:10 +09:00
21in7
f53b8a5a0f research: MTF + BTC 추세 필터 백테스트 — FAIL, MTF 전략 최종 폐기
- 메인 가설(BTC 1h EMA50/200 ADX>20) OOS fees PF 0.90, 베이스라인(0.94)보다 악화
- 12개 sweep 조합 중 합격(fees PF>=1.2) 0개
- 761일 데이터로 전략 근본적 edge 부재 확인

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-04 09:24:45 +09:00
6 changed files with 5302 additions and 1 deletions

View File

@@ -154,6 +154,8 @@ All design documents and implementation plans are stored in `docs/plans/` with t
| 2026-03-30 | `ls-ratio-backtest` (design + result) | Edge 없음 확정, 폐기 | | 2026-03-30 | `ls-ratio-backtest` (design + result) | Edge 없음 확정, 폐기 |
| 2026-03-30 | `fr-oi-backtest` (result) | SHORT PF=1.88이나 대칭성 실패(Case2), 폐기 | | 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 | `public-api-research-closed` | Binance 공개 API 전수 테스트 완료, 단독 edge 없음 |
| 2026-03-30 | `mtf-pullback-bot` | MTF Pullback Bot 배포, 4월 OOS Dry-run 검증 진행 중 | | 2026-03-30 | `mtf-pullback-bot` | MTF Pullback Bot **최종 폐기** (OOS+BTC필터 모두 실패) |
| 2026-04-21 | `mtf-oos-dryrun-result` | 중간 보고 — 24건 Raw PF 0.98 | | 2026-04-21 | `mtf-oos-dryrun-result` | 중간 보고 — 24건 Raw PF 0.98 |
| 2026-05-04 | `mtf-oos-final-result` | **FAIL, 폐기** — 30건 fees_only PF 0.84, SHORT 대칭성 실패 | | 2026-05-04 | `mtf-oos-final-result` | **FAIL, 폐기** — 30건 fees_only PF 0.84, SHORT 대칭성 실패 |
| 2026-05-04 | `mtf-btc-filter` (design + result) | **FAIL, 최종 폐기** — BTC 필터 추가해도 OOS PF 0.90, 베이스라인보다 악화 |
| 2026-05-04 | `strategy-post-mortem` | 7전 7패 분석 — 공개 시그널 방향 예측 패러다임 한계, 다음 방향 제안 |

View File

@@ -0,0 +1,79 @@
# MTF + BTC 추세 필터 백테스트 설계
## 가설
BTC 추세 방향과 일치하는 MTF 풀백 시그널만 실행하면 비용 반영 후에도 PF > 1.2를 달성한다.
## 메인 가설 (사전 확정 — commitment device)
> 메인 가설은 sweep 결과를 보기 **전에** BTC 1h + EMA 50/200 + ADX > 20으로 확정한다.
> sweep 12개 결과에서 가장 좋은 조합으로 사후 변경하지 않는다.
> 4h/1d 결과는 robustness 참고용이며, 메인 가설이 OOS 실패 시
> "다른 조합이 됐으니 PASS"로 구제하지 않는다.
- **BTC 타임프레임**: 1h
- **BTC EMA**: fast=50, slow=200
- **BTC ADX 임계값**: 20
- **선택 근거**: XRP MTF bot의 1h 메타필터와 동일 기준 → 시그널 정합성 확보, 사후 정당화 차단
## 필터 로직
```
BTC_trend = (BTC EMA_fast > BTC EMA_slow) AND (BTC ADX > 20)
? (EMA_fast > EMA_slow ? "UP" : "DOWN")
: "NEUTRAL"
if BTC_trend == "UP": SHORT 차단, LONG만 허용
if BTC_trend == "DOWN": LONG 차단, SHORT만 허용
if BTC_trend == "NEUTRAL": 양방향 차단 (추세 불명확)
```
## Sweep 파라미터 (robustness check용)
| 파라미터 | 후보 | 설명 |
|---------|------|------|
| BTC 타임프레임 | 1h, 4h, 1d | BTC 추세 판단 주기 |
| BTC EMA fast | 20, 50 | 단기 EMA |
| BTC EMA slow | 100, 200 | 장기 EMA |
총 조합: 3 × 2 × 2 = 12개. ADX > 20은 전 조합 고정.
## 데이터
- XRP: `data/xrpusdt/combined_15m.parquet` (기존)
- BTC: `data/btcusdt/combined_15m.parquet` (fetch 필요)
- 기간: 최소 6개월 (XRP와 동일 기간)
- BTC 15m → 1h/4h/1d resample 후 EMA/ADX 계산
- merge: `merge_asof(direction="backward")` — look-ahead bias 방지
- fetch 후 XRP/BTC 첫/마지막 timestamp + bar 수 일치 검증 필수
## IS/OOS 분할
- 앞 70% IS, 뒤 30% OOS (단순 시간 분할)
- ML 없고 sweep 12개뿐이므로 walk-forward 불필요
## 합격 기준
| 기준 | 값 | 비고 |
|------|-----|------|
| 메인 가설 OOS fees_only PF | >= 1.2 | 실거래 마진 확보 |
| 메인 가설 OOS realistic PF | >= 1.0 | 슬리피지+펀딩 반영 후 흑자 |
| LONG/SHORT 양쪽 fees_only PF | >= 0.8 | 대칭성 |
| OOS 거래 수 | >= 50 | 통계적 유의성 |
| IS/OOS PF 격차 | < 30% | 과적합 방지 |
| 베이스라인 대비 OOS PF | 명확한 개선 | 절대값보다 차이 중요 |
| IS 거래 수 | >= 100 | 미달 시 조합 자동 제외 |
## 판정 흐름
1. 베이스라인(BTC 필터 없는 MTF) IS/OOS 결과 먼저 산출
2. 12개 조합 IS sweep, 모두 결과 저장 (IS 거래 수 < 100 자동 제외)
3. 메인 가설(1h/EMA50-200/ADX20) OOS 검증 → 합격 기준 통과 시 PASS
4. 나머지 11개도 OOS 검증 → robustness 보고서 (참고용)
5. 메인 가설 실패 시 → 전략 폐기 (다른 조합으로 구제 안 함)
## 산출물
- `scripts/mtf_btc_filter_backtest.py` — 백테스트 스크립트
- `docs/plans/2026-05-04-mtf-btc-filter-result.md` — 결과 문서
- 거래 수준 로그 (CSV) — entry/exit/BTC trend/PnL 포함, 사후 분석용

View File

@@ -0,0 +1,80 @@
# MTF + BTC 추세 필터 백테스트 결과
## 가설
BTC 추세 방향과 일치하는 MTF 풀백 시그널만 실행하면 비용 반영 후에도 PF > 1.2를 달성한다.
메인 가설 (사전 확정): BTC 1h + EMA 50/200 + ADX > 20
## 데이터
- 심볼: XRPUSDT
- 기간: 2024-04-02 ~ 2026-05-03 (761일, 73,123 bars)
- IS: 2024-04-02 ~ 2025-09-17 (70%)
- OOS: 2025-09-17 ~ 2026-05-03 (30%)
- BTC OHLCV: XRP parquet 내 상관 컬럼 활용 (NaN 0%)
## 베이스라인 (BTC 필터 없음)
| 구분 | IS PF(fees) | OOS PF(fees) | OOS 거래수 |
|------|------------|-------------|-----------|
| Total | 1.02 | 0.94 | 206 |
| LONG | 1.04 | 0.71 | 69 |
| SHORT | 1.01 | 1.06 | 137 |
베이스라인 자체가 OOS에서 적자 (fees PF 0.94).
## 메인 가설 결과 (BTC 1h EMA50/200 ADX>20)
| 구분 | IS PF(fees) | OOS PF(fees) | OOS PF(real) | OOS 거래수 |
|------|------------|-------------|-------------|-----------|
| Total | 1.12 | **0.90** | 0.88 | 158 |
| LONG | 1.24 | 0.81 | 0.78 | 56 |
| SHORT | 1.02 | 0.95 | 0.92 | 102 |
## 합격 기준 체크
| 기준 | 결과 | 판정 |
|------|------|------|
| OOS fees_only PF >= 1.2 | 0.90 | **FAIL** |
| OOS realistic PF >= 1.0 | 0.88 | **FAIL** |
| OOS 거래수 >= 50 | 158 | PASS |
| LONG/SHORT fees PF >= 0.8 | L:0.81 S:0.95 | PASS |
| IS/OOS PF 격차 < 30% | 19.6% | PASS |
| 베이스라인 대비 개선 | 0.90 vs 0.94 | **FAIL** |
## Sweep Robustness
| 조합 | IS N | IS FPF | OOS FPF | OOS rPF | 비고 |
|------|------|--------|---------|---------|------|
| BTC_1h_EMA20/100 | 327 | 1.19 | 0.84 | 0.81 | |
| BTC_1h_EMA20/200 | 333 | 1.16 | 0.86 | 0.84 | |
| BTC_1h_EMA50/100 | 335 | 1.14 | 0.83 | 0.80 | |
| **BTC_1h_EMA50/200** | **333** | **1.12** | **0.90** | **0.88** | **메인 ★** |
| BTC_4h_EMA20/100 | 310 | 1.00 | 1.04 | 1.01 | |
| BTC_4h_EMA20/200 | 269 | 0.95 | 1.09 | 1.06 | |
| BTC_4h_EMA50/100 | 271 | 0.91 | 1.07 | 1.04 | |
| BTC_4h_EMA50/200 | 231 | 0.91 | 1.01 | 0.98 | |
| BTC_1d_EMA20/100 | 151 | 1.10 | 1.17 | 1.13 | |
| BTC_1d_EMA20/200 | 94 | 1.41 | 1.28 | 1.25 | IS<100 SKIP |
| BTC_1d_EMA50/100 | 129 | 1.28 | 1.20 | 1.16 | |
| BTC_1d_EMA50/200 | 96 | 1.44 | 1.14 | 1.11 | IS<100 SKIP |
- 1h 조합: **전멸** (OOS fees PF 0.83~0.90)
- 4h 조합: 1.01~1.09 — BEP 수준, 1.2 미달
- 1d 조합: 1.17~1.28 — 겉보기 좋으나 IS 거래수 94~151로 과적합 위험 + 사전 합의에 의해 구제 불가
12개 중 fees PF >= 1.2 통과는 **1개** (BTC_1d_EMA20/100, 1.17로 미달 → 실제 0개).
## 핵심 발견
1. **BTC 필터가 성과를 악화시킴**: 메인 가설 OOS PF 0.90 < 베이스라인 0.94. 필터가 오히려 좋은 거래를 걸러냄
2. **IS/OOS 격차 패턴**: 1h 조합은 IS에서 과적합(1.12~1.19) → OOS 붕괴(0.83~0.90). 전형적 curve-fitting
3. **1d가 좋아보이는 함정**: IS 거래수가 94~151로 적어 통계적 유의성 부족. 사전 합의대로 구제 불가
4. **MTF Pullback 자체의 한계**: 베이스라인 OOS 0.94로 전략 자체에 edge 없음. 필터로 보완 불가능
## 결론
**FAIL** — MTF Pullback 전략 최종 폐기.
BTC 추세 필터(1h EMA50/200 ADX>20)는 OOS에서 베이스라인보다 오히려 악화. 12개 sweep 조합 중 합격 기준(fees PF >= 1.2)을 통과한 조합 0개. 베이스라인 자체도 OOS 적자(PF 0.94)로 전략의 근본적 edge 부재 확인.

View File

@@ -0,0 +1,133 @@
# CoinTrader 전략 Post-Mortem (2026-05-04)
지금까지 시도한 모든 전략의 실패 원인을 분석하고, 다음 방향을 도출한다.
## 1. 사망자 명단
| # | 전략 | 기간 | IS PF | OOS PF(fees) | 사망 원인 |
|---|------|------|-------|-------------|----------|
| 1 | RSI+MACD+BB+EMA+StochRSI (기본) | 2026-03 | — | 0.89 | 수익성 부족 |
| 2 | ML Filter (LightGBM/ONNX) | 2026-03 | — | ML OFF > ML ON | 피처에 alpha 없음 |
| 3 | LS Ratio | 2026-03 | — | 0.86 (best) | r=0.12, 비용 커버 불가 |
| 4 | FR × OI | 2026-03 | — | SHORT 1.88 / LONG 0.50 | 대칭성 실패 |
| 5 | Taker Buy/Sell Ratio | 2026-03 | — | 0.93 | r=-0.08, 약함 |
| 6 | MTF Pullback (OOS dry-run) | 2026-04 | — | 0.84 (fees) | SHORT 역 edge |
| 7 | MTF Pullback + BTC 필터 | 2026-05 | 1.12 | 0.90 (fees) | 베이스라인보다 악화 |
**7전 7패.** 단 하나도 비용 반영 후 OOS PF > 1.0을 달성하지 못함.
## 2. 공통 실패 패턴
### 패턴 A: "IS에서 살고 OOS에서 죽는다"
| 전략 | IS fees PF | OOS fees PF | 격차 |
|------|-----------|-------------|------|
| 기본 시그널 | ~1.02 | 0.89 | -13% |
| MTF Pullback | 1.02 | 0.94 | -8% |
| MTF + BTC 1h | 1.12 | 0.90 | -20% |
IS에서 마진이 얇은 전략(PF 1.0~1.1)은 OOS에서 비용을 이기지 못한다. **IS PF < 1.3이면 OOS 통과 가능성 거의 0.**
### 패턴 B: "LONG/SHORT 중 한쪽만 작동"
| 전략 | 좋은 쪽 | 나쁜 쪽 | 진단 |
|------|---------|---------|------|
| FR × OI | SHORT PF 1.88 | LONG PF 0.50 | 시장 베타 |
| MTF OOS (4월) | LONG PF 1.28 | SHORT PF 0.73 | 상승장 편승 |
| MTF 백테스트 OOS | SHORT PF 1.06 | LONG PF 0.71 | 하락장 편승 |
한쪽만 좋은 건 "전략의 edge"가 아니라 **해당 기간의 시장 방향(beta)**을 타고 있을 뿐. 기간이 바뀌면 좋은 쪽과 나쁜 쪽이 뒤집힌다 (4월 OOS에서 LONG이 좋았고, 전체 백테스트 OOS에서는 SHORT가 좋았음).
### 패턴 C: "필터/피처를 추가할수록 악화"
| 레이어 | OOS fees PF |
|--------|-------------|
| 기본 시그널 | 0.89 |
| + ML Filter | 더 나빠짐 |
| MTF Pullback (베이스라인) | 0.94 |
| + BTC 추세 필터 | 0.90 (악화) |
필터를 추가하면 **좋은 거래도 같이 걸러진다**. 기본 시그널 자체에 edge가 없으면 필터로 구제 불가능. 오히려 거래 수 감소 → 통계적 불안정 → 악화.
## 3. 근본 원인 진단: Edge 부재 vs Regime Dependence?
**답: 둘 다. 그리고 이 둘은 연결되어 있다.**
1. **Edge 부재가 근본**: RSI/MACD/BB/EMA/StochRSI + 풀백 패턴은 너무 많은 참가자가 아는 시그널. 알파가 소진된 공공재. r < 0.15인 시그널은 수수료 8bps를 이길 수 없다.
2. **Regime dependence는 결과**: edge가 없는 전략은 시장 방향(beta)에 수익이 좌우된다. 상승장에선 LONG이, 하락장에선 SHORT가 좋아 보이지만 이건 전략의 alpha가 아니라 시장의 beta.
3. **검증 방법론은 정상 작동**: IS/OOS 분할, 비용 모델, 대칭성 체크, 사전 가설 확정 — 이 프레임워크가 7개 전략을 정확히 거부했다. 문제는 방법론이 아니라 **시그널 소스**.
## 4. "뭘 안 시도했나?" — 미탐색 영역 지도
### 이미 소진한 영역 (재시도 금지)
- Binance 공개 API 파생 피처 (전수 테스트 완료)
- 기술적 지표 조합 (RSI, MACD, BB, EMA, StochRSI, ADX)
- ML 피처 선택/앙상블 (LightGBM, ONNX — 피처 자체에 alpha 없음)
- 시간대 변경 (15m→1h→4h→1d sweep 완료)
- 상관 자산 필터 (BTC 추세 필터 검증 완료)
### 미탐색 영역
| 방향 | 구체적 아이디어 | 난이도 | 기대값 |
|------|---------------|--------|--------|
| **데이터 소스 변경** | 온체인 (whale wallet, exchange flow) | 높음 | 중 — 지연 있으나 독점 정보 가능성 |
| **데이터 소스 변경** | 크로스 거래소 (OKX/Bybit OI/FR 차이) | 중 | 중 — 차이 자체가 시그널 |
| **데이터 소스 변경** | 소셜/뉴스 센티먼트 | 높음 | 낮음 — 노이즈 많음, 지연 |
| **자산 변경** | BTC/ETH (유동성 높은 메이저) | 낮음 | 중 — 경쟁 치열하지만 유동성 풍부 |
| **자산 변경** | 저유동성 알트 (DYDX, APE 등) | 낮음 | 중 — 비효율성 클 수 있으나 슬리피지 |
| **패러다임 변경** | 통계적 차익 (pair trading, mean reversion) | 중 | 중상 — 방향 중립, beta 제거 |
| **패러다임 변경** | 마이크로 구조 (호가창 불균형, 체결 패턴) | 높음 | 높음 — 진입 장벽이 alpha 보호 |
| **패러다임 변경** | 변동성 전략 (vol expansion/compression) | 중 | 중 — 방향 아닌 "크기" 예측 |
| **실행 최적화** | 동일 시그널 + maker order (비용 절감) | 중 | 중 — 0.08%→0.04% 비용 반감 |
## 5. 다음 방향 제안
### 우선순위 1: 패러다임을 바꿔라
지금까지의 모든 전략은 **"방향 예측"** 패러다임:
- "XRP가 오를까 내릴까?" → LONG/SHORT
이 패러다임에서 공개 시그널로 alpha를 찾기는 극히 어렵다. 대안:
**(A) 통계적 차익 (Stat Arb / Pair Trading)**
- XRP/BTC 비율의 평균 회귀
- 여러 알트코인 간 상대 강도 기반 롱숏
- 장점: 방향 중립 → beta 제거, 대칭성 자동 충족
- 인프라: 현재 multi-symbol 아키텍처로 확장 가능
**(B) 변동성 전략**
- "방향"이 아니라 "크기"를 예측
- ATR 수축 → 확장 시 양방향 진입 (straddle-like)
- BB squeeze → breakout 방향은 예측하되, 비용 대비 마진이 큰 구간만
### 우선순위 2: 비용을 줄여라
현재 taker 왕복 8bps가 모든 전략의 적. 같은 시그널이라도:
- Entry를 limit order로 → 4bps 절감 (왕복 4bps)
- 이것만으로 MTF 베이스라인 OOS가 0.94 → ~1.02로 개선 가능
- 근본 해결은 아니지만, 마진이 얇은 전략을 BEP 위로 올릴 수 있음
### 우선순위 3: 데이터 소스를 넓혀라
Binance 공개 API는 소진. 외부 데이터가 필요:
- Glassnode/CryptoQuant (온체인)
- 크로스 거래소 OI/FR 차이
- 이 데이터는 접근 비용(유료 API)이 있으나 그만큼 경쟁자가 적음
## 6. 보존할 자산
7전 7패였지만 **인프라는 자산**:
| 자산 | 용도 |
|------|------|
| 비용 모델 (3 시나리오) | 어떤 전략이든 재사용 |
| IS/OOS 검증 프레임워크 | 자기기만 방지 |
| 사전 가설 확정 프로토콜 | sweep 과적합 방지 |
| multi-symbol 아키텍처 | pair trading 확장 가능 |
| kill switch (Fast/Slow) | 실전 리스크 관리 |
| 주간 리포트 파이프라인 | 모니터링 |
다음 전략이 뭐든, 이 검증 인프라 위에서 시작하면 실패를 빠르게 확인할 수 있다.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,601 @@
"""
MTF Pullback + BTC 추세 필터 백테스트
──────────────────────────────────────
기존 MTF Pullback 전략에 BTC 추세 필터를 추가하여 검증.
메인 가설 (사전 확정):
BTC 1h + EMA 50/200 + ADX > 20
sweep 결과와 무관하게 사후 변경하지 않음.
판정 흐름:
1. 베이스라인(필터 없음) IS/OOS 결과 산출
2. 12개 sweep IS, 메인 가설 OOS 검증
3. 나머지 11개 OOS robustness 체크
Usage:
python scripts/mtf_btc_filter_backtest.py
python scripts/mtf_btc_filter_backtest.py --symbol xrpusdt
"""
import sys
from pathlib import Path
from dataclasses import dataclass, field
from itertools import product
import pandas as pd
import pandas_ta as ta
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.config import COST_MODEL, COST_SCENARIOS # noqa: E402
# ─── 설정 ──────────────────────────────────────────────────────────
SYMBOL = "xrpusdt"
DATA_PATH = Path(f"data/{SYMBOL}/combined_15m.parquet")
# XRP 1h 메타필터 (기존 MTF bot 설정 그대로)
MTF_EMA_FAST = 50
MTF_EMA_SLOW = 200
MTF_ADX_THRESHOLD = 20
# 15m Trigger
EMA_PULLBACK_LEN = 20
VOL_DRY_RATIO = 0.5
# SL/TP
ATR_SL_MULT = 1.5
ATR_TP_MULT = 2.3
# IS/OOS 분할
IS_RATIO = 0.7
# ─── Sweep 그리드 ─────────────────────────────────────────────────
SWEEP_GRID = {
"btc_tf": ["1h", "4h", "1D"],
"btc_ema_fast": [20, 50],
"btc_ema_slow": [100, 200],
}
# BTC ADX 임계값 — 전 조합 고정
BTC_ADX_THRESHOLD = 20
# 메인 가설 (사전 확정 — commitment device)
MAIN_HYPOTHESIS = {"btc_tf": "1h", "btc_ema_fast": 50, "btc_ema_slow": 200}
# IS 거래 수 최소 기준
MIN_IS_TRADES = 100
@dataclass
class Trade:
entry_time: pd.Timestamp
entry_price: float
side: str
sl: float
tp: float
btc_trend: str = ""
exit_time: pd.Timestamp | None = None
exit_price: float | None = None
pnl_bps: float | None = None
reason: str = ""
def build_xrp_1h(df_15m: pd.DataFrame) -> pd.DataFrame:
"""XRP 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 build_btc_resampled(df_15m: pd.DataFrame, tf: str, ema_fast: int, ema_slow: int) -> pd.DataFrame:
"""BTC 15m → 지정 타임프레임: EMA + ADX."""
btc_cols = {"open_btc": "open", "high_btc": "high", "low_btc": "low",
"close_btc": "close", "volume_btc": "volume"}
df_btc = df_15m[list(btc_cols.keys())].rename(columns=btc_cols)
df_rs = df_btc.resample(tf).agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
).dropna()
df_rs[f"btc_ema_fast"] = ta.ema(df_rs["close"], length=ema_fast)
df_rs[f"btc_ema_slow"] = ta.ema(df_rs["close"], length=ema_slow)
adx_df = ta.adx(df_rs["high"], df_rs["low"], df_rs["close"], length=14)
df_rs["btc_adx"] = adx_df["ADX_14"]
return df_rs[["btc_ema_fast", "btc_ema_slow", "btc_adx"]]
def merge_higher_tf(df_15m: pd.DataFrame, df_htf: pd.DataFrame, tf: str) -> pd.DataFrame:
"""Look-ahead bias 방지 merge. 1h → +1h shift, 4h → +4h shift, 1d → +1d shift."""
shift_map = {"1h": pd.Timedelta(hours=1), "4h": pd.Timedelta(hours=4),
"1D": pd.Timedelta(days=1)}
df_shifted = df_htf.copy()
df_shifted.index = df_shifted.index + shift_map[tf]
df_15m_r = df_15m.reset_index()
df_htf_r = df_shifted.reset_index()
ts_col_15m = df_15m_r.columns[0]
ts_col_htf = df_htf_r.columns[0]
df_15m_r.rename(columns={ts_col_15m: "timestamp"}, inplace=True)
df_htf_r.rename(columns={ts_col_htf: "timestamp"}, inplace=True)
df_15m_r["timestamp"] = pd.to_datetime(df_15m_r["timestamp"]).astype("datetime64[us]")
df_htf_r["timestamp"] = pd.to_datetime(df_htf_r["timestamp"]).astype("datetime64[us]")
merged = pd.merge_asof(
df_15m_r.sort_values("timestamp"),
df_htf_r.sort_values("timestamp"),
on="timestamp",
direction="backward",
)
return merged.set_index("timestamp")
def get_xrp_meta(row) -> str:
"""XRP 1h 메타필터."""
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"
return "LONG" if ema50 > ema200 else "SHORT"
def get_btc_trend(row) -> str:
"""BTC 추세 필터."""
ema_f = row.get("btc_ema_fast")
ema_s = row.get("btc_ema_slow")
adx = row.get("btc_adx")
if pd.isna(ema_f) or pd.isna(ema_s) or pd.isna(adx):
return "NEUTRAL"
if adx < BTC_ADX_THRESHOLD:
return "NEUTRAL"
return "UP" if ema_f > ema_s else "DOWN"
def run_backtest(df: pd.DataFrame, use_btc_filter: bool) -> list[Trade]:
"""MTF Pullback 백테스트 실행."""
trades: list[Trade] = []
in_trade = False
current_trade: Trade | None = None
pullback_ready = False
pullback_side = ""
for i in range(1, len(df)):
row = df.iloc[i]
# ── SL/TP 체크 ──
if in_trade and current_trade is not None:
hit_sl = hit_tp = False
if current_trade.side == "LONG":
hit_sl = row["low"] <= current_trade.sl
hit_tp = row["high"] >= current_trade.tp
else:
hit_sl = row["high"] >= current_trade.sl
hit_tp = row["low"] <= current_trade.tp
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_bps = raw_pnl * 10000 # raw bps (비용 미반영)
current_trade.reason = "SL" if hit_sl else "TP"
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
# ── XRP 1h Meta ──
meta = get_xrp_meta(row)
if meta == "HOLD":
pullback_ready = False
continue
# ── BTC 추세 필터 ──
btc_trend = get_btc_trend(row) if use_btc_filter else "DISABLED"
if use_btc_filter:
# BTC UP → LONG만, BTC DOWN → SHORT만, NEUTRAL → 차단
if btc_trend == "UP" and meta != "LONG":
pullback_ready = False
continue
elif btc_trend == "DOWN" and meta != "SHORT":
pullback_ready = False
continue
elif btc_trend == "NEUTRAL":
pullback_ready = False
continue
# ── Pullback 감지 → 재개 확인 ──
if pullback_ready and pullback_side == meta:
if pullback_side == "LONG" and row["close"] > row["ema20"]:
if i + 1 < len(df):
next_row = df.iloc[i + 1]
entry_price = next_row["open"]
atr = row["atr_1h"]
current_trade = Trade(
entry_time=df.index[i + 1], entry_price=entry_price,
side="LONG", sl=entry_price - atr * ATR_SL_MULT,
tp=entry_price + atr * ATR_TP_MULT, btc_trend=btc_trend,
)
in_trade = True
pullback_ready = False
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 = row["atr_1h"]
current_trade = Trade(
entry_time=df.index[i + 1], entry_price=entry_price,
side="SHORT", sl=entry_price + atr * ATR_SL_MULT,
tp=entry_price - atr * ATR_TP_MULT, btc_trend=btc_trend,
)
in_trade = True
pullback_ready = False
continue
# ── Pullback 감지 ──
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"
elif meta == "SHORT" and row["close"] > row["ema20"] and vol_dry:
pullback_ready = True
pullback_side = "SHORT"
elif meta != pullback_side:
pullback_ready = False
return trades
def apply_cost(trades: list[Trade], scenario_name: str) -> list[float]:
"""거래 리스트에 비용 시나리오 적용, adjusted pnl_bps 리스트 반환."""
scenario = COST_SCENARIOS[scenario_name]
fee_per_side = COST_MODEL["taker_fee_bps"] # 현재 전부 taker
fee_roundtrip = fee_per_side * 2
slippage_roundtrip = scenario["slippage_bps_per_side"] * 2
adjusted = []
for t in trades:
# 펀딩비: 보유 시간 중 8h 경계 교차 수
if t.entry_time is not None and t.exit_time is not None:
dur_h = (t.exit_time - t.entry_time).total_seconds() / 3600
funding_events = max(0, int(dur_h / 8))
else:
funding_events = 0
funding_cost = funding_events * scenario["funding_bps_per_8h"]
total_cost = fee_roundtrip + slippage_roundtrip + funding_cost
adjusted.append(t.pnl_bps - total_cost)
return adjusted
def calc_metrics(pnl_list: list[float]) -> dict:
"""pnl_bps 리스트로 메트릭 계산."""
if not pnl_list:
return {"trades": 0, "win_rate": 0.0, "pf": 0.0, "cum_pnl": 0.0, "avg_pnl": 0.0}
wins = [p for p in pnl_list if p > 0]
losses = [p for p in pnl_list 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")
return {
"trades": len(pnl_list),
"win_rate": round(len(wins) / len(pnl_list) * 100, 1),
"pf": round(pf, 2),
"cum_pnl": round(sum(pnl_list), 1),
"avg_pnl": round(sum(pnl_list) / len(pnl_list), 2),
}
def split_is_oos(trades: list[Trade], split_ts: pd.Timestamp):
"""IS/OOS 분할."""
is_trades = [t for t in trades if t.entry_time < split_ts]
oos_trades = [t for t in trades if t.entry_time >= split_ts]
return is_trades, oos_trades
def print_metrics_row(label: str, raw: dict, fees: dict, realistic: dict):
"""한 줄 메트릭 출력."""
print(f" {label:<8} {raw['trades']:>5} {raw['win_rate']:>5.1f}% "
f"{raw['pf']:>5.2f} {fees['pf']:>5.2f} {realistic['pf']:>5.2f} "
f"{raw['cum_pnl']:>+8.1f} {fees['cum_pnl']:>+8.1f}")
def print_section(title: str, trades: list[Trade]):
"""섹션별 메트릭 출력."""
if not trades:
print(f"\n [{title}] 거래 없음")
return
raw_all = [t.pnl_bps for t in trades]
fees_all = apply_cost(trades, "fees_only")
real_all = apply_cost(trades, "realistic")
long_t = [t for t in trades if t.side == "LONG"]
short_t = [t for t in trades if t.side == "SHORT"]
raw_l = [t.pnl_bps for t in long_t]
raw_s = [t.pnl_bps for t in short_t]
fees_l = apply_cost(long_t, "fees_only")
fees_s = apply_cost(short_t, "fees_only")
real_l = apply_cost(long_t, "realistic")
real_s = apply_cost(short_t, "realistic")
print(f"\n [{title}]")
print(f" {'':8} {'N':>5} {'WR':>6} {'RawPF':>5} {'FeePF':>5} {'RealPF':>5} {'RawPnL':>8} {'FeePnL':>8}")
print(f" {'-'*62}")
print_metrics_row("Total", calc_metrics(raw_all), calc_metrics(fees_all), calc_metrics(real_all))
print_metrics_row("LONG", calc_metrics(raw_l), calc_metrics(fees_l), calc_metrics(real_l))
print_metrics_row("SHORT", calc_metrics(raw_s), calc_metrics(fees_s), calc_metrics(real_s))
def save_trade_log(trades: list[Trade], filepath: Path, combo_label: str):
"""거래 수준 CSV 로그 저장."""
rows = []
for t in trades:
rows.append({
"combo": combo_label,
"entry_time": t.entry_time,
"exit_time": t.exit_time,
"side": t.side,
"entry_price": t.entry_price,
"exit_price": t.exit_price,
"pnl_bps": t.pnl_bps,
"reason": t.reason,
"btc_trend": t.btc_trend,
})
df = pd.DataFrame(rows)
mode = "a" if filepath.exists() else "w"
header = not filepath.exists()
df.to_csv(filepath, mode=mode, header=header, index=False)
def main():
import argparse
parser = argparse.ArgumentParser(description="MTF + BTC 추세 필터 백테스트")
parser.add_argument("--symbol", default="xrpusdt")
args = parser.parse_args()
data_path = Path(f"data/{args.symbol}/combined_15m.parquet")
print("=" * 72)
print(" MTF Pullback + BTC 추세 필터 백테스트")
print(f" 메인 가설: BTC {MAIN_HYPOTHESIS['btc_tf']} EMA{MAIN_HYPOTHESIS['btc_ema_fast']}/{MAIN_HYPOTHESIS['btc_ema_slow']} ADX>{BTC_ADX_THRESHOLD}")
print("=" * 72)
# ── 데이터 로드 ──
df_raw = pd.read_parquet(data_path)
if df_raw.index.tz is not None:
df_raw.index = df_raw.index.tz_localize(None)
# EMA200 워밍업 (200h × 4 + 여유 = 1000 bars)
warmup_bars = 1000
df_full = df_raw.iloc[warmup_bars:].copy() if len(df_raw) > warmup_bars else df_raw.copy()
# 워밍업 포함 전체 데이터로 지표 계산
df_calc = df_raw.copy()
print(f"\n데이터: {len(df_raw)} bars total, 분석: {len(df_full)} bars")
print(f"기간: {df_full.index[0]} ~ {df_full.index[-1]}")
print(f"일수: {(df_full.index[-1] - df_full.index[0]).days}")
# ── IS/OOS 분할 ──
split_idx = int(len(df_full) * IS_RATIO)
split_ts = df_full.index[split_idx]
print(f"IS/OOS 분할: IS ~{split_ts.date()} | OOS {split_ts.date()}~")
# ── XRP 15m 지표 ──
df_calc["ema20"] = ta.ema(df_calc["close"], length=EMA_PULLBACK_LEN)
df_calc["vol_ma20"] = ta.sma(df_calc["volume"], length=20)
# ── XRP 1h 지표 ──
df_1h = build_xrp_1h(df_calc)
df_merged_base = merge_higher_tf(df_calc, df_1h, "1h")
# 분석 기간만 슬라이스
df_analysis = df_merged_base[df_merged_base.index >= df_full.index[0]].copy()
# ── 1. 베이스라인 (BTC 필터 없음) ──
print("\n" + "=" * 72)
print(" BASELINE (BTC 필터 없음)")
print("=" * 72)
baseline_trades = run_backtest(df_analysis, use_btc_filter=False)
baseline_is, baseline_oos = split_is_oos(baseline_trades, split_ts)
print_section("IS (베이스라인)", baseline_is)
print_section("OOS (베이스라인)", baseline_oos)
# ── 2. Sweep ──
print("\n" + "=" * 72)
print(" SWEEP (12개 조합)")
print("=" * 72)
combos = list(product(
SWEEP_GRID["btc_tf"],
SWEEP_GRID["btc_ema_fast"],
SWEEP_GRID["btc_ema_slow"],
))
trade_log_path = Path(f"results/{args.symbol}/mtf_btc_filter_trades.csv")
trade_log_path.parent.mkdir(parents=True, exist_ok=True)
if trade_log_path.exists():
trade_log_path.unlink()
results = []
for btc_tf, ema_f, ema_s in combos:
if ema_f >= ema_s:
continue # fast >= slow는 무의미
label = f"BTC_{btc_tf}_EMA{ema_f}/{ema_s}"
is_main = (btc_tf == MAIN_HYPOTHESIS["btc_tf"] and
ema_f == MAIN_HYPOTHESIS["btc_ema_fast"] and
ema_s == MAIN_HYPOTHESIS["btc_ema_slow"])
# BTC 지표 계산 + merge
df_btc = build_btc_resampled(df_calc, btc_tf, ema_f, ema_s)
df_with_btc = merge_higher_tf(df_analysis, df_btc, btc_tf)
# 백테스트
trades = run_backtest(df_with_btc, use_btc_filter=True)
is_trades, oos_trades = split_is_oos(trades, split_ts)
# IS 거래 수 체크
if len(is_trades) < MIN_IS_TRADES:
status = "SKIP(IS<100)"
else:
status = "MAIN" if is_main else "sweep"
# 메트릭
is_raw = calc_metrics([t.pnl_bps for t in is_trades])
is_fees = calc_metrics(apply_cost(is_trades, "fees_only"))
oos_raw = calc_metrics([t.pnl_bps for t in oos_trades])
oos_fees = calc_metrics(apply_cost(oos_trades, "fees_only"))
oos_real = calc_metrics(apply_cost(oos_trades, "realistic"))
# LONG/SHORT 분리 (OOS)
oos_long = [t for t in oos_trades if t.side == "LONG"]
oos_short = [t for t in oos_trades if t.side == "SHORT"]
oos_fees_l = calc_metrics(apply_cost(oos_long, "fees_only"))
oos_fees_s = calc_metrics(apply_cost(oos_short, "fees_only"))
results.append({
"label": label, "is_main": is_main, "status": status,
"is_trades": is_raw["trades"], "is_raw_pf": is_raw["pf"],
"is_fees_pf": is_fees["pf"],
"oos_trades": oos_raw["trades"], "oos_raw_pf": oos_raw["pf"],
"oos_fees_pf": oos_fees["pf"], "oos_real_pf": oos_real["pf"],
"oos_fees_pnl": oos_fees["cum_pnl"],
"oos_long_fees_pf": oos_fees_l["pf"], "oos_short_fees_pf": oos_fees_s["pf"],
"oos_long_n": oos_fees_l["trades"], "oos_short_n": oos_fees_s["trades"],
})
# 거래 로그 저장
save_trade_log(trades, trade_log_path, label)
# ── Sweep 결과 테이블 ──
print(f"\n {'Label':<22} {'St':>6} {'IS_N':>5} {'IS_FPF':>6} "
f"{'OOS_N':>5} {'OOS_RPF':>7} {'OOS_FPF':>7} {'OOS_rPF':>7} "
f"{'L_FPF':>6} {'S_FPF':>6}")
print(f" {'-'*92}")
for r in results:
marker = "" if r["is_main"] else ""
print(f" {r['label']:<22} {r['status']:>6} {r['is_trades']:>5} {r['is_fees_pf']:>6.2f} "
f"{r['oos_trades']:>5} {r['oos_raw_pf']:>7.2f} {r['oos_fees_pf']:>7.2f} {r['oos_real_pf']:>7.2f} "
f"{r['oos_long_fees_pf']:>6.2f} {r['oos_short_fees_pf']:>6.2f}{marker}")
# ── 3. 메인 가설 상세 결과 ──
main_result = next((r for r in results if r["is_main"]), None)
if main_result is None:
print("\n [ERROR] 메인 가설 결과 없음")
return
print("\n" + "=" * 72)
print(f" 메인 가설 상세: {main_result['label']}")
print("=" * 72)
# 메인 가설 재실행하여 상세 출력
df_btc_main = build_btc_resampled(
df_calc, MAIN_HYPOTHESIS["btc_tf"],
MAIN_HYPOTHESIS["btc_ema_fast"], MAIN_HYPOTHESIS["btc_ema_slow"])
df_main = merge_higher_tf(df_analysis, df_btc_main, MAIN_HYPOTHESIS["btc_tf"])
main_trades = run_backtest(df_main, use_btc_filter=True)
main_is, main_oos = split_is_oos(main_trades, split_ts)
print_section("IS (메인 가설)", main_is)
print_section("OOS (메인 가설)", main_oos)
# ── 4. 판정 ──
print("\n" + "=" * 72)
print(" 판정")
print("=" * 72)
# 베이스라인 비교
bl_oos_fees = calc_metrics(apply_cost(baseline_oos, "fees_only"))
main_oos_fees = calc_metrics(apply_cost(main_oos, "fees_only"))
main_oos_real = calc_metrics(apply_cost(main_oos, "realistic"))
print(f"\n 베이스라인 OOS fees_only PF: {bl_oos_fees['pf']:.2f} ({bl_oos_fees['trades']}건)")
print(f" 메인 가설 OOS fees_only PF: {main_oos_fees['pf']:.2f} ({main_oos_fees['trades']}건)")
print(f" 메인 가설 OOS realistic PF: {main_oos_real['pf']:.2f}")
print(f" 개선폭: fees_only PF {main_oos_fees['pf'] - bl_oos_fees['pf']:+.2f}")
# 합격 기준 체크
checks = []
checks.append(("OOS fees_only PF >= 1.2", main_oos_fees["pf"] >= 1.2, f"{main_oos_fees['pf']:.2f}"))
checks.append(("OOS realistic PF >= 1.0", main_oos_real["pf"] >= 1.0, f"{main_oos_real['pf']:.2f}"))
checks.append(("OOS 거래수 >= 50", main_oos_fees["trades"] >= 50, f"{main_oos_fees['trades']}"))
# LONG/SHORT 대칭성
oos_long_t = [t for t in main_oos if t.side == "LONG"]
oos_short_t = [t for t in main_oos if t.side == "SHORT"]
l_pf = calc_metrics(apply_cost(oos_long_t, "fees_only"))["pf"]
s_pf = calc_metrics(apply_cost(oos_short_t, "fees_only"))["pf"]
checks.append(("LONG/SHORT fees PF >= 0.8", l_pf >= 0.8 and s_pf >= 0.8, f"L:{l_pf:.2f} S:{s_pf:.2f}"))
# IS/OOS 격차
main_is_fees = calc_metrics(apply_cost(main_is, "fees_only"))
if main_is_fees["pf"] > 0:
gap = abs(main_oos_fees["pf"] - main_is_fees["pf"]) / main_is_fees["pf"]
else:
gap = 1.0
checks.append(("IS/OOS PF 격차 < 30%", gap < 0.3, f"{gap*100:.1f}%"))
# 베이스라인 대비 개선
improvement = main_oos_fees["pf"] > bl_oos_fees["pf"]
checks.append(("베이스라인 대비 개선", improvement,
f"{main_oos_fees['pf']:.2f} vs {bl_oos_fees['pf']:.2f}"))
print(f"\n 합격 기준 체크:")
all_pass = True
for desc, passed, val in checks:
icon = "PASS" if passed else "FAIL"
print(f" [{icon}] {desc}: {val}")
if not passed:
all_pass = False
print()
if all_pass:
print(" ★ [최종 판정: PASS] BTC 추세 필터가 유효합니다.")
else:
print(" ✗ [최종 판정: FAIL] BTC 추세 필터로도 기준 미달. MTF 전략 폐기.")
# robustness 요약
passing_combos = [r for r in results if r["status"] != "SKIP(IS<100)" and r["oos_fees_pf"] >= 1.2]
print(f"\n Robustness: {len(passing_combos)}/{len(results)} 조합이 OOS fees_only PF >= 1.2")
print(f"\n 거래 로그: {trade_log_path}")
print()
if __name__ == "__main__":
main()