Compare commits
2 Commits
52d05f2ddd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09ae926f06 | ||
|
|
f53b8a5a0f |
@@ -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패 분석 — 공개 시그널 방향 예측 패러다임 한계, 다음 방향 제안 |
|
||||||
|
|||||||
79
docs/plans/2026-05-04-mtf-btc-filter-design.md
Normal file
79
docs/plans/2026-05-04-mtf-btc-filter-design.md
Normal 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 포함, 사후 분석용
|
||||||
80
docs/plans/2026-05-04-mtf-btc-filter-result.md
Normal file
80
docs/plans/2026-05-04-mtf-btc-filter-result.md
Normal 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 부재 확인.
|
||||||
133
docs/plans/2026-05-04-strategy-post-mortem.md
Normal file
133
docs/plans/2026-05-04-strategy-post-mortem.md
Normal 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) | 실전 리스크 관리 |
|
||||||
|
| 주간 리포트 파이프라인 | 모니터링 |
|
||||||
|
|
||||||
|
다음 전략이 뭐든, 이 검증 인프라 위에서 시작하면 실패를 빠르게 확인할 수 있다.
|
||||||
4406
results/xrpusdt/mtf_btc_filter_trades.csv
Normal file
4406
results/xrpusdt/mtf_btc_filter_trades.csv
Normal file
File diff suppressed because it is too large
Load Diff
601
scripts/mtf_btc_filter_backtest.py
Normal file
601
scripts/mtf_btc_filter_backtest.py
Normal 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()
|
||||||
Reference in New Issue
Block a user