Compare commits
93 Commits
67692b3ebd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6ba45f8de | ||
|
|
295ed7db76 | ||
|
|
ec7a6e427c | ||
|
|
f488720ca2 | ||
|
|
930d4d2c7a | ||
|
|
e31c4bf080 | ||
|
|
b8a371992f | ||
|
|
1cfb1b322a | ||
|
|
75b5c5d7fe | ||
|
|
af865c3db2 | ||
|
|
c94c605f3e | ||
|
|
a0990c5fd5 | ||
|
|
82f4977dff | ||
|
|
4c40516559 | ||
|
|
17742da6af | ||
|
|
ff2566dfef | ||
|
|
0ddd1f6764 | ||
|
|
1135efc5be | ||
|
|
bd152a84e1 | ||
|
|
e2b0454825 | ||
|
|
aa5c0afce6 | ||
|
|
4fef073b0a | ||
|
|
dacefaa1ed | ||
|
|
d8f5d4f1fb | ||
|
|
b5a5510499 | ||
|
|
c29d3e0569 | ||
|
|
30ddb2fef4 | ||
|
|
6830549fd6 | ||
|
|
fe99885faa | ||
|
|
4533118aab | ||
|
|
c0da46c60a | ||
|
|
5bad7dd691 | ||
|
|
a34fc6f996 | ||
|
|
24f0faa540 | ||
|
|
0fe87bb366 | ||
|
|
0cc5835b3a | ||
|
|
75d1af7fcc | ||
|
|
41b0aa3f28 | ||
|
|
e3623293f7 | ||
|
|
13c2b95c8e | ||
|
|
9f0057e29d | ||
|
|
f14c521302 | ||
|
|
e648ae7ca0 | ||
|
|
e3a78974b3 | ||
|
|
181f82d3c0 | ||
|
|
24ed7ddec0 | ||
|
|
b86aa8b072 | ||
|
|
42e53b9ae4 | ||
|
|
4930140b19 | ||
|
|
f890009a92 | ||
|
|
5b3f6af13c | ||
|
|
9d9f4960fc | ||
|
|
8c1cd0422f | ||
|
|
4792b0f9cf | ||
|
|
652990082d | ||
|
|
5e3a207af4 | ||
|
|
ab032691d4 | ||
|
|
55c20012a3 | ||
|
|
106eaf182b | ||
|
|
64f56806d2 | ||
|
|
8803c71bf9 | ||
|
|
b188607d58 | ||
|
|
9644cf4ff0 | ||
|
|
805f1b0528 | ||
|
|
363234ac7c | ||
|
|
de27f85e6d | ||
|
|
cdde1795db | ||
|
|
d03012bb04 | ||
|
|
af91b36467 | ||
|
|
c6c60b274c | ||
|
|
97aef14d6c | ||
|
|
afdbacaabd | ||
|
|
9b76313500 | ||
|
|
60510c026b | ||
|
|
0a8748913e | ||
|
|
c577019793 | ||
|
|
2a767c35d4 | ||
|
|
6a6740d708 | ||
|
|
f47ad26156 | ||
|
|
1b1542d51f | ||
|
|
90d99a1662 | ||
|
|
58596785aa | ||
|
|
3b0335f57e | ||
|
|
35177bf345 | ||
|
|
9011344aab | ||
|
|
2e788c0d0f | ||
|
|
771b357f28 | ||
|
|
d8d4bf3e20 | ||
|
|
072910df39 | ||
|
|
89f44c96af | ||
|
|
dbc900d478 | ||
|
|
90a72e4c39 | ||
|
|
cd9d379bc2 |
11
.env.example
11
.env.example
@@ -2,15 +2,22 @@ BINANCE_API_KEY=
|
|||||||
BINANCE_API_SECRET=
|
BINANCE_API_SECRET=
|
||||||
SYMBOLS=XRPUSDT
|
SYMBOLS=XRPUSDT
|
||||||
CORRELATION_SYMBOLS=BTCUSDT,ETHUSDT
|
CORRELATION_SYMBOLS=BTCUSDT,ETHUSDT
|
||||||
LEVERAGE=10
|
LEVERAGE=20
|
||||||
RISK_PER_TRADE=0.02
|
|
||||||
DISCORD_WEBHOOK_URL=
|
DISCORD_WEBHOOK_URL=
|
||||||
ML_THRESHOLD=0.55
|
ML_THRESHOLD=0.55
|
||||||
|
NO_ML_FILTER=true
|
||||||
MAX_SAME_DIRECTION=2
|
MAX_SAME_DIRECTION=2
|
||||||
|
# Global defaults (fallback when no per-symbol override)
|
||||||
ATR_SL_MULT=2.0
|
ATR_SL_MULT=2.0
|
||||||
ATR_TP_MULT=2.0
|
ATR_TP_MULT=2.0
|
||||||
SIGNAL_THRESHOLD=3
|
SIGNAL_THRESHOLD=3
|
||||||
ADX_THRESHOLD=25
|
ADX_THRESHOLD=25
|
||||||
VOL_MULTIPLIER=2.5
|
VOL_MULTIPLIER=2.5
|
||||||
|
|
||||||
|
# Per-symbol strategy params (2026-03-21 운영 설정)
|
||||||
|
ATR_SL_MULT_XRPUSDT=1.5
|
||||||
|
ATR_TP_MULT_XRPUSDT=4.0
|
||||||
|
ADX_THRESHOLD_XRPUSDT=25
|
||||||
|
DASHBOARD_API_URL=http://10.1.10.24:8000
|
||||||
BINANCE_TESTNET_API_KEY=
|
BINANCE_TESTNET_API_KEY=
|
||||||
BINANCE_TESTNET_API_SECRET=
|
BINANCE_TESTNET_API_SECRET=
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -16,3 +16,5 @@ data/*.parquet
|
|||||||
|
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.venv
|
.venv
|
||||||
|
dashboard/ui/node_modules/
|
||||||
|
dashboard/ui/dist/
|
||||||
597
ARCHITECTURE.md
597
ARCHITECTURE.md
@@ -1,40 +1,72 @@
|
|||||||
# CoinTrader — 아키텍처 문서
|
# CoinTrader — 아키텍처 문서
|
||||||
|
|
||||||
> 이 문서는 CoinTrader 코드베이스를 처음 접하는 개발자와 트레이딩 배경 독자 모두를 위해 작성되었습니다.
|
> 이 문서는 CoinTrader의 내부 구조를 설명합니다.
|
||||||
> 기술 스택, 레이어별 역할, MLOps 파이프라인, 핵심 동작 시나리오를 순서대로 설명합니다.
|
> **봇 사용법**은 [README.md](README.md)를 참고하세요.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 목차
|
## 목차
|
||||||
|
|
||||||
1. [시스템 오버뷰](#1-시스템-오버뷰)
|
1. [시스템 개요](#1-시스템-개요) — 봇이 무엇을 하는지, 어떤 구조인지
|
||||||
2. [코어 레이어 아키텍처](#2-코어-레이어-아키텍처)
|
2. [매매 판단 과정](#2-매매-판단-과정) — 15분마다 어떤 과정을 거쳐 매매하는지
|
||||||
3. [MLOps 파이프라인 — 자가 진화 시스템](#3-mlops-파이프라인--자가-진화-시스템)
|
3. [5개 레이어 상세](#3-5개-레이어-상세) — 각 레이어의 역할과 동작 원리
|
||||||
4. [핵심 동작 시나리오](#4-핵심-동작-시나리오)
|
4. [MLOps 파이프라인](#4-mlops-파이프라인) — ML 모델의 학습·배포·모니터링 전체 흐름
|
||||||
5. [테스트 커버리지](#5-테스트-커버리지)
|
5. [핵심 동작 시나리오](#5-핵심-동작-시나리오) — 실제 상황별 봇의 동작 흐름도
|
||||||
|
5-1. [MTF Pullback Bot](#5-1-mtf-pullback-bot) — 멀티타임프레임 풀백 전략 Dry-run 봇
|
||||||
|
6. [테스트 커버리지](#6-테스트-커버리지) — 무엇을 어떻게 테스트하는지
|
||||||
|
7. [파일 구조](#7-파일-구조) — 전체 파일 역할 요약
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 시스템 오버뷰
|
## 1. 시스템 개요
|
||||||
|
|
||||||
CoinTrader는 **Binance Futures 자동매매 봇**입니다. 기술 지표 신호를 1차 필터로, LightGBM(또는 MLX 신경망) 모델을 2차 필터로 사용하여 다중 심볼(XRP, TRX, DOGE 등) 선물 포지션을 동시에 자동 진입·청산합니다.
|
CoinTrader는 **Binance Futures 자동매매 봇**입니다.
|
||||||
|
|
||||||
### 멀티심볼 아키텍처
|
**한 줄 요약**: 15분마다 기술 지표로 매매 신호를 생성하고, ML 모델로 한 번 더 검증한 뒤, 조건을 충족하면 자동으로 주문을 넣습니다.
|
||||||
|
|
||||||
|
### 1.1 전체 흐름 (간략)
|
||||||
|
|
||||||
|
```
|
||||||
|
15분봉 마감 → 기술 지표 계산 → 매매 신호 생성 → ML 필터 검증 → 리스크 체크 → 주문 실행 → Discord 알림
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 멀티심볼 아키텍처
|
||||||
|
|
||||||
|
여러 심볼을 동시에 거래합니다. 각 심볼은 독립된 봇 인스턴스로 실행되며, 리스크 관리만 공유합니다.
|
||||||
|
|
||||||
```
|
```
|
||||||
main.py
|
main.py
|
||||||
└─ Config (SYMBOLS=XRPUSDT,TRXUSDT,DOGEUSDT)
|
└─ Config (SYMBOLS=XRPUSDT) # 멀티심볼 지원, 현재 XRP만 운영
|
||||||
└─ RiskManager (공유 싱글턴, asyncio.Lock)
|
└─ RiskManager (공유 싱글턴, asyncio.Lock)
|
||||||
└─ asyncio.gather(
|
└─ asyncio.gather(
|
||||||
TradingBot(symbol="XRPUSDT", risk=shared_risk),
|
TradingBot(symbol="XRPUSDT", risk=shared_risk),
|
||||||
TradingBot(symbol="TRXUSDT", risk=shared_risk),
|
|
||||||
TradingBot(symbol="DOGEUSDT", risk=shared_risk),
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
각 봇은 독립적인 `Exchange`, `MLFilter`, `DataStream`을 소유합니다. `RiskManager`만 공유 싱글턴으로 글로벌 리스크(일일 손실 한도, 동일 방향 제한, 최대 포지션 수)를 관리합니다.
|
> **운영 이력**: SOL/DOGE/TRX는 파라미터 스윕에서 모든 조합에서 PF < 1.0으로 제외 (2026-03-21).
|
||||||
|
|
||||||
### 전체 데이터 파이프라인 흐름도
|
- **독립**: 각 봇은 자체 `Exchange`, `MLFilter`, `DataStream`, `SymbolStrategyParams`를 소유
|
||||||
|
- **공유**: `RiskManager`만 싱글턴으로 글로벌 리스크(일일 손실 한도, 동일 방향 제한) 관리
|
||||||
|
- **병렬**: `asyncio.gather()`로 동시 실행, 서로 간섭 없음
|
||||||
|
- **심볼별 전략**: `config.get_symbol_params(symbol)`로 SL/TP/ADX 등을 심볼별 독립 설정 (`ATR_SL_MULT_XRPUSDT` 등 환경변수)
|
||||||
|
|
||||||
|
### 1.3 기술 스택
|
||||||
|
|
||||||
|
| 분류 | 기술 |
|
||||||
|
|------|------|
|
||||||
|
| 언어 | Python 3.11+ |
|
||||||
|
| 비동기 런타임 | `asyncio` + `python-binance` WebSocket |
|
||||||
|
| 기술 지표 | `pandas-ta` (RSI, MACD, BB, EMA, StochRSI, ATR, ADX) |
|
||||||
|
| ML 프레임워크 | `LightGBM` (CPU) / `MLX` (Apple Silicon GPU) |
|
||||||
|
| 모델 서빙 | `onnxruntime` (ONNX 우선) / `joblib` (LightGBM 폴백) |
|
||||||
|
| 하이퍼파라미터 탐색 | `Optuna` (TPE Sampler + MedianPruner) |
|
||||||
|
| 데이터 저장 | `Parquet` (pyarrow) |
|
||||||
|
| 로깅 | `Loguru` |
|
||||||
|
| 알림 | Discord Webhook (`httpx`) |
|
||||||
|
| 컨테이너화 | Docker + Docker Compose |
|
||||||
|
| CI/CD | Jenkins + Gitea Container Registry |
|
||||||
|
|
||||||
|
### 1.4 데이터 파이프라인 전체 흐름도
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
@@ -47,21 +79,21 @@ flowchart TD
|
|||||||
subgraph 실시간봇["실시간 봇 (bot.py — asyncio)"]
|
subgraph 실시간봇["실시간 봇 (bot.py — asyncio)"]
|
||||||
DS["data_stream.py<br/>MultiSymbolStream (심볼별)<br/>캔들 버퍼 (deque 200개)"]
|
DS["data_stream.py<br/>MultiSymbolStream (심볼별)<br/>캔들 버퍼 (deque 200개)"]
|
||||||
IND["indicators.py<br/>기술 지표 계산<br/>RSI·MACD·BB·EMA·StochRSI·ATR·ADX"]
|
IND["indicators.py<br/>기술 지표 계산<br/>RSI·MACD·BB·EMA·StochRSI·ATR·ADX"]
|
||||||
MF["ml_features.py<br/>23개 피처 추출<br/>(XRP 13 + BTC/ETH 8 + OI/FR 2)"]
|
MF["ml_features.py<br/>26개 피처 추출<br/>(XRP 13 + BTC/ETH 8 + OI/FR 2 + OI파생 2 + ADX 1)"]
|
||||||
ML["ml_filter.py<br/>MLFilter<br/>ONNX 우선 / LightGBM 폴백<br/>확률 ≥ 0.60 시 진입 허용"]
|
ML["ml_filter.py<br/>MLFilter<br/>ONNX 우선 / LightGBM 폴백<br/>확률 ≥ 0.55 시 진입 허용"]
|
||||||
RM["risk_manager.py<br/>RiskManager (공유 싱글턴)<br/>일일 손실 5% 한도<br/>동적 증거금 비율<br/>동일 방향 제한"]
|
RM["risk_manager.py<br/>RiskManager (공유 싱글턴)<br/>일일 손실 5% 한도<br/>동적 증거금 비율<br/>동일 방향 제한"]
|
||||||
EX["exchange.py<br/>BinanceFuturesClient<br/>주문·레버리지·잔고 API"]
|
EX["exchange.py<br/>BinanceFuturesClient<br/>주문·레버리지·잔고 API"]
|
||||||
UDS["user_data_stream.py<br/>UserDataStream<br/>TP/SL 즉시 감지"]
|
UDS["user_data_stream.py<br/>UserDataStream<br/>TP/SL 즉시 감지"]
|
||||||
NT["notifier.py<br/>DiscordNotifier<br/>진입·청산·오류 알림"]
|
NT["notifier.py<br/>DiscordNotifier<br/>진입·청산·오류 알림"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph mlops["MLOps 파이프라인 (맥미니 — 수동/크론)"]
|
subgraph mlops["MLOps 파이프라인 (수동/크론)"]
|
||||||
FH["fetch_history.py<br/>과거 캔들 + OI/펀딩비<br/>Parquet Upsert"]
|
FH["fetch_history.py<br/>과거 캔들 + OI/펀딩비<br/>Parquet Upsert"]
|
||||||
DB["dataset_builder.py<br/>벡터화 데이터셋 생성<br/>레이블: ATR SL/TP 6시간 룩어헤드"]
|
DB["dataset_builder.py<br/>벡터화 데이터셋 생성<br/>레이블: ATR SL/TP 6시간 룩어헤드"]
|
||||||
TM["train_model.py<br/>LightGBM 학습<br/>Walk-Forward 5폴드 검증"]
|
TM["train_model.py<br/>LightGBM 학습<br/>Walk-Forward 5폴드 검증"]
|
||||||
TN["tune_hyperparams.py<br/>Optuna 50 trials<br/>TPE + MedianPruner"]
|
TN["tune_hyperparams.py<br/>Optuna 50 trials<br/>TPE + MedianPruner"]
|
||||||
AP["active_lgbm_params.json<br/>Active Config 패턴<br/>승인된 파라미터 저장"]
|
AP["active_lgbm_params.json<br/>Active Config 패턴<br/>승인된 파라미터 저장"]
|
||||||
DM["deploy_model.sh<br/>rsync → LXC 서버<br/>봇 핫리로드 트리거"]
|
DM["deploy_model.sh<br/>rsync → 운영 서버<br/>봇 핫리로드 트리거"]
|
||||||
end
|
end
|
||||||
|
|
||||||
WS1 -->|캔들 마감 이벤트| DS
|
WS1 -->|캔들 마감 이벤트| DS
|
||||||
@@ -84,26 +116,59 @@ flowchart TD
|
|||||||
DM -->|모델 파일 전송| ML
|
DM -->|모델 파일 전송| ML
|
||||||
```
|
```
|
||||||
|
|
||||||
### 기술 스택 요약
|
---
|
||||||
|
|
||||||
| 분류 | 기술 |
|
## 2. 매매 판단 과정
|
||||||
|------|------|
|
|
||||||
| 언어 | Python 3.11+ |
|
봇이 매매를 결정하는 과정을 단계별로 설명합니다. 코드를 읽기 전에 이 섹션을 먼저 이해하면 전체 구조가 명확해집니다.
|
||||||
| 비동기 런타임 | `asyncio` + `python-binance` WebSocket |
|
|
||||||
| 기술 지표 | `pandas-ta` (RSI, MACD, BB, EMA, StochRSI, ATR) |
|
### 2.1 진입 판단 (5단계 게이트)
|
||||||
| ML 프레임워크 | `LightGBM` (CPU) / `MLX` (Apple Silicon GPU) |
|
|
||||||
| 모델 서빙 | `onnxruntime` (ONNX 우선) / `joblib` (LightGBM 폴백) |
|
```
|
||||||
| 하이퍼파라미터 탐색 | `Optuna` (TPE Sampler + MedianPruner) |
|
Gate 0: 킬스위치 확인
|
||||||
| 데이터 저장 | `Parquet` (pyarrow) |
|
└─ 해당 심볼이 킬 상태인가? → 킬이면 즉시 return (신규 진입 차단)
|
||||||
| 로깅 | `Loguru` |
|
└─ Fast Kill: 8연속 순손실 / Slow Kill: 최근 15거래 PF < 0.75
|
||||||
| 알림 | Discord Webhook (`httpx`) |
|
|
||||||
| 컨테이너화 | Docker + Docker Compose |
|
Gate 1: 추세 존재 확인
|
||||||
| CI/CD | Jenkins + Gitea Container Registry |
|
└─ ADX ≥ 25 인가? → 미만이면 HOLD (횡보장 진입 차단)
|
||||||
| 운영 서버 | LXC 컨테이너 (`10.1.10.24`) |
|
|
||||||
|
Gate 2: 기술 지표 신호 생성
|
||||||
|
└─ RSI, MACD, 볼린저, EMA, StochRSI 점수 합산
|
||||||
|
└─ 합계 ≥ SIGNAL_THRESHOLD(기본 3)인가?
|
||||||
|
|
||||||
|
Gate 3: 거래량 확인
|
||||||
|
└─ 거래량 ≥ 20MA × VOL_MULTIPLIER(기본 2.5)인가?
|
||||||
|
└─ 또는 신호 점수가 SIGNAL_THRESHOLD + 1 이상인가?
|
||||||
|
|
||||||
|
Gate 4: ML 필터 (활성화 시)
|
||||||
|
└─ 26개 피처로 성공 확률 예측
|
||||||
|
└─ 확률 ≥ ML_THRESHOLD(기본 0.55)인가?
|
||||||
|
|
||||||
|
Gate 5: 리스크 관리
|
||||||
|
└─ 일일 손실 한도 미초과?
|
||||||
|
└─ 동일 방향 포지션 2개 미만?
|
||||||
|
└─ 같은 심볼 기존 포지션 없음?
|
||||||
|
|
||||||
|
→ 6개 게이트 모두 통과 → 주문 실행
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 청산 메커니즘
|
||||||
|
|
||||||
|
| 청산 방식 | 설명 |
|
||||||
|
|-----------|------|
|
||||||
|
| **TP (익절)** | 진입가 ± ATR × ATR_TP_MULT 도달 시 자동 청산 |
|
||||||
|
| **SL (손절)** | 진입가 ∓ ATR × ATR_SL_MULT 도달 시 자동 청산 |
|
||||||
|
| **반대 시그널** | 보유 중 반대 방향 신호 → 즉시 청산 후 반대 방향 재진입 |
|
||||||
|
|
||||||
|
### 2.3 현재 ML 필터 상태
|
||||||
|
|
||||||
|
**현재 비활성화** (`NO_ML_FILTER=true`)
|
||||||
|
|
||||||
|
Walk-Forward 검증 결과 각 폴드 학습 세트에 유효 신호가 약 27건으로, LightGBM이 의미 있는 패턴을 학습하기엔 표본이 부족합니다. 전략 파라미터 스윕에서 ADX 필터 + 거래량 배수 조합만으로 PF 1.57~2.39를 달성하여, 충분한 트레이드 데이터가 축적될 때까지 ML 없이 운영합니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 코어 레이어 아키텍처
|
## 3. 5개 레이어 상세
|
||||||
|
|
||||||
봇은 5개의 레이어로 구성됩니다. 각 레이어는 단일 책임을 가지며, 위에서 아래로 데이터가 흐릅니다.
|
봇은 5개의 레이어로 구성됩니다. 각 레이어는 단일 책임을 가지며, 위에서 아래로 데이터가 흐릅니다.
|
||||||
|
|
||||||
@@ -134,7 +199,7 @@ flowchart TD
|
|||||||
|
|
||||||
**파일:** `src/data_stream.py`
|
**파일:** `src/data_stream.py`
|
||||||
|
|
||||||
각 봇 인스턴스가 시작되면 가장 먼저 실행되는 레이어입니다. Binance Combined WebSocket 단일 연결로 주 거래 심볼 + 상관관계 심볼(BTC/ETH)의 15분봉 캔들을 동시에 수신합니다.
|
Binance Combined WebSocket 단일 연결로 주 거래 심볼 + 상관관계 심볼(BTC/ETH)의 15분봉 캔들을 동시에 수신합니다.
|
||||||
|
|
||||||
**핵심 동작:**
|
**핵심 동작:**
|
||||||
|
|
||||||
@@ -170,7 +235,7 @@ flowchart TD
|
|||||||
| ADX | length=14 | 추세 강도 측정 → 횡보장 필터 (ADX < 25 시 진입 차단) |
|
| ADX | length=14 | 추세 강도 측정 → 횡보장 필터 (ADX < 25 시 진입 차단) |
|
||||||
| Volume MA | length=20 | 거래량 급증 감지 |
|
| Volume MA | length=20 | 거래량 급증 감지 |
|
||||||
|
|
||||||
**신호 생성 로직 (ADX 필터 + 가중치 합산):**
|
**신호 생성 로직:**
|
||||||
|
|
||||||
```
|
```
|
||||||
[1단계] ADX 횡보장 필터:
|
[1단계] ADX 횡보장 필터:
|
||||||
@@ -183,9 +248,13 @@ flowchart TD
|
|||||||
EMA 정배열 (9 > 21 > 50) → +1
|
EMA 정배열 (9 > 21 > 50) → +1
|
||||||
StochRSI K < 20 and K > D → +1
|
StochRSI K < 20 and K > D → +1
|
||||||
|
|
||||||
진입 조건: 점수 ≥ 3 AND (거래량 급증 OR 점수 ≥ 4)
|
진입 조건: 점수 ≥ SIGNAL_THRESHOLD(기본 3)
|
||||||
SL = 진입가 - ATR × 1.5
|
AND (거래량 ≥ 20MA × VOL_MULTIPLIER(기본 2.5) OR 점수 ≥ SIGNAL_THRESHOLD + 1)
|
||||||
TP = 진입가 + ATR × 3.0 (리스크:리워드 = 1:2)
|
|
||||||
|
SL = 진입가 - ATR × ATR_SL_MULT (기본 2.0)
|
||||||
|
TP = 진입가 + ATR × ATR_TP_MULT (기본 2.0)
|
||||||
|
|
||||||
|
※ SL/TP/신호임계값/ADX/거래량배수 모두 환경변수로 설정 가능 (심볼별 오버라이드 지원)
|
||||||
```
|
```
|
||||||
|
|
||||||
숏 신호는 롱의 대칭 조건으로 계산됩니다.
|
숏 신호는 롱의 대칭 조건으로 계산됩니다.
|
||||||
@@ -196,7 +265,7 @@ TP = 진입가 + ATR × 3.0 (리스크:리워드 = 1:2)
|
|||||||
|
|
||||||
**파일:** `src/ml_filter.py`, `src/ml_features.py`
|
**파일:** `src/ml_filter.py`, `src/ml_features.py`
|
||||||
|
|
||||||
기술 지표 신호가 발생해도 ML 모델이 "이 타점은 실패 확률이 높다"고 판단하면 진입을 차단합니다. 오진입(억까 타점)을 줄이는 2차 게이트키퍼입니다.
|
기술 지표 신호가 발생해도 ML 모델이 "이 타점은 실패 확률이 높다"고 판단하면 진입을 차단합니다. 오진입을 줄이는 2차 게이트키퍼입니다.
|
||||||
|
|
||||||
**모델 우선순위:**
|
**모델 우선순위:**
|
||||||
|
|
||||||
@@ -206,7 +275,7 @@ ONNX (MLX 신경망) → LightGBM → 폴백(항상 허용)
|
|||||||
|
|
||||||
모델 파일이 없으면 모든 신호를 허용합니다. 봇 재시작 없이 모델 파일을 교체하면 다음 캔들 마감 시 자동으로 핫리로드됩니다(`mtime` 감지).
|
모델 파일이 없으면 모든 신호를 허용합니다. 봇 재시작 없이 모델 파일을 교체하면 다음 캔들 마감 시 자동으로 핫리로드됩니다(`mtime` 감지).
|
||||||
|
|
||||||
**23개 ML 피처:**
|
**26개 ML 피처:**
|
||||||
|
|
||||||
```
|
```
|
||||||
XRP 기술 지표 (13개):
|
XRP 기술 지표 (13개):
|
||||||
@@ -222,6 +291,13 @@ BTC/ETH 상관관계 (8개):
|
|||||||
시장 미시구조 (2개):
|
시장 미시구조 (2개):
|
||||||
oi_change ← 이전 캔들 대비 미결제약정 변화율
|
oi_change ← 이전 캔들 대비 미결제약정 변화율
|
||||||
funding_rate ← 현재 펀딩비
|
funding_rate ← 현재 펀딩비
|
||||||
|
|
||||||
|
OI 파생 피처 (2개):
|
||||||
|
oi_change_ma5 ← OI 변화율 5캔들 이동평균 (스마트머니 추세)
|
||||||
|
oi_price_spread ← OI 변화율 - 가격 변화율 (OI-가격 괴리도)
|
||||||
|
|
||||||
|
추세 강도 (1개):
|
||||||
|
adx ← ADX 값 (ML 모델이 횡보/추세 판단에 활용)
|
||||||
```
|
```
|
||||||
|
|
||||||
`oi_change`와 `funding_rate`는 캔들 마감마다 Binance REST API로 실시간 조회합니다. API 실패 시 `0.0`으로 폴백하여 봇이 멈추지 않습니다.
|
`oi_change`와 `funding_rate`는 캔들 마감마다 Binance REST API로 실시간 조회합니다. API 실패 시 `0.0`으로 폴백하여 봇이 멈추지 않습니다.
|
||||||
@@ -230,7 +306,7 @@ BTC/ETH 상관관계 (8개):
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
proba = model.predict_proba(features)[0][1] # 성공 확률
|
proba = model.predict_proba(features)[0][1] # 성공 확률
|
||||||
return proba >= 0.60 # 임계값 60%
|
return proba >= 0.55 # 임계값 (ML_THRESHOLD 환경변수로 조절)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -254,28 +330,33 @@ ML 필터를 통과한 신호를 실제 주문으로 변환하고, 리스크 한
|
|||||||
**주문 흐름:**
|
**주문 흐름:**
|
||||||
|
|
||||||
```
|
```
|
||||||
1. set_leverage(10x)
|
1. set_leverage(20x)
|
||||||
2. place_order(MARKET) ← 진입
|
2. place_order(MARKET) ← 진입
|
||||||
3. place_order(STOP_MARKET) ← SL 설정
|
3. place_order(STOP_MARKET) ← SL 설정 (3회 재시도)
|
||||||
4. place_order(TAKE_PROFIT_MARKET) ← TP 설정
|
4. place_order(TAKE_PROFIT_MARKET) ← TP 설정 (3회 재시도)
|
||||||
|
※ SL/TP 최종 실패 시 → 긴급 시장가 청산 + Discord 알림
|
||||||
```
|
```
|
||||||
|
|
||||||
SL/TP 주문은 `/fapi/v1/algoOrder` 엔드포인트로 전송됩니다 (일반 계정의 `-4120` 오류 대응).
|
**SL/TP 원자성 보장:** SL/TP 배치는 `_place_sl_tp_with_retry()`로 3회 재시도합니다. 개별 추적(SL 성공 후 TP만 재시도)하여 불필요한 중복 주문을 방지합니다. 모든 재시도 실패 시 `_emergency_close()`가 포지션을 즉시 시장가 청산하고 Discord로 긴급 알림을 전송합니다.
|
||||||
|
|
||||||
**리스크 제어:**
|
**리스크 제어:**
|
||||||
|
|
||||||
| 제어 항목 | 기준 |
|
| 제어 항목 | 기준 | 방어 대상 |
|
||||||
|----------|------|
|
|----------|------|-----------|
|
||||||
| 일일 최대 손실 | 기준 잔고의 5% |
|
| 일일 최대 손실 | 기준 잔고의 5% | 단일 충격 (하루 급락) |
|
||||||
| 최대 동시 포지션 | 3개 (전체 심볼 합산) |
|
| 킬스위치 Fast Kill | 8연속 순손실 | 전략 급격 붕괴 |
|
||||||
| 동일 방향 제한 | 2개 (LONG 2개면 3번째 LONG 차단) |
|
| 킬스위치 Slow Kill | 최근 15거래 PF < 0.75 | 점진적 엣지 소실 (Slow Bleed) |
|
||||||
| 같은 심볼 중복 | 차단 (1심볼 1포지션) |
|
| 최대 동시 포지션 | 3개 (전체 심볼 합산) | 과노출 |
|
||||||
| 최소 명목금액 | $5 USDT |
|
| 동일 방향 제한 | 2개 (LONG 2개면 3번째 LONG 차단) | 방향 편중 |
|
||||||
|
| 같은 심볼 중복 | 차단 (1심볼 1포지션) | 중복 진입 |
|
||||||
|
| 최소 명목금액 | $5 USDT | 거래소 제약 |
|
||||||
|
|
||||||
**반대 시그널 재진입:** 보유 포지션과 반대 방향 신호 발생 시 기존 포지션을 즉시 청산하고, ML 필터 통과 시 반대 방향으로 재진입합니다. 재진입 중 User Data Stream 콜백이 신규 포지션 상태를 덮어쓰지 않도록 `_is_reentering` 플래그로 보호합니다.
|
**반대 시그널 재진입:** 보유 포지션과 반대 방향 신호 발생 시 기존 포지션을 즉시 청산하고, ML 필터 통과 시 반대 방향으로 재진입합니다. 재진입 중 User Data Stream 콜백이 신규 포지션 상태를 덮어쓰지 않도록 `_is_reentering` 플래그로 보호합니다.
|
||||||
|
|
||||||
**마진 균등 배분:** 멀티심볼 모드에서 각 봇은 전체 잔고를 심볼 수로 나눈 금액만큼만 사용합니다 (`balance / len(symbols)`). 공유 `RiskManager`의 `asyncio.Lock`으로 동시 포지션 등록/해제 시 경합 조건을 방지합니다.
|
**마진 균등 배분:** 멀티심볼 모드에서 각 봇은 전체 잔고를 심볼 수로 나눈 금액만큼만 사용합니다 (`balance / len(symbols)`). 공유 `RiskManager`의 `asyncio.Lock`으로 동시 포지션 등록/해제 시 경합 조건을 방지합니다.
|
||||||
|
|
||||||
|
**Graceful Shutdown:** `main.py`에서 `SIGTERM`/`SIGINT` 시그널을 수신하면 `_graceful_shutdown()`이 실행됩니다. 각 봇의 오픈 주문을 심볼별로 취소(5초 타임아웃)한 후 모든 asyncio 태스크를 정리합니다. Docker `docker stop` 또는 `kill` 시 고아 주문이 거래소에 남지 않습니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Layer 5: Event / Alert Layer
|
### Layer 5: Event / Alert Layer
|
||||||
@@ -307,7 +388,7 @@ Binance `ORDER_TRADE_UPDATE` 웹소켓 이벤트를 구독하여 TP/SL 체결을
|
|||||||
net_pnl = realized_pnl - commission
|
net_pnl = realized_pnl - commission
|
||||||
```
|
```
|
||||||
|
|
||||||
**Discord 알림 포맷:**
|
**Discord 알림 예시:**
|
||||||
|
|
||||||
진입 시:
|
진입 시:
|
||||||
```
|
```
|
||||||
@@ -319,7 +400,7 @@ RSI: 32.50 | MACD Hist: -0.000123 | ATR: 0.023400
|
|||||||
|
|
||||||
청산 시:
|
청산 시:
|
||||||
```
|
```
|
||||||
✅ [XRPUSDT] LONG TP 청산
|
[XRPUSDT] LONG TP 청산
|
||||||
청산가: 2.4150
|
청산가: 2.4150
|
||||||
예상 수익: +7.0000 USDT
|
예상 수익: +7.0000 USDT
|
||||||
실제 순수익: +6.7800 USDT
|
실제 순수익: +6.7800 USDT
|
||||||
@@ -328,20 +409,20 @@ RSI: 32.50 | MACD Hist: -0.000123 | ATR: 0.023400
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. MLOps 파이프라인 — 자가 진화 시스템
|
## 4. MLOps 파이프라인
|
||||||
|
|
||||||
봇의 ML 모델은 고정된 것이 아니라 주기적으로 재학습·개선됩니다. 전체 라이프사이클은 다음과 같습니다.
|
봇의 ML 모델은 고정된 것이 아니라 주기적으로 재학습·개선됩니다.
|
||||||
|
|
||||||
### 3.1 전체 라이프사이클
|
### 4.1 전체 라이프사이클
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
A["주말 수동 트리거<br/>tune_hyperparams.py<br/>(Optuna 50 trials, ~30분)"]
|
A["주말 수동 트리거<br/>tune_hyperparams.py<br/>(Optuna 50 trials)"]
|
||||||
B["결과 검토<br/>tune_results_YYYYMMDD.json<br/>Best AUC vs Baseline 비교"]
|
B["결과 검토<br/>tune_results_YYYYMMDD.json<br/>Best AUC vs Baseline 비교"]
|
||||||
C{"개선폭 충분?<br/>(AUC +0.01 이상<br/>폴드 분산 낮음)"}
|
C{"개선폭 충분?<br/>(AUC +0.01 이상<br/>폴드 분산 낮음)"}
|
||||||
D["active_lgbm_params.json<br/>업데이트<br/>(Active Config 패턴)"]
|
D["active_lgbm_params.json<br/>업데이트<br/>(Active Config 패턴)"]
|
||||||
E["새벽 2시 크론탭<br/>train_and_deploy.sh<br/>(데이터 수집 → 학습 → 배포)"]
|
E["크론탭 또는 수동 실행<br/>train_and_deploy.sh<br/>(데이터 수집 → 학습 → 배포)"]
|
||||||
F["LXC 서버<br/>lgbm_filter.pkl 교체"]
|
F["운영 서버<br/>lgbm_filter.pkl 교체"]
|
||||||
G["봇 핫리로드<br/>다음 캔들 mtime 감지<br/>→ 자동 리로드"]
|
G["봇 핫리로드<br/>다음 캔들 mtime 감지<br/>→ 자동 리로드"]
|
||||||
|
|
||||||
A --> B
|
A --> B
|
||||||
@@ -354,7 +435,7 @@ flowchart LR
|
|||||||
G --> A
|
G --> A
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2 단계별 상세 설명
|
### 4.2 단계별 상세
|
||||||
|
|
||||||
#### Step 1: Optuna 하이퍼파라미터 탐색
|
#### Step 1: Optuna 하이퍼파라미터 탐색
|
||||||
|
|
||||||
@@ -404,7 +485,7 @@ Optuna가 찾은 파라미터는 **자동으로 적용되지 않습니다.** 사
|
|||||||
|
|
||||||
> **주의**: Optuna 결과는 과적합 위험이 있습니다. 폴드별 AUC 분산이 크거나 (std > 0.05), 개선폭이 미미하면 (< 0.01) 적용하지 않는 것을 권장합니다.
|
> **주의**: Optuna 결과는 과적합 위험이 있습니다. 폴드별 AUC 분산이 크거나 (std > 0.05), 개선폭이 미미하면 (< 0.01) 적용하지 않는 것을 권장합니다.
|
||||||
|
|
||||||
#### Step 3: 자동 학습 및 배포 (크론탭)
|
#### Step 3: 자동 학습 및 배포
|
||||||
|
|
||||||
`scripts/train_and_deploy.sh`는 3단계를 자동으로 실행합니다:
|
`scripts/train_and_deploy.sh`는 3단계를 자동으로 실행합니다:
|
||||||
|
|
||||||
@@ -421,8 +502,8 @@ Optuna가 찾은 파라미터는 **자동으로 적용되지 않습니다.** 사
|
|||||||
- Walk-Forward 5폴드 검증 후 최종 모델 저장
|
- Walk-Forward 5폴드 검증 후 최종 모델 저장
|
||||||
- 학습 로그: models/{symbol}/training_log.json
|
- 학습 로그: models/{symbol}/training_log.json
|
||||||
|
|
||||||
[3/3] LXC 배포 (deploy_model.sh --symbol {SYM})
|
[3/3] 운영 서버 배포 (deploy_model.sh --symbol {SYM})
|
||||||
- rsync로 models/{symbol}/lgbm_filter.pkl → LXC 서버 전송
|
- rsync로 models/{symbol}/lgbm_filter.pkl → 운영 서버 전송
|
||||||
- 기존 모델 자동 백업 (lgbm_filter_prev.pkl)
|
- 기존 모델 자동 백업 (lgbm_filter_prev.pkl)
|
||||||
- ONNX 파일 충돌 방지 (우선순위 보장)
|
- ONNX 파일 충돌 방지 (우선순위 보장)
|
||||||
```
|
```
|
||||||
@@ -444,14 +525,34 @@ if onnx_changed or lgbm_changed:
|
|||||||
|
|
||||||
매 캔들 마감(15분)마다 모델 파일의 `mtime`을 확인합니다. 변경이 감지되면 즉시 리로드합니다.
|
매 캔들 마감(15분)마다 모델 파일의 `mtime`을 확인합니다. 변경이 감지되면 즉시 리로드합니다.
|
||||||
|
|
||||||
### 3.3 레이블 생성 방식
|
### 4.3 주간 전략 모니터링
|
||||||
|
|
||||||
|
`scripts/weekly_report.py`가 매주 자동으로 전략 성능을 측정하고 Discord로 리포트를 전송합니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
[매주 일요일 크론탭]
|
||||||
|
|
||||||
|
[1/7] 데이터 수집 (fetch_history.py × 심볼 수, 최근 35일 Upsert)
|
||||||
|
[2/7] Walk-Forward 백테스트 (심볼별 → 합산 PF/승률/MDD)
|
||||||
|
[3/7] 운영 대시보드 API 조회 (GET /api/trades + GET /api/stats → 실전 거래 통계)
|
||||||
|
[4/7] 추이 분석 (이전 리포트에서 PF/승률/MDD 추이 로드)
|
||||||
|
[5/7] 킬스위치 모니터링 (심볼별 연속 손실/15거래 PF → 2단계 경고 출력)
|
||||||
|
[6/7] ML 재학습 체크 (누적 트레이드 ≥ 150, PF < 1.0, PF 3주 하락 → 2/3 충족 시 권장)
|
||||||
|
[7/7] PF < 1.0이면 파라미터 스윕 실행 → 상위 3개 대안 제시
|
||||||
|
|
||||||
|
→ Discord 알림 + results/weekly/report_YYYY-MM-DD.json 저장
|
||||||
|
```
|
||||||
|
|
||||||
|
**전략 파라미터 스윕**: 성능 저하 감지 시 324개 파라미터 조합(SL/TP/ADX/신호임계값/거래량배수)을 자동 탐색하여 현재보다 높은 PF의 대안을 제시합니다. 자동 적용되지 않으며, 사람이 검토 후 승인해야 합니다.
|
||||||
|
|
||||||
|
### 4.4 레이블 생성 방식
|
||||||
|
|
||||||
학습 데이터의 레이블은 **미래 6시간(24캔들) 룩어헤드**로 생성됩니다.
|
학습 데이터의 레이블은 **미래 6시간(24캔들) 룩어헤드**로 생성됩니다.
|
||||||
|
|
||||||
```
|
```
|
||||||
신호 발생 시점 기준:
|
신호 발생 시점 기준:
|
||||||
SL = 진입가 - ATR × 1.5
|
SL = 진입가 - ATR × ATR_SL_MULT (기본 2.0)
|
||||||
TP = 진입가 + ATR × 3.0
|
TP = 진입가 + ATR × ATR_TP_MULT (기본 2.0)
|
||||||
|
|
||||||
향후 24캔들 동안:
|
향후 24캔들 동안:
|
||||||
- 저가가 SL에 먼저 닿으면 → label = 0 (실패)
|
- 저가가 SL에 먼저 닿으면 → label = 0 (실패)
|
||||||
@@ -463,11 +564,11 @@ if onnx_changed or lgbm_changed:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 핵심 동작 시나리오
|
## 5. 핵심 동작 시나리오
|
||||||
|
|
||||||
### 시나리오 1: 15분 캔들 마감 시 봇의 동작 흐름
|
### 시나리오 1: 15분 캔들 마감 → 진입 판단
|
||||||
|
|
||||||
> "XRP 15분봉이 마감되면 봇은 무엇을 하는가?"
|
> "15분봉이 마감되면 봇은 무엇을 하는가?"
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
@@ -496,13 +597,13 @@ sequenceDiagram
|
|||||||
|
|
||||||
alt 신호 = LONG 또는 SHORT, 포지션 없음
|
alt 신호 = LONG 또는 SHORT, 포지션 없음
|
||||||
BOT->>MF: build_features(df, signal, btc_df, eth_df, oi_change, funding_rate)
|
BOT->>MF: build_features(df, signal, btc_df, eth_df, oi_change, funding_rate)
|
||||||
MF-->>BOT: features (23개 피처 Series)
|
MF-->>BOT: features (26개 피처 Series)
|
||||||
BOT->>ML: should_enter(features)
|
BOT->>ML: should_enter(features)
|
||||||
ML-->>BOT: proba=0.73 ≥ 0.60 → True
|
ML-->>BOT: proba=0.73 ≥ 0.55 → True
|
||||||
|
|
||||||
BOT->>EX: get_balance()
|
BOT->>EX: get_balance()
|
||||||
BOT->>RM: get_dynamic_margin_ratio(balance)
|
BOT->>RM: get_dynamic_margin_ratio(balance)
|
||||||
BOT->>EX: set_leverage(10)
|
BOT->>EX: set_leverage(20)
|
||||||
BOT->>EX: place_order(MARKET, BUY, qty=100.0)
|
BOT->>EX: place_order(MARKET, BUY, qty=100.0)
|
||||||
BOT->>EX: place_order(STOP_MARKET, SELL, stop=2.3100)
|
BOT->>EX: place_order(STOP_MARKET, SELL, stop=2.3100)
|
||||||
BOT->>EX: place_order(TAKE_PROFIT_MARKET, SELL, stop=2.4150)
|
BOT->>EX: place_order(TAKE_PROFIT_MARKET, SELL, stop=2.4150)
|
||||||
@@ -520,7 +621,7 @@ sequenceDiagram
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 시나리오 2: TP/SL 체결 시 봇의 동작 흐름
|
### 시나리오 2: TP/SL 체결 → 포지션 종료
|
||||||
|
|
||||||
> "거래소에서 TP가 작동하면 봇은 어떻게 반응하는가?"
|
> "거래소에서 TP가 작동하면 봇은 어떻게 반응하는가?"
|
||||||
|
|
||||||
@@ -550,6 +651,9 @@ sequenceDiagram
|
|||||||
BOT->>NT: notify_close(TP, exit=2.4150, est=+7.00, net=+6.78, diff=-0.22)
|
BOT->>NT: notify_close(TP, exit=2.4150, est=+7.00, net=+6.78, diff=-0.22)
|
||||||
NT->>NT: Discord 웹훅 전송
|
NT->>NT: Discord 웹훅 전송
|
||||||
|
|
||||||
|
BOT->>BOT: _append_trade(net_pnl, "TP") [JSONL 파일에 기록]
|
||||||
|
BOT->>BOT: _check_kill_switch() [8연패/PF<0.75 검사]
|
||||||
|
|
||||||
BOT->>BOT: current_trade_side = None
|
BOT->>BOT: current_trade_side = None
|
||||||
BOT->>BOT: _entry_price = None
|
BOT->>BOT: _entry_price = None
|
||||||
BOT->>BOT: _entry_quantity = None
|
BOT->>BOT: _entry_quantity = None
|
||||||
@@ -558,112 +662,321 @@ sequenceDiagram
|
|||||||
|
|
||||||
**핵심 포인트:**
|
**핵심 포인트:**
|
||||||
- User Data Stream은 `asyncio.gather()`로 캔들 스트림과 **병렬** 실행
|
- User Data Stream은 `asyncio.gather()`로 캔들 스트림과 **병렬** 실행
|
||||||
- 체결 즉시 감지 (최대 15분 지연이었던 폴링 방식 대비 실시간)
|
- 체결 즉시 감지 (폴링 방식의 최대 15분 지연 해소)
|
||||||
- `realized_pnl - commission` = 정확한 순수익 (슬리피지·수수료 포함)
|
- `realized_pnl - commission` = 정확한 순수익 (슬리피지·수수료 포함)
|
||||||
- `_is_reentering` 플래그: 반대 시그널 재진입 중에는 콜백이 신규 포지션 상태를 초기화하지 않음
|
- `_is_reentering` 플래그: 반대 시그널 재진입 중에는 콜백이 신규 포지션 상태를 초기화하지 않음
|
||||||
|
- `_close_lock`: 콜백(`_on_position_closed`)과 포지션 모니터(`_position_monitor` SYNC 경로) 간 PnL 이중기록 방지. asyncio await 포인트 사이 경쟁 조건을 Lock으로 원자화
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 테스트 커버리지
|
## 5-1. MTF Pullback Bot
|
||||||
|
|
||||||
### 5.1 테스트 파일 구성
|
기존 메인 봇(`bot.py`)과 **별도로** 운영되는 멀티타임프레임 풀백 전략 봇입니다. 4월 OOS(Out-of-Sample) 검증 기간 동안 Dry-run 모드로 실행됩니다.
|
||||||
|
|
||||||
`tests/` 폴더에 14개 테스트 파일, 총 **99개의 테스트 케이스**가 작성되어 있습니다.
|
**파일:** `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 테스트 실행
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest tests/ -v # 전체 실행
|
pytest tests/ -v # 전체 실행
|
||||||
bash scripts/run_tests.sh # 래퍼 스크립트 실행
|
bash scripts/run_tests.sh # 래퍼 스크립트 실행
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.2 모듈별 테스트 현황
|
`tests/` 폴더에 19개 테스트 파일, 총 **191개의 테스트 케이스**가 작성되어 있습니다.
|
||||||
|
|
||||||
| 테스트 파일 | 대상 모듈 | 테스트 케이스 | 주요 검증 항목 |
|
### 6.2 모듈별 테스트 현황
|
||||||
|------------|----------|:------------:|--------------|
|
|
||||||
| `test_bot.py` | `src/bot.py` | 11 | 반대 시그널 재진입 흐름, ML 차단 시 재진입 스킵, OI/펀딩비 피처 전달, OI 변화율 계산 |
|
| 테스트 파일 | 대상 모듈 | 케이스 | 주요 검증 항목 |
|
||||||
| `test_indicators.py` | `src/indicators.py` | 7 | RSI 범위(0~100), MACD 컬럼 존재, 볼린저 밴드 상하단 대소관계, 신호 반환값 유효성, ADX 컬럼 존재, ADX<25 횡보장 차단, ADX NaN 폴스루 |
|
|------------|----------|:------:|--------------|
|
||||||
| `test_ml_features.py` | `src/ml_features.py` | 11 | 23개 피처 수, BTC/ETH 포함 시 피처 수, RS 분모 0 처리, NaN 없음, side 인코딩, OI/펀딩비 파라미터 반영 |
|
| `test_bot.py` | `src/bot.py` | 18 | 반대 시그널 재진입, ML 차단 시 스킵, OI/펀딩비 피처 전달 |
|
||||||
| `test_ml_filter.py` | `src/ml_filter.py` | 5 | 모델 없을 때 폴백 허용, 임계값 이상/미만 판단, 핫리로드 후 상태 변화 |
|
| `test_indicators.py` | `src/indicators.py` | 7 | RSI 범위, MACD 컬럼, 볼린저 대소관계, ADX 횡보장 차단 |
|
||||||
| `test_risk_manager.py` | `src/risk_manager.py` | 13 | 일일 손실 한도 초과 차단, 최대 포지션 수 제한, 동일 방향 제한, 심볼 중복 차단, 비동기 포지션 등록/해제, 동적 증거금 비율 상한/하한 클램핑 |
|
| `test_ml_features.py` | `src/ml_features.py` | 14 | 26개 피처 수, RS 분모 0 처리, NaN 없음 |
|
||||||
| `test_exchange.py` | `src/exchange.py` | 8 | 수량 계산(기본/최소명목금액/잔고0), OI·펀딩비 조회 정상/오류 시 반환값 |
|
| `test_ml_filter.py` | `src/ml_filter.py` | 5 | 모델 없을 때 폴백, 임계값 판단, 핫리로드 |
|
||||||
| `test_data_stream.py` | `src/data_stream.py` | 6 | 3심볼 버퍼 존재, 빈 버퍼 None 반환, 캔들 파싱, 마감 캔들 콜백 호출, 프리로드 200개 |
|
| `test_risk_manager.py` | `src/risk_manager.py` | 15 | 일일 손실 한도, 동일 방향 제한, 동적 증거금 비율 |
|
||||||
| `test_label_builder.py` | `src/label_builder.py` | 4 | LONG TP 도달 → 1, LONG SL 도달 → 0, 미결 → None, SHORT TP 도달 → 1 |
|
| `test_exchange.py` | `src/exchange.py` | 12 | 수량 계산, OI·펀딩비 조회 정상/오류 |
|
||||||
| `test_dataset_builder.py` | `src/dataset_builder.py` | 9 | DataFrame 반환, 필수 컬럼 존재, 레이블 이진값, BTC/ETH 포함 시 23개 피처, inf/NaN 없음, OI nan 마스킹, RS 분모 0 처리 |
|
| `test_data_stream.py` | `src/data_stream.py` | 7 | 3심볼 버퍼, 캔들 파싱, 프리로드 200개 |
|
||||||
| `test_mlx_filter.py` | `src/mlx_filter.py` | 5 | GPU 디바이스 확인, 학습 전 예측 형태, 학습 후 유효 확률, NaN 피처 처리, 저장/로드 후 동일 예측 |
|
| `test_label_builder.py` | `src/label_builder.py` | 4 | TP/SL 도달 레이블, 미결 → None |
|
||||||
| `test_fetch_history.py` | `scripts/fetch_history.py` | 5 | OI=0 구간 Upsert, 신규 행 추가, 기존 비0값 보존, 파일 없을 때 신규 반환, 타임스탬프 오름차순 정렬 |
|
| `test_dataset_builder.py` | `src/dataset_builder.py` | 14 | DataFrame 반환, 필수 컬럼, inf/NaN 없음 |
|
||||||
| `test_config.py` | `src/config.py` | 6 | 환경변수 로드, 동적 증거금 파라미터 로드, `symbols` 리스트, `correlation_symbols`, `max_same_direction`, SYMBOL→symbols 폴백 |
|
| `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` | 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` 패키지)이 없는 환경에서 자동 스킵됩니다.
|
> `test_mlx_filter.py`는 Apple Silicon(`mlx` 패키지)이 없는 환경에서 자동 스킵됩니다.
|
||||||
|
|
||||||
### 5.3 커버리지 매트릭스
|
### 6.3 커버리지 매트릭스
|
||||||
|
|
||||||
아래는 핵심 비즈니스 로직의 테스트 커버 여부입니다.
|
| 기능 | 단위 | 통합 | 비고 |
|
||||||
|
|------|:----:|:----:|------|
|
||||||
| 기능 | 단위 테스트 | 통합 수준 테스트 | 비고 |
|
| 기술 지표 계산 | ✅ | ✅ | `test_indicators` + `test_ml_features` + `test_dataset_builder` |
|
||||||
|------|:----------:|:--------------:|------|
|
|
||||||
| 기술 지표 계산 (RSI/MACD/BB/EMA/StochRSI/ADX) | ✅ | ✅ | `test_indicators` + `test_ml_features` + `test_dataset_builder` |
|
|
||||||
| 신호 생성 (가중치 합산) | ✅ | ✅ | `test_indicators` + `test_dataset_builder` |
|
| 신호 생성 (가중치 합산) | ✅ | ✅ | `test_indicators` + `test_dataset_builder` |
|
||||||
| ADX 횡보장 필터 (ADX < 25 차단) | ✅ | ✅ | `test_indicators` + `test_dataset_builder` (`_calc_signals` 실제 호출) |
|
| ADX 횡보장 필터 | ✅ | ✅ | `test_indicators` |
|
||||||
| ML 피처 추출 (23개) | ✅ | ✅ | `test_ml_features` + `test_dataset_builder` (`_calc_features_vectorized` 실제 호출) |
|
| ML 피처 추출 (26개) | ✅ | ✅ | `test_ml_features` + `test_dataset_builder` |
|
||||||
| ML 필터 추론 (임계값 판단) | ✅ | — | `test_ml_filter` |
|
| ML 필터 추론 | ✅ | — | `test_ml_filter` |
|
||||||
| MLX 신경망 학습/저장/로드 | ✅ | — | `test_mlx_filter` (Apple Silicon 전용) |
|
| MLX 신경망 학습/저장/로드 | ✅ | — | `test_mlx_filter` (Apple Silicon 전용) |
|
||||||
| 레이블 생성 (SL/TP 룩어헤드) | ✅ | ✅ | `test_label_builder` + `test_dataset_builder` (전체 파이프라인 실제 호출) |
|
| 레이블 생성 (SL/TP 룩어헤드) | ✅ | ✅ | `test_label_builder` + `test_dataset_builder` |
|
||||||
| 벡터화 데이터셋 빌더 | ✅ | ✅ | `test_dataset_builder` |
|
| 벡터화 데이터셋 빌더 | ✅ | ✅ | `test_dataset_builder` |
|
||||||
| 동적 증거금 비율 계산 | ✅ | — | `test_risk_manager` |
|
| 동적 증거금 비율 | ✅ | — | `test_risk_manager` |
|
||||||
| 동일 방향 포지션 제한 | ✅ | — | `test_risk_manager` |
|
| 동일 방향 포지션 제한 | ✅ | — | `test_risk_manager` |
|
||||||
| 심볼 중복 진입 차단 | ✅ | — | `test_risk_manager` |
|
| 일일 손실 한도 | ✅ | — | `test_risk_manager` |
|
||||||
| 일일 손실 한도 제어 | ✅ | — | `test_risk_manager` |
|
|
||||||
| 포지션 수량 계산 | ✅ | — | `test_exchange` |
|
| 포지션 수량 계산 | ✅ | — | `test_exchange` |
|
||||||
| OI/펀딩비 API 조회 (정상/오류) | ✅ | ✅ | `test_exchange` + `test_bot` (`process_candle` → OI/펀딩비 → `build_features` 전달) |
|
| OI/펀딩비 API 조회 | ✅ | ✅ | `test_exchange` + `test_bot` |
|
||||||
| 반대 시그널 재진입 흐름 | ✅ | ✅ | `test_bot` |
|
| 반대 시그널 재진입 | ✅ | ✅ | `test_bot` |
|
||||||
| ML 차단 시 재진입 스킵 | ✅ | ✅ | `test_bot` (`_close_and_reenter` → ML 판단 → 스킵 전체 흐름) |
|
| OI 변화율 계산 | ✅ | ✅ | `test_bot` |
|
||||||
| OI 변화율 계산 (API 실패 폴백) | ✅ | ✅ | `test_bot` (`process_candle` → OI 조회 → `_calc_oi_change` 흐름) |
|
| Parquet Upsert | ✅ | — | `test_fetch_history` |
|
||||||
| 캔들 버퍼 관리 및 프리로드 | ✅ | — | `test_data_stream` |
|
| 주간 리포트 | ✅ | ✅ | `test_weekly_report` |
|
||||||
| Parquet Upsert (OI=0 보충) | ✅ | — | `test_fetch_history` |
|
| MTF Pullback Bot | ✅ | ✅ | `test_mtf_bot` (20 cases) |
|
||||||
| User Data Stream TP/SL 감지 | ❌ | — | 미작성 (실제 WebSocket 의존) |
|
| User Data Stream TP/SL | ❌ | — | 미작성 (WebSocket 의존) |
|
||||||
| Discord 알림 전송 | ❌ | — | 미작성 (외부 웹훅 의존) |
|
| Discord 알림 전송 | ❌ | — | 미작성 (외부 웹훅 의존) |
|
||||||
| CI/CD 파이프라인 | ❌ | — | Jenkins 환경 의존 |
|
|
||||||
|
|
||||||
### 5.4 테스트 전략
|
### 6.4 테스트 전략
|
||||||
|
|
||||||
**Mock 활용 원칙:**
|
- **Mock 원칙**: Binance API 호출은 모두 `unittest.mock.AsyncMock`으로 대체. 외부 의존성(Discord, WebSocket)은 테스트 대상에서 제외.
|
||||||
- Binance API 호출(`BinanceFuturesClient`, `AsyncClient`)은 모두 `unittest.mock.AsyncMock`으로 대체합니다.
|
- **비동기 테스트**: `pytest-asyncio` + `@pytest.mark.asyncio`
|
||||||
- 외부 의존성(Discord Webhook, Binance WebSocket)은 테스트 대상에서 제외합니다.
|
- **경계값 중심**: 분모 0 처리, API 실패 폴백, 최소 주문 금액 미달, OI=0 구간 Upsert
|
||||||
- `tmp_path` pytest fixture로 Parquet 파일 I/O를 격리합니다.
|
|
||||||
|
|
||||||
**비동기 테스트:**
|
|
||||||
- `pytest-asyncio`를 사용하며, `@pytest.mark.asyncio` 데코레이터로 `async def` 테스트를 실행합니다.
|
|
||||||
|
|
||||||
**경계값 및 엣지 케이스 중심:**
|
|
||||||
- 분모 0 (RS 계산, bb_range, vol_ma20)
|
|
||||||
- API 실패 시 `None` 반환 및 `0.0` 폴백
|
|
||||||
- 최소 명목금액 미달 시 주문 스킵
|
|
||||||
- OI=0 구간 Parquet Upsert 보존/덮어쓰기 조건
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 부록: 파일별 역할 요약
|
## 7. 파일 구조
|
||||||
|
|
||||||
| 파일 | 레이어 | 역할 |
|
| 파일 | 레이어 | 역할 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| `main.py` | — | 진입점. 심볼별 `TradingBot` 생성 + 공유 `RiskManager` + `asyncio.gather()` |
|
| `main.py` | — | 진입점. 심볼별 `TradingBot` 생성 + 공유 `RiskManager` + `asyncio.gather()` + SIGTERM/SIGINT graceful shutdown |
|
||||||
| `src/bot.py` | 오케스트레이터 | 심볼별 독립 트레이딩 루프 (symbol, risk 주입) |
|
| `src/bot.py` | 오케스트레이터 | 심볼별 독립 트레이딩 루프 + 듀얼 레이어 킬스위치 |
|
||||||
| `src/config.py` | — | 환경변수 기반 설정 (`symbols` 리스트, `correlation_symbols`) |
|
| `src/config.py` | — | 환경변수 기반 설정 (`symbols` 리스트, `correlation_symbols`, 심볼별 `SymbolStrategyParams`) |
|
||||||
| `src/data_stream.py` | Data | Combined WebSocket 캔들 수신·버퍼 관리 |
|
| `src/data_stream.py` | Data | Combined WebSocket 캔들 수신·버퍼 관리 |
|
||||||
| `src/indicators.py` | Signal | 기술 지표 계산 및 복합 신호 생성 |
|
| `src/indicators.py` | Signal | 기술 지표 계산 및 복합 신호 생성 |
|
||||||
| `src/ml_features.py` | ML Filter | 23개 ML 피처 추출 |
|
| `src/ml_features.py` | ML Filter | 26개 ML 피처 추출 |
|
||||||
| `src/ml_filter.py` | ML Filter | ONNX/LightGBM 모델 로드·추론·핫리로드 |
|
| `src/ml_filter.py` | ML Filter | ONNX/LightGBM 모델 로드·추론·핫리로드 |
|
||||||
| `src/mlx_filter.py` | ML Filter | Apple Silicon GPU 학습 + ONNX export |
|
| `src/mlx_filter.py` | ML Filter | Apple Silicon GPU 학습 + ONNX export |
|
||||||
| `src/exchange.py` | Execution | Binance Futures REST API 클라이언트 (심볼별 독립) |
|
| `src/exchange.py` | Execution | Binance Futures REST API 클라이언트 |
|
||||||
| `src/risk_manager.py` | Risk | 공유 싱글턴 — 일일 손실 한도·동일 방향 제한·동적 증거금 비율 |
|
| `src/risk_manager.py` | Risk | 공유 싱글턴 — 일일 손실 한도·동일 방향 제한·동적 증거금 비율 |
|
||||||
| `src/user_data_stream.py` | Event | User Data Stream TP/SL 즉시 감지 |
|
| `src/user_data_stream.py` | Event | User Data Stream TP/SL 즉시 감지 |
|
||||||
| `src/notifier.py` | Alert | Discord 웹훅 알림 |
|
| `src/notifier.py` | Alert | Discord 웹훅 알림 |
|
||||||
| `src/label_builder.py` | MLOps | 학습 레이블 생성 (ATR SL/TP 룩어헤드) |
|
| `src/label_builder.py` | MLOps | 학습 레이블 생성 (ATR SL/TP 룩어헤드) |
|
||||||
| `src/dataset_builder.py` | MLOps | 벡터화 데이터셋 빌더 (학습용) |
|
| `src/dataset_builder.py` | MLOps | 벡터화 데이터셋 빌더 (학습용) |
|
||||||
|
| `src/backtester.py` | MLOps | 백테스트 엔진 (단일 + Walk-Forward) |
|
||||||
|
| `src/mtf_bot.py` | MTF Bot | 멀티타임프레임 풀백 봇 (1h MetaFilter + 15m TriggerStrategy + Dry-run ExecutionManager) |
|
||||||
|
| `src/backtest_validator.py` | MLOps | 백테스트 결과 검증 |
|
||||||
| `src/logger_setup.py` | — | Loguru 로거 설정 |
|
| `src/logger_setup.py` | — | Loguru 로거 설정 |
|
||||||
| `scripts/fetch_history.py` | MLOps | 과거 캔들 + OI/펀딩비 수집 (`--symbol` 지원) |
|
| `scripts/fetch_history.py` | MLOps | 과거 캔들 + OI/펀딩비 수집 |
|
||||||
| `scripts/train_model.py` | MLOps | LightGBM 모델 학습 (`--symbol` 지원) |
|
| `scripts/train_model.py` | MLOps | LightGBM 모델 학습 |
|
||||||
| `scripts/train_mlx_model.py` | MLOps | MLX 신경망 학습 (Apple Silicon GPU) |
|
| `scripts/train_mlx_model.py` | MLOps | MLX 신경망 학습 (Apple Silicon GPU) |
|
||||||
| `scripts/tune_hyperparams.py` | MLOps | Optuna 하이퍼파라미터 탐색 (`--symbol` 지원) |
|
| `scripts/tune_hyperparams.py` | MLOps | Optuna 하이퍼파라미터 탐색 |
|
||||||
| `scripts/train_and_deploy.sh` | MLOps | 전체 파이프라인 (`--symbol` / `--all` 지원) |
|
| `scripts/train_and_deploy.sh` | MLOps | 전체 파이프라인 (수집 → 학습 → 배포) |
|
||||||
| `scripts/deploy_model.sh` | MLOps | 모델 파일 LXC 서버 전송 (`--symbol` 지원) |
|
| `scripts/deploy_model.sh` | MLOps | 모델 파일 운영 서버 전송 |
|
||||||
| `models/{symbol}/active_lgbm_params.json` | MLOps | 심볼별 승인된 LightGBM 파라미터 (Active Config) |
|
| `scripts/strategy_sweep.py` | MLOps | 전략 파라미터 그리드 스윕 (324개 조합) |
|
||||||
|
| `scripts/weekly_report.py` | MLOps | 주간 전략 리포트 (백테스트+킬스위치+대시보드API+추이+스윕+Discord) |
|
||||||
|
| `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 파라미터 |
|
||||||
|
|||||||
28
CLAUDE.md
28
CLAUDE.md
@@ -36,6 +36,12 @@ bash scripts/train_and_deploy.sh mlx --symbol XRPUSDT
|
|||||||
# Hyperparameter tuning (50 trials, 5-fold walk-forward)
|
# Hyperparameter tuning (50 trials, 5-fold walk-forward)
|
||||||
python scripts/tune_hyperparams.py --symbol XRPUSDT
|
python scripts/tune_hyperparams.py --symbol XRPUSDT
|
||||||
|
|
||||||
|
# Weekly strategy report (manual, skip data fetch)
|
||||||
|
python scripts/weekly_report.py --skip-fetch
|
||||||
|
|
||||||
|
# Weekly report with data refresh
|
||||||
|
python scripts/weekly_report.py
|
||||||
|
|
||||||
# Fetch historical data (single symbol with auto correlation)
|
# Fetch historical data (single symbol with auto correlation)
|
||||||
python scripts/fetch_history.py --symbol TRXUSDT --interval 15m --days 365
|
python scripts/fetch_history.py --symbol TRXUSDT --interval 15m --days 365
|
||||||
|
|
||||||
@@ -59,6 +65,8 @@ bash scripts/deploy_model.sh --symbol XRPUSDT
|
|||||||
4. `src/exchange.py` + `src/risk_manager.py` — Dynamic margin, MARKET orders with SL/TP, daily loss limit (5%), same-direction limit
|
4. `src/exchange.py` + `src/risk_manager.py` — Dynamic margin, MARKET orders with SL/TP, daily loss limit (5%), same-direction limit
|
||||||
5. `src/user_data_stream.py` + `src/notifier.py` — Real-time TP/SL detection via WebSocket, Discord webhooks
|
5. `src/user_data_stream.py` + `src/notifier.py` — Real-time TP/SL detection via WebSocket, Discord webhooks
|
||||||
|
|
||||||
|
**Dual-layer kill switch** (per-symbol, in `src/bot.py`): Fast Kill (8 consecutive net losses) + Slow Kill (last 15 trades PF < 0.75). Trade history persisted to `data/trade_history/{symbol}.jsonl`. Blocks new entries only; existing SL/TP exits work normally. Manual reset via `RESET_KILL_SWITCH_{SYMBOL}=True` env var + restart.
|
||||||
|
|
||||||
**Parallel execution**: Per-symbol bots run independently via `asyncio.gather()`. Each bot's `user_data_stream` also runs in parallel.
|
**Parallel execution**: Per-symbol bots run independently via `asyncio.gather()`. Each bot's `user_data_stream` also runs in parallel.
|
||||||
|
|
||||||
**Model/data directories**: `models/{symbol}/` and `data/{symbol}/` for per-symbol models. Falls back to `models/` root if symbol dir doesn't exist.
|
**Model/data directories**: `models/{symbol}/` and `data/{symbol}/` for per-symbol models. Falls back to `models/` root if symbol dir doesn't exist.
|
||||||
@@ -82,9 +90,9 @@ bash scripts/deploy_model.sh --symbol XRPUSDT
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Environment variables via `.env` file (see `.env.example`). Key vars: `BINANCE_API_KEY`, `BINANCE_API_SECRET`, `SYMBOLS` (comma-separated, e.g. `XRPUSDT,TRXUSDT`), `CORRELATION_SYMBOLS` (default `BTCUSDT,ETHUSDT`), `LEVERAGE`, `DISCORD_WEBHOOK_URL`, `MARGIN_MAX_RATIO`, `MARGIN_MIN_RATIO`, `MAX_SAME_DIRECTION` (default 2), `NO_ML_FILTER`.
|
Environment variables via `.env` file (see `.env.example`). Key vars: `BINANCE_API_KEY`, `BINANCE_API_SECRET`, `SYMBOLS` (comma-separated, currently `XRPUSDT` only — SOL/DOGE/TRX removed due to PF < 1.0), `CORRELATION_SYMBOLS` (default `BTCUSDT,ETHUSDT`), `LEVERAGE`, `DISCORD_WEBHOOK_URL`, `MARGIN_MAX_RATIO`, `MARGIN_MIN_RATIO`, `MAX_SAME_DIRECTION` (default 2), `NO_ML_FILTER` (default `true` — ML disabled due to insufficient feature alpha).
|
||||||
|
|
||||||
`src/config.py` uses `@dataclass` with `__post_init__` to load and validate all env vars.
|
`src/config.py` uses `@dataclass` with `__post_init__` to load and validate all env vars. Per-symbol strategy params supported via `SymbolStrategyParams` — override with `ATR_SL_MULT_{SYMBOL}`, `ATR_TP_MULT_{SYMBOL}`, `SIGNAL_THRESHOLD_{SYMBOL}`, `ADX_THRESHOLD_{SYMBOL}`, `VOL_MULTIPLIER_{SYMBOL}`. Access via `config.get_symbol_params(symbol)`.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
@@ -131,3 +139,19 @@ All design documents and implementation plans are stored in `docs/plans/` with t
|
|||||||
| 2026-03-05 | `multi-symbol-trading` (design + plan) | Completed |
|
| 2026-03-05 | `multi-symbol-trading` (design + plan) | Completed |
|
||||||
| 2026-03-06 | `multi-symbol-dashboard` (design + plan) | Completed |
|
| 2026-03-06 | `multi-symbol-dashboard` (design + plan) | Completed |
|
||||||
| 2026-03-06 | `strategy-parameter-sweep` (plan) | Completed |
|
| 2026-03-06 | `strategy-parameter-sweep` (plan) | Completed |
|
||||||
|
| 2026-03-07 | `weekly-report` (plan) | Completed |
|
||||||
|
| 2026-03-07 | `code-review-improvements` | Partial (#1,#2,#4,#5,#6,#8 완료) |
|
||||||
|
| 2026-03-19 | `critical-bugfixes` (C5,C1,C3,C8) | Completed |
|
||||||
|
| 2026-03-21 | `dashboard-code-review-r2` (#14,#19) | Completed |
|
||||||
|
| 2026-03-21 | `code-review-fixes-r2` (9 issues) | Completed |
|
||||||
|
| 2026-03-21 | `ml-pipeline-fixes` (C1,C3,I1,I3,I4,I5) | Completed |
|
||||||
|
| 2026-03-21 | `training-threshold-relaxation` (plan) | Completed |
|
||||||
|
| 2026-03-21 | `purged-gap-and-ablation` (plan) | Completed |
|
||||||
|
| 2026-03-21 | `ml-validation-result` | ML OFF > ML ON 확정, SOL/DOGE/TRX 제외, XRP 단독 운영 |
|
||||||
|
| 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 검증 진행 중 |
|
||||||
|
|||||||
31
Jenkinsfile
vendored
31
Jenkinsfile
vendored
@@ -37,16 +37,25 @@ pipeline {
|
|||||||
stage('Detect Changes') {
|
stage('Detect Changes') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
def changes = sh(script: 'git diff --name-only HEAD~1 || echo "ALL"', returnStdout: true).trim()
|
// 이전 성공 빌드 커밋과 비교 (없으면 HEAD~5 fallback)
|
||||||
|
def baseCommit = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT ?: sh(script: 'git rev-parse HEAD~5 2>/dev/null || echo ""', returnStdout: true).trim()
|
||||||
|
def diffCmd = baseCommit ? "git diff --name-only ${baseCommit}..HEAD" : 'git diff --name-only HEAD~1'
|
||||||
|
def changes = sh(script: "${diffCmd} || echo \"ALL\"", returnStdout: true).trim()
|
||||||
|
echo "Base commit: ${baseCommit ?: 'HEAD~1 (fallback)'}"
|
||||||
echo "Changed files:\n${changes}"
|
echo "Changed files:\n${changes}"
|
||||||
|
|
||||||
if (changes == 'ALL') {
|
if (changes == 'ALL') {
|
||||||
// 첫 빌드이거나 diff 실패 시 전체 빌드
|
// 첫 빌드이거나 diff 실패 시 전체 빌드
|
||||||
env.BOT_CHANGED = 'true'
|
env.BOT_CHANGED = 'true'
|
||||||
|
env.MTF_CHANGED = 'true'
|
||||||
env.DASH_API_CHANGED = 'true'
|
env.DASH_API_CHANGED = 'true'
|
||||||
env.DASH_UI_CHANGED = 'true'
|
env.DASH_UI_CHANGED = 'true'
|
||||||
} else {
|
} else {
|
||||||
env.BOT_CHANGED = (changes =~ /(?m)^(src\/|main\.py|requirements\.txt|Dockerfile)/).find() ? 'true' : 'false'
|
// mtf_bot.py 변경 감지 (mtf-bot 서비스만 재시작)
|
||||||
|
env.MTF_CHANGED = (changes =~ /(?m)^src\/mtf_bot\.py/).find() ? 'true' : 'false'
|
||||||
|
// src/ 변경 중 mtf_bot.py만 바뀐 경우 메인 봇은 재시작 불필요
|
||||||
|
def botFiles = changes.split('\n').findAll { it =~ /^(src\/(?!mtf_bot\.py)|scripts\/|main\.py|requirements\.txt|Dockerfile)/ }
|
||||||
|
env.BOT_CHANGED = botFiles.size() > 0 ? 'true' : 'false'
|
||||||
env.DASH_API_CHANGED = (changes =~ /(?m)^dashboard\/api\//).find() ? 'true' : 'false'
|
env.DASH_API_CHANGED = (changes =~ /(?m)^dashboard\/api\//).find() ? 'true' : 'false'
|
||||||
env.DASH_UI_CHANGED = (changes =~ /(?m)^dashboard\/ui\//).find() ? 'true' : 'false'
|
env.DASH_UI_CHANGED = (changes =~ /(?m)^dashboard\/ui\//).find() ? 'true' : 'false'
|
||||||
}
|
}
|
||||||
@@ -58,7 +67,7 @@ pipeline {
|
|||||||
env.COMPOSE_CHANGED = 'false'
|
env.COMPOSE_CHANGED = 'false'
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "BOT_CHANGED=${env.BOT_CHANGED}, DASH_API_CHANGED=${env.DASH_API_CHANGED}, DASH_UI_CHANGED=${env.DASH_UI_CHANGED}, COMPOSE_CHANGED=${env.COMPOSE_CHANGED}"
|
echo "BOT_CHANGED=${env.BOT_CHANGED}, MTF_CHANGED=${env.MTF_CHANGED}, DASH_API_CHANGED=${env.DASH_API_CHANGED}, DASH_UI_CHANGED=${env.DASH_UI_CHANGED}, COMPOSE_CHANGED=${env.COMPOSE_CHANGED}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,7 +75,7 @@ pipeline {
|
|||||||
stage('Build Docker Images') {
|
stage('Build Docker Images') {
|
||||||
parallel {
|
parallel {
|
||||||
stage('Bot') {
|
stage('Bot') {
|
||||||
when { expression { env.BOT_CHANGED == 'true' } }
|
when { expression { env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true' } }
|
||||||
steps {
|
steps {
|
||||||
sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ."
|
sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ."
|
||||||
}
|
}
|
||||||
@@ -91,7 +100,7 @@ pipeline {
|
|||||||
withCredentials([usernamePassword(credentialsId: 'gitea-registry-cred', passwordVariable: 'GITEA_TOKEN', usernameVariable: 'GITEA_USER')]) {
|
withCredentials([usernamePassword(credentialsId: 'gitea-registry-cred', passwordVariable: 'GITEA_TOKEN', usernameVariable: 'GITEA_USER')]) {
|
||||||
sh "echo \$GITEA_TOKEN | docker login ${REGISTRY} -u \$GITEA_USER --password-stdin"
|
sh "echo \$GITEA_TOKEN | docker login ${REGISTRY} -u \$GITEA_USER --password-stdin"
|
||||||
script {
|
script {
|
||||||
if (env.BOT_CHANGED == 'true') {
|
if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') {
|
||||||
sh "docker push ${FULL_IMAGE}"
|
sh "docker push ${FULL_IMAGE}"
|
||||||
sh "docker push ${LATEST_IMAGE}"
|
sh "docker push ${LATEST_IMAGE}"
|
||||||
}
|
}
|
||||||
@@ -119,7 +128,13 @@ pipeline {
|
|||||||
|
|
||||||
// 변경된 서비스만 pull & recreate (나머지는 중단 없음)
|
// 변경된 서비스만 pull & recreate (나머지는 중단 없음)
|
||||||
def services = []
|
def services = []
|
||||||
if (env.BOT_CHANGED == 'true') services.add('cointrader')
|
if (env.BOT_CHANGED == 'true') {
|
||||||
|
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_API_CHANGED == 'true') services.add('dashboard-api')
|
||||||
if (env.DASH_UI_CHANGED == 'true') services.add('dashboard-ui')
|
if (env.DASH_UI_CHANGED == 'true') services.add('dashboard-ui')
|
||||||
|
|
||||||
@@ -137,7 +152,7 @@ pipeline {
|
|||||||
stage('Cleanup') {
|
stage('Cleanup') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
if (env.BOT_CHANGED == 'true') {
|
if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') {
|
||||||
sh "docker rmi ${FULL_IMAGE} || true"
|
sh "docker rmi ${FULL_IMAGE} || true"
|
||||||
sh "docker rmi ${LATEST_IMAGE} || true"
|
sh "docker rmi ${LATEST_IMAGE} || true"
|
||||||
}
|
}
|
||||||
@@ -160,7 +175,7 @@ pipeline {
|
|||||||
sh """
|
sh """
|
||||||
curl -H "Content-Type: application/json" \
|
curl -H "Content-Type: application/json" \
|
||||||
-X POST \
|
-X POST \
|
||||||
-d '{"content": "✅ **[배포 성공]** `cointrader` (Build #${env.BUILD_NUMBER}) 운영 서버(10.1.10.24) 배포 완료!\\n- 🤖 봇: ${env.BOT_CHANGED}\\n- 📊 API: ${env.DASH_API_CHANGED}\\n- 🖥️ UI: ${env.DASH_UI_CHANGED}"}' \
|
-d '{"content": "✅ **[배포 성공]** `cointrader` (Build #${env.BUILD_NUMBER}) 운영 서버(10.1.10.24) 배포 완료!\\n- 🤖 봇: ${env.BOT_CHANGED}\\n- 📈 MTF: ${env.MTF_CHANGED}\\n- 📊 API: ${env.DASH_API_CHANGED}\\n- 🖥️ UI: ${env.DASH_UI_CHANGED}"}' \
|
||||||
${DISCORD_WEBHOOK}
|
${DISCORD_WEBHOOK}
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|||||||
660
README.md
660
README.md
@@ -1,36 +1,267 @@
|
|||||||
# CoinTrader
|
# CoinTrader
|
||||||
|
|
||||||
Binance Futures 자동매매 봇. 복합 기술 지표와 ML 필터(LightGBM / MLX 신경망)를 결합하여 다중 심볼(XRP, TRX, DOGE 등) 선물 포지션을 동시에 자동 진입·청산하며, Discord로 실시간 알림을 전송합니다.
|
Binance Futures 자동매매 봇. 복합 기술 지표와 킬스위치로 XRPUSDT 선물 포지션을 자동 진입·청산하며, Discord로 실시간 알림을 전송합니다. 멀티심볼 아키텍처를 지원하지만, 현재 XRP만 운영 중입니다.
|
||||||
|
|
||||||
> **아키텍처 문서**: 코드 구조, 레이어별 역할, MLOps 파이프라인, 동작 시나리오를 상세히 설명한 [ARCHITECTURE.md](./ARCHITECTURE.md)를 참고하세요.
|
> **심볼 운영 이력**: SOL, DOGE, TRX는 파라미터 스윕에서 모든 ADX 수준에서 PF < 1.0으로, 현재 전략으로는 수익을 낼 수 없어 제외되었습니다 (2026-03-21). ML 필터도 기술 지표 기반 피처의 예측력 한계로 비활성화 상태 (`NO_ML_FILTER=true`).
|
||||||
|
|
||||||
|
> **이 봇은 실제 자산을 거래합니다.** 운영 전 반드시 Binance Testnet에서 충분히 검증하세요.
|
||||||
|
> 과거 수익이 미래 수익을 보장하지 않습니다. 투자 손실에 대한 책임은 사용자 본인에게 있습니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 주요 기능
|
## 주요 기능
|
||||||
|
|
||||||
- **복합 기술 지표 신호**: RSI, MACD 크로스, 볼린저 밴드, EMA 정/역배열, Stochastic RSI, 거래량 급증 — 가중치 합계 ≥ 3 시 진입
|
- **멀티심볼 동시 거래**: 심볼별 독립 봇 인스턴스를 병렬 실행, 공유 RiskManager로 글로벌 리스크 관리
|
||||||
- **ML 필터 (ONNX 우선 / LightGBM 폴백)**: 기술 지표 신호를 한 번 더 검증하여 오진입 차단. 우선순위: ONNX > LightGBM > 폴백(항상 허용)
|
- **복합 기술 지표 신호**: RSI, MACD, 볼린저 밴드, EMA, Stochastic RSI, ADX, 거래량 급증 — 가중치 합산 시스템
|
||||||
- **모델 핫리로드**: 캔들마다 모델 파일 mtime을 감지해 변경 시 자동 리로드 (봇 재시작 불필요)
|
- **ML 필터 (선택)**: LightGBM / ONNX 모델로 오진입 차단 (비활성화 가능)
|
||||||
- **멀티심볼 스트림**: XRP/BTC/ETH 3개 심볼을 단일 Combined WebSocket으로 수신, BTC·ETH 상관관계 피처 활용
|
- **ATR 기반 손절/익절**: 변동성에 따라 동적으로 SL/TP 계산, 환경변수로 배수 조절
|
||||||
- **23개 ML 피처**: XRP 기술 지표 13개 + BTC/ETH 수익률·상대강도 8개 + OI 변화율·펀딩비 2개 (캔들 마감 시 실시간 조회, 실패 시 0으로 폴백)
|
- **반대 시그널 재진입**: 보유 포지션과 반대 신호 발생 시 즉시 청산 후 재진입
|
||||||
- **점진적 OI 데이터 축적 (Upsert)**: 바이낸스 OI 히스토리 API는 최근 30일치만 제공. `fetch_history.py` 실행 시 기존 parquet의 `oi_change/funding_rate=0` 구간을 신규 값으로 채워 학습 데이터 품질을 점진적으로 개선
|
- **리스크 관리**: 동일 방향 포지션 제한, 일일 손실 한도(5%), 동적 증거금 비율
|
||||||
- **실시간 OI/펀딩비 조회**: 캔들 마감마다 `get_open_interest()` / `get_funding_rate()`를 비동기 병렬 조회하여 ML 피처에 전달. 이전 캔들 대비 OI 변화율로 변환하여 train-serve skew 해소
|
- **듀얼 레이어 킬스위치**: Fast Kill(8연속 순손실) + Slow Kill(15거래 PF<0.75) — 심볼별 독립 차단, 기존 포지션 청산은 정상 작동
|
||||||
- **ATR 기반 손절/익절**: 변동성에 따라 동적으로 SL/TP 계산 (1.5× / 3.0× ATR)
|
- **SL/TP 원자성 보장**: SL/TP 배치 3회 재시도 + 최종 실패 시 긴급 시장가 청산
|
||||||
- **Algo Order API 지원**: 계정 설정에 따라 STOP_MARKET/TAKE_PROFIT_MARKET 주문을 `/fapi/v1/algoOrder` 엔드포인트로 자동 전송 (오류 코드 -4120 대응)
|
- **실시간 TP/SL 감지**: Binance User Data Stream으로 즉시 감지
|
||||||
- **동적 증거금 비율**: 잔고 증가에 따라 선형 감소 (최대 50% → 최소 20%)
|
- **Graceful Shutdown**: SIGTERM/SIGINT 시 심볼별 오픈 주문 취소 후 정상 종료
|
||||||
- **반대 시그널 재진입**: 보유 포지션과 반대 신호 발생 시 즉시 청산 후 ML 필터 통과 시 반대 방향 재진입
|
- **Discord 알림**: 진입·청산·킬스위치 발동·긴급 청산·오류 이벤트 실시간 웹훅 알림
|
||||||
- **멀티심볼 동시 거래**: 심볼별 독립 봇 인스턴스를 `asyncio.gather()`로 병렬 실행. 공유 RiskManager로 글로벌 리스크 관리
|
- **모니터링 대시보드**: 거래 내역, 수익 통계, 차트를 웹에서 조회
|
||||||
- **리스크 관리**: 트레이드당 리스크 비율, 최대 포지션 수, 동일 방향 포지션 제한(기본 2개), 일일 손실 한도(5%) 제어
|
- **주간 전략 리포트**: 자동 성능 측정, 추이 추적, 킬스위치 모니터링, ML 재학습 시점 판단
|
||||||
- **포지션 복구**: 봇 재시작 시 기존 포지션 자동 감지 및 상태 복원
|
- **종목 비교 분석**: 심볼별 파라미터 sweep + Robust Monte Carlo 포지션 사이징
|
||||||
- **실시간 TP/SL 감지**: Binance User Data Stream으로 TP/SL 작동을 즉시 감지 (캔들 마감 대기 없음)
|
- **MTF Pullback Bot**: 1h MetaFilter(EMA50/200 + ADX) + 15m 3캔들 풀백 시퀀스 기반 Dry-run 봇 (OOS 검증용)
|
||||||
- **순수익(Net PnL) 기록**: 바이낸스 `realizedProfit - commission`으로 정확한 순수익 계산
|
|
||||||
- **Discord 상세 청산 알림**: 예상 수익 vs 실제 순수익 + 슬리피지/수수료 차이 표시
|
|
||||||
- **listenKey 자동 갱신**: 30분 keepalive + 네트워크 단절 시 자동 재연결. `stream.recv()` 기반으로 수신하며, 라이브러리 내부 에러 페이로드(`{"e":"error"}`) 감지 시 즉시 재연결하여 좀비 커넥션 방지
|
|
||||||
- **Discord 알림**: 진입·청산·오류 이벤트 실시간 웹훅 알림
|
|
||||||
- **CI/CD**: Jenkins + Gitea Container Registry 기반 Docker 이미지 자동 빌드·배포 (LXC 운영 서버 자동 적용)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# 봇 사용 가이드
|
||||||
|
|
||||||
|
봇을 설치하고 운영하려는 사용자를 위한 섹션입니다.
|
||||||
|
|
||||||
|
## 요구사항
|
||||||
|
|
||||||
|
- Python 3.11+ (또는 Docker)
|
||||||
|
- Binance Futures 계정 + API 키
|
||||||
|
- (선택) Discord 웹훅 URL
|
||||||
|
|
||||||
|
## 빠른 시작
|
||||||
|
|
||||||
|
### 1. 환경변수 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd cointrader
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` 파일을 열어 아래 필수 값을 채웁니다.
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 필수
|
||||||
|
BINANCE_API_KEY=your_api_key
|
||||||
|
BINANCE_API_SECRET=your_api_secret
|
||||||
|
SYMBOLS=XRPUSDT # 거래할 심볼 (쉼표 구분, 멀티심볼 지원)
|
||||||
|
|
||||||
|
# 권장
|
||||||
|
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||||
|
LEVERAGE=10
|
||||||
|
```
|
||||||
|
|
||||||
|
> 처음 사용 시 Binance Testnet에서 먼저 테스트하는 것을 권장합니다. `BINANCE_TESTNET_API_KEY`와 `BINANCE_TESTNET_API_SECRET`을 설정하세요.
|
||||||
|
|
||||||
|
### 2-A. Docker로 실행 (권장)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
로그 확인:
|
||||||
|
```bash
|
||||||
|
docker compose logs -f cointrader
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2-B. 로컬 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 정상 동작 확인
|
||||||
|
|
||||||
|
봇이 정상 실행되면 다음과 같은 로그가 출력됩니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
INFO | 기준 잔고 설정: 1000.00 USDT
|
||||||
|
INFO | [XRPUSDT] 봇 시작, 레버리지 10x | SL=2.0x TP=2.0x Signal≥3 ADX≥25.0 Vol≥2.5x
|
||||||
|
INFO | [XRPUSDT] 기존 포지션 없음 - 신규 진입 대기
|
||||||
|
INFO | [XRPUSDT] OI 히스토리 초기화: 5개
|
||||||
|
INFO | Kline WebSocket 연결 완료
|
||||||
|
```
|
||||||
|
|
||||||
|
Discord 웹훅을 설정했다면 진입/청산 시 실시간 알림을 받게 됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 매매 전략
|
||||||
|
|
||||||
|
### 기술 지표 신호 (15분봉)
|
||||||
|
|
||||||
|
| 지표 | 롱 조건 | 숏 조건 | 가중치 |
|
||||||
|
|------|---------|---------|--------|
|
||||||
|
| RSI (14) | < 35 | > 65 | 1 |
|
||||||
|
| MACD 크로스 | 골든크로스 | 데드크로스 | 2 |
|
||||||
|
| 볼린저 밴드 | 하단 이탈 | 상단 돌파 | 1 |
|
||||||
|
| EMA 정배열 (9/21/50) | 정배열 | 역배열 | 1 |
|
||||||
|
| Stochastic RSI | < 20 + K>D | > 80 + K<D | 1 |
|
||||||
|
| 거래량 | 20MA × `VOL_MULTIPLIER` 이상 시 신호 강화 | — | 보조 |
|
||||||
|
|
||||||
|
**진입 조건**: 가중치 합계 ≥ `SIGNAL_THRESHOLD` + (거래량 급증 또는 가중치 합계 ≥ `SIGNAL_THRESHOLD` + 1)
|
||||||
|
**ADX 필터**: ADX < `ADX_THRESHOLD` 시 횡보장으로 판단, 진입 차단
|
||||||
|
**손절/익절**: ATR × `ATR_SL_MULT` / ATR × `ATR_TP_MULT`
|
||||||
|
|
||||||
|
### 전략 파라미터 조절
|
||||||
|
|
||||||
|
환경변수로 전략 파라미터를 조절할 수 있습니다. 기본값은 Walk-Forward 백테스트 스윕 결과에서 선정된 값입니다.
|
||||||
|
|
||||||
|
**전역 기본값** (심볼별 오버라이드 없을 때 적용):
|
||||||
|
|
||||||
|
| 환경변수 | 기본값 | 설명 |
|
||||||
|
|---------|--------|------|
|
||||||
|
| `ATR_SL_MULT` | `2.0` | 손절 ATR 배수 |
|
||||||
|
| `ATR_TP_MULT` | `2.0` | 익절 ATR 배수 |
|
||||||
|
| `SIGNAL_THRESHOLD` | `3` | 진입을 위한 최소 가중치 점수 |
|
||||||
|
| `ADX_THRESHOLD` | `25` | ADX 횡보장 필터 (0=비활성) |
|
||||||
|
| `VOL_MULTIPLIER` | `2.5` | 거래량 급증 감지 배수 |
|
||||||
|
|
||||||
|
**심볼별 오버라이드**: `{환경변수}_{심볼}` 형태로 심볼마다 독립 설정 가능. 미설정 시 전역 기본값 사용.
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 현재 운영 설정 (2026-03-21)
|
||||||
|
ATR_SL_MULT_XRPUSDT=1.5
|
||||||
|
ATR_TP_MULT_XRPUSDT=4.0
|
||||||
|
ADX_THRESHOLD_XRPUSDT=25
|
||||||
|
```
|
||||||
|
|
||||||
|
> **제외된 심볼**: SOLUSDT(PF 0.00~0.83), DOGEUSDT(PF 0.70~0.83), TRXUSDT(PF 0.08) — 모든 파라미터 조합에서 PF < 1.0.
|
||||||
|
|
||||||
|
### ML 필터
|
||||||
|
|
||||||
|
ML 필터는 기술 지표 신호를 한 번 더 검증하여 오진입을 차단합니다. 기본적으로 **비활성화** 상태입니다.
|
||||||
|
|
||||||
|
- `NO_ML_FILTER=true` (기본값) — ML 없이 기술 지표만으로 운영
|
||||||
|
- `NO_ML_FILTER=false` — ML 필터 활성화 (모델 파일 필요)
|
||||||
|
|
||||||
|
> **비활성화 이유 (2026-03-21)**: Walk-Forward 백테스트에서 ML ON이 ML OFF보다 오히려 PF가 낮았습니다 (XRP: ML OFF PF 1.16 vs ML ON PF 0.71). Feature ablation 분석 결과, 모델 예측력의 대부분이 signal_strength/side 피처에 의존하며 (A→C AUC 드롭 0.08~0.09), 기술 지표 z-score만으로는 수수료를 이기는 알파를 만들 수 없었습니다. 오더북/청산 데이터 등 새로운 피처 소스에서 알파가 확인되면 재활성화 예정.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 리스크 관리
|
||||||
|
|
||||||
|
| 설정 | 기본값 | 설명 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `LEVERAGE` | `10` | 레버리지 배수 |
|
||||||
|
| `MAX_SAME_DIRECTION` | `2` | 동일 방향 최대 포지션 수 |
|
||||||
|
| `MARGIN_MAX_RATIO` | `0.50` | 최대 증거금 비율 (잔고 대비) |
|
||||||
|
| `MARGIN_MIN_RATIO` | `0.20` | 최소 증거금 비율 (잔고 대비) |
|
||||||
|
| `MARGIN_DECAY_RATE` | `0.0006` | 잔고 증가 시 증거금 비율 감소 속도 |
|
||||||
|
|
||||||
|
- **일일 손실 한도**: 기준 잔고의 5% 초과 시 당일 거래 중단 (단일 충격 방어)
|
||||||
|
- **듀얼 레이어 킬스위치**: 구조적 엣지 소실에 의한 점진적 계좌 우하향(Slow Bleed) 방어
|
||||||
|
- **동적 증거금**: 잔고가 늘어날수록 비율을 선형으로 줄여 과노출 방지
|
||||||
|
- **포지션 복구**: 봇 재시작 시 기존 포지션 자동 감지 및 상태 복원
|
||||||
|
|
||||||
|
### 킬스위치
|
||||||
|
|
||||||
|
일일 손실 한도는 단일 충격 방어용이지, 누적 승률 하락 방어용이 아닙니다. 매일 한도 근처까지 손실을 내고 멈추는 패턴이 반복되면 한 달 뒤 계좌의 30~40%가 조용히 증발합니다. 킬스위치는 이 Slow Bleed를 자동으로 차단합니다.
|
||||||
|
|
||||||
|
| 레이어 | 조건 | 방어 대상 |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| **Fast Kill** | 8연속 순손실 (net_pnl, 수수료 포함) | 급격한 전략 붕괴 |
|
||||||
|
| **Slow Kill** | 최근 15거래 Profit Factor < 0.75 | 점진적 엣지 소실 |
|
||||||
|
|
||||||
|
**동작 방식:**
|
||||||
|
- 심볼별 독립 제어: 한 심볼이 킬되어도 다른 심볼은 정상 운영
|
||||||
|
- 진입만 차단: 기존 포지션의 SL/TP 청산은 정상 작동 (물린 상태 방치 방지)
|
||||||
|
- 거래 이력 persist: `data/trade_history/{symbol}.jsonl`에 매 청산마다 기록
|
||||||
|
- 봇 재시작 시 소급 검증: 이력 파일에서 마지막 15건을 읽어 킬스위치 상태 복원
|
||||||
|
- 수동 해제: `.env`에 `RESET_KILL_SWITCH_{SYMBOL}=True` 추가 후 봇 재시작
|
||||||
|
|
||||||
|
**주간 리포트 모니터링:**
|
||||||
|
```
|
||||||
|
[킬스위치 모니터링]
|
||||||
|
XRP: 연속손실 2/8 | 15거래PF 1.42
|
||||||
|
```
|
||||||
|
|
||||||
|
| 환경변수 | 설명 |
|
||||||
|
|---------|------|
|
||||||
|
| `RESET_KILL_SWITCH_{SYMBOL}` | `True`로 설정 후 재시작하면 해당 심볼 킬스위치 해제. 해제 후 반드시 제거할 것 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대시보드
|
||||||
|
|
||||||
|
봇 로그를 실시간으로 파싱하여 거래 내역, 수익 통계, 차트를 웹에서 조회할 수 있습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
# 접속: http://<서버IP>:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
| 탭 | 내용 |
|
||||||
|
|----|------|
|
||||||
|
| **Overview** | 총 수익, 승률, 거래 수, 최대 수익/손실 KPI + 일별 PnL 차트 + 누적 수익 곡선 |
|
||||||
|
| **Trades** | 전체 거래 내역 — 진입/청산가, 방향, 레버리지, 기술 지표, SL/TP, 순익 상세 |
|
||||||
|
| **Chart** | 15분봉 가격 차트 + RSI 지표 + ADX 추세 강도 |
|
||||||
|
|
||||||
|
### API 엔드포인트
|
||||||
|
|
||||||
|
| 엔드포인트 | 설명 |
|
||||||
|
|-----------|------|
|
||||||
|
| `GET /api/position` | 현재 포지션 + 봇 상태 |
|
||||||
|
| `GET /api/trades` | 청산 거래 내역 (페이지네이션) |
|
||||||
|
| `GET /api/daily` | 일별 PnL 집계 |
|
||||||
|
| `GET /api/stats` | 전체 통계 (총 거래, 승률, 수수료 등) |
|
||||||
|
| `GET /api/candles` | 최근 캔들 + 기술 지표 |
|
||||||
|
| `GET /api/health` | 헬스 체크 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 환경변수 전체 레퍼런스
|
||||||
|
|
||||||
|
| 변수 | 기본값 | 필수 | 설명 |
|
||||||
|
|------|--------|:----:|------|
|
||||||
|
| `BINANCE_API_KEY` | — | ✅ | Binance API 키 |
|
||||||
|
| `BINANCE_API_SECRET` | — | ✅ | Binance API 시크릿 |
|
||||||
|
| `SYMBOLS` | `XRPUSDT` | | 거래 심볼 목록 (쉼표 구분) |
|
||||||
|
| `CORRELATION_SYMBOLS` | `BTCUSDT,ETHUSDT` | | 상관관계 심볼 (BTC/ETH 피처용) |
|
||||||
|
| `LEVERAGE` | `10` | | 레버리지 배수 |
|
||||||
|
| `MAX_SAME_DIRECTION` | `2` | | 동일 방향 최대 포지션 수 |
|
||||||
|
| `DISCORD_WEBHOOK_URL` | — | | Discord 웹훅 URL |
|
||||||
|
| `MARGIN_MAX_RATIO` | `0.50` | | 최대 증거금 비율 |
|
||||||
|
| `MARGIN_MIN_RATIO` | `0.20` | | 최소 증거금 비율 |
|
||||||
|
| `MARGIN_DECAY_RATE` | `0.0006` | | 잔고 증가 시 감소 속도 |
|
||||||
|
| `NO_ML_FILTER` | `true` | | ML 필터 비활성화 |
|
||||||
|
| `ML_THRESHOLD` | `0.55` | | ML 예측 확률 임계값 |
|
||||||
|
| `ATR_SL_MULT` | `2.0` | | 손절 ATR 배수 (전역 기본값) |
|
||||||
|
| `ATR_TP_MULT` | `2.0` | | 익절 ATR 배수 (전역 기본값) |
|
||||||
|
| `SIGNAL_THRESHOLD` | `3` | | 최소 가중치 점수 (전역 기본값) |
|
||||||
|
| `ADX_THRESHOLD` | `25` | | ADX 횡보장 필터 (전역 기본값, 0=비활성) |
|
||||||
|
| `VOL_MULTIPLIER` | `2.5` | | 거래량 급증 배수 (전역 기본값) |
|
||||||
|
| `ATR_SL_MULT_{SYMBOL}` | — | | 심볼별 손절 ATR 배수 오버라이드 |
|
||||||
|
| `ATR_TP_MULT_{SYMBOL}` | — | | 심볼별 익절 ATR 배수 오버라이드 |
|
||||||
|
| `SIGNAL_THRESHOLD_{SYMBOL}` | — | | 심볼별 최소 가중치 점수 오버라이드 |
|
||||||
|
| `ADX_THRESHOLD_{SYMBOL}` | — | | 심볼별 ADX 필터 오버라이드 |
|
||||||
|
| `VOL_MULTIPLIER_{SYMBOL}` | — | | 심볼별 거래량 배수 오버라이드 |
|
||||||
|
| `DASHBOARD_API_URL` | `http://10.1.10.24:8000` | | 대시보드 API 주소 (주간 리포트용) |
|
||||||
|
| `MARGIN_MAX_RATIO_{SYMBOL}` | — | | 심볼별 최대 증거금 비율 오버라이드 |
|
||||||
|
| `RESET_KILL_SWITCH_{SYMBOL}` | — | | `True`로 설정 후 재시작하면 킬스위치 해제 (해제 후 반드시 제거) |
|
||||||
|
| `BINANCE_TESTNET_API_KEY` | — | | Testnet API 키 |
|
||||||
|
| `BINANCE_TESTNET_API_SECRET` | — | | Testnet API 시크릿 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 개발 가이드
|
||||||
|
|
||||||
|
코드를 수정하거나 기능을 추가하려는 개발자를 위한 섹션입니다.
|
||||||
|
|
||||||
|
> **아키텍처 문서**: 5-레이어 구조, 데이터 흐름, MLOps 파이프라인, 동작 시나리오를 상세히 설명한 [ARCHITECTURE.md](./ARCHITECTURE.md)를 참고하세요.
|
||||||
|
|
||||||
## 프로젝트 구조
|
## 프로젝트 구조
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -47,6 +278,8 @@ cointrader/
|
|||||||
│ ├── mlx_filter.py # MLX 신경망 필터 (Apple Silicon GPU 학습 + ONNX export)
|
│ ├── mlx_filter.py # MLX 신경망 필터 (Apple Silicon GPU 학습 + ONNX export)
|
||||||
│ ├── label_builder.py # 학습 레이블 생성
|
│ ├── label_builder.py # 학습 레이블 생성
|
||||||
│ ├── dataset_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, 동일 방향 제한)
|
│ ├── risk_manager.py # 공유 리스크 관리 (asyncio.Lock, 동일 방향 제한)
|
||||||
│ ├── notifier.py # Discord 웹훅 알림
|
│ ├── notifier.py # Discord 웹훅 알림
|
||||||
│ └── logger_setup.py # Loguru 로거 설정
|
│ └── logger_setup.py # Loguru 로거 설정
|
||||||
@@ -56,246 +289,48 @@ cointrader/
|
|||||||
│ ├── train_mlx_model.py # MLX 신경망 학습 (Apple Silicon GPU)
|
│ ├── train_mlx_model.py # MLX 신경망 학습 (Apple Silicon GPU)
|
||||||
│ ├── train_and_deploy.sh # 전체 파이프라인 (--symbol / --all 지원)
|
│ ├── train_and_deploy.sh # 전체 파이프라인 (--symbol / --all 지원)
|
||||||
│ ├── tune_hyperparams.py # Optuna 하이퍼파라미터 자동 탐색 (--symbol 지원)
|
│ ├── tune_hyperparams.py # Optuna 하이퍼파라미터 자동 탐색 (--symbol 지원)
|
||||||
|
│ ├── strategy_sweep.py # 전략 파라미터 그리드 스윕 (324개 조합)
|
||||||
|
│ ├── compare_symbols.py # 종목 비교 백테스트 (심볼별 파라미터 sweep)
|
||||||
|
│ ├── position_sizing_analysis.py # Robust Monte Carlo 포지션 사이징 분석
|
||||||
|
│ ├── weekly_report.py # 주간 전략 리포트 (백테스트+킬스위치+대시보드API+추이+Discord)
|
||||||
|
│ ├── run_backtest.py # 단일 백테스트 CLI
|
||||||
│ ├── deploy_model.sh # 모델 파일 LXC 서버 전송 (--symbol 지원)
|
│ ├── deploy_model.sh # 모델 파일 LXC 서버 전송 (--symbol 지원)
|
||||||
│ └── run_tests.sh # 전체 테스트 실행
|
│ └── run_tests.sh # 전체 테스트 실행
|
||||||
├── dashboard/
|
├── dashboard/
|
||||||
│ ├── api/ # FastAPI 백엔드 (로그 파서 + REST API)
|
│ ├── api/ # FastAPI 백엔드 (로그 파서 + REST API)
|
||||||
│ └── ui/ # React 프론트엔드 (Vite + Recharts)
|
│ └── ui/ # React 프론트엔드 (Vite + Recharts)
|
||||||
├── models/ # 학습된 모델 저장 (심볼별 하위 디렉토리)
|
├── models/ # 학습된 모델 저장 (심볼별 하위 디렉토리)
|
||||||
│ ├── xrpusdt/ # models/xrpusdt/lgbm_filter.pkl
|
|
||||||
│ ├── trxusdt/ # models/trxusdt/lgbm_filter.pkl
|
|
||||||
│ └── dogeusdt/ # models/dogeusdt/lgbm_filter.pkl
|
|
||||||
├── data/ # 과거 데이터 캐시 (심볼별 하위 디렉토리)
|
├── data/ # 과거 데이터 캐시 (심볼별 하위 디렉토리)
|
||||||
│ ├── xrpusdt/ # data/xrpusdt/combined_15m.parquet
|
│ └── trade_history/ # 킬스위치용 실전 거래 이력 (심볼별 JSONL)
|
||||||
│ ├── trxusdt/ # data/trxusdt/combined_15m.parquet
|
├── results/
|
||||||
│ └── dogeusdt/ # data/dogeusdt/combined_15m.parquet
|
│ └── weekly/ # 주간 리포트 JSON 저장
|
||||||
├── logs/ # 로그 파일
|
├── logs/ # 로그 파일
|
||||||
├── docs/plans/ # 설계 문서 및 구현 플랜
|
├── docs/plans/ # 설계 문서 및 구현 플랜
|
||||||
├── tests/ # 테스트 코드
|
├── tests/ # 테스트 코드 (15파일, 138개 케이스)
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── Jenkinsfile
|
├── Jenkinsfile
|
||||||
└── requirements.txt
|
└── requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 개발 환경 설정
|
||||||
|
|
||||||
## 빠른 시작
|
|
||||||
|
|
||||||
### 1. 환경변수 설정
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 가상환경 생성 및 활성화
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
# 의존성 설치
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 환경변수 설정
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
`.env` 파일을 열어 아래 값을 채웁니다.
|
|
||||||
|
|
||||||
```env
|
|
||||||
BINANCE_API_KEY=your_api_key
|
|
||||||
BINANCE_API_SECRET=your_api_secret
|
|
||||||
SYMBOLS=XRPUSDT,TRXUSDT,DOGEUSDT
|
|
||||||
CORRELATION_SYMBOLS=BTCUSDT,ETHUSDT
|
|
||||||
LEVERAGE=10
|
|
||||||
MAX_SAME_DIRECTION=2
|
|
||||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 로컬 실행
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Docker Compose로 실행
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
로그 확인:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose logs -f cointrader
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ML 모델 학습
|
|
||||||
|
|
||||||
봇은 모델 파일이 없으면 ML 필터 없이 동작합니다. 최초 실행 전 또는 수동 재학습 시 아래 순서로 진행합니다.
|
|
||||||
|
|
||||||
### 전체 파이프라인 (권장)
|
|
||||||
|
|
||||||
맥미니에서 데이터 수집 → 학습 → LXC 배포까지 한 번에 실행합니다.
|
|
||||||
|
|
||||||
> **자동 분기**: `data/{symbol}/combined_15m.parquet`가 없으면 1년치(365일) 전체 수집, 있으면 35일치 Upsert로 자동 전환합니다. 서버 이전이나 데이터 유실 시에도 사람의 개입 없이 자동 복구됩니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 전체 심볼 학습 + 배포 (SYMBOLS 환경변수의 모든 심볼)
|
|
||||||
bash scripts/train_and_deploy.sh
|
|
||||||
|
|
||||||
# 단일 심볼만 학습 + 배포
|
|
||||||
bash scripts/train_and_deploy.sh --symbol TRXUSDT
|
|
||||||
|
|
||||||
# MLX GPU 학습 (단일 심볼)
|
|
||||||
bash scripts/train_and_deploy.sh mlx --symbol XRPUSDT
|
|
||||||
|
|
||||||
# LightGBM + Walk-Forward 3폴드
|
|
||||||
bash scripts/train_and_deploy.sh lgbm 3
|
|
||||||
|
|
||||||
# 학습만 (배포 없이)
|
|
||||||
bash scripts/train_and_deploy.sh lgbm 0
|
|
||||||
```
|
|
||||||
|
|
||||||
### 단계별 수동 실행
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 과거 데이터 수집 (단일 심볼 — 상관관계 심볼 자동 추가)
|
|
||||||
python scripts/fetch_history.py --symbol TRXUSDT --interval 15m --days 365
|
|
||||||
# → data/trxusdt/combined_15m.parquet 에 저장
|
|
||||||
|
|
||||||
# 1-alt. 명시적 심볼 지정 (기존 방식도 지원)
|
|
||||||
python scripts/fetch_history.py \
|
|
||||||
--symbols XRPUSDT BTCUSDT ETHUSDT \
|
|
||||||
--interval 15m \
|
|
||||||
--days 365 \
|
|
||||||
--output data/combined_15m.parquet
|
|
||||||
|
|
||||||
# 2-A. LightGBM 모델 학습 (심볼별)
|
|
||||||
python scripts/train_model.py --symbol TRXUSDT
|
|
||||||
# → models/trxusdt/lgbm_filter.pkl 에 저장
|
|
||||||
|
|
||||||
# 2-B. MLX 신경망 학습 (Apple Silicon GPU)
|
|
||||||
python scripts/train_mlx_model.py --data data/xrpusdt/combined_15m.parquet
|
|
||||||
|
|
||||||
# 3. LXC 서버에 모델 배포
|
|
||||||
bash scripts/deploy_model.sh --symbol XRPUSDT
|
|
||||||
bash scripts/deploy_model.sh mlx --symbol XRPUSDT
|
|
||||||
```
|
|
||||||
|
|
||||||
학습된 모델은 `models/{symbol}/lgbm_filter.pkl` (LightGBM) 또는 `models/{symbol}/mlx_filter.weights.onnx` (MLX) 에 저장됩니다. 심볼별 디렉토리가 없으면 `models/` 루트로 폴백합니다.
|
|
||||||
|
|
||||||
> **모델 핫리로드**: 봇이 실행 중일 때 모델 파일을 교체하면, 다음 캔들 마감 시 자동으로 감지해 리로드합니다. 봇 재시작이 필요 없습니다.
|
|
||||||
|
|
||||||
### 하이퍼파라미터 자동 튜닝 (Optuna)
|
|
||||||
|
|
||||||
봇 성능이 저하되거나 데이터가 충분히 축적되었을 때 Optuna로 최적 LightGBM 파라미터를 탐색합니다.
|
|
||||||
결과를 확인하고 직접 승인한 후 재학습에 반영하는 **수동 트리거** 방식입니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 심볼별 튜닝 (50 trials, 5폴드 Walk-Forward, ~30분)
|
|
||||||
python scripts/tune_hyperparams.py --symbol XRPUSDT
|
|
||||||
|
|
||||||
# 빠른 테스트 (10 trials, 3폴드, ~5분)
|
|
||||||
python scripts/tune_hyperparams.py --symbol TRXUSDT --trials 10 --folds 3
|
|
||||||
|
|
||||||
# 베이스라인 측정 없이 탐색만
|
|
||||||
python scripts/tune_hyperparams.py --symbol XRPUSDT --no-baseline
|
|
||||||
```
|
|
||||||
|
|
||||||
결과는 `models/{symbol}/tune_results_YYYYMMDD_HHMMSS.json`에 저장됩니다.
|
|
||||||
콘솔에 Best Params, 베이스라인 대비 개선폭, 폴드별 AUC를 출력하므로 직접 확인 후 판단하세요.
|
|
||||||
|
|
||||||
> **주의**: Optuna가 찾은 파라미터는 과적합 위험이 있습니다. Best Params를 `train_model.py`에 반영하기 전에 반드시 폴드별 AUC 분산과 개선폭을 검토하세요.
|
|
||||||
|
|
||||||
### Apple Silicon GPU 가속 학습 (M1/M2/M3/M4)
|
|
||||||
|
|
||||||
M 시리즈 맥에서는 MLX를 사용해 통합 GPU(Metal)로 학습할 수 있습니다.
|
|
||||||
|
|
||||||
> **설치**: `mlx`는 Apple Silicon 전용이며 `requirements.txt`에 포함되지 않습니다.
|
|
||||||
> 맥미니에서 별도 설치: `pip install mlx`
|
|
||||||
|
|
||||||
MLX로 학습한 모델은 ONNX 포맷으로 export되어 Linux 서버에서 `onnxruntime`으로 추론합니다.
|
|
||||||
|
|
||||||
> **참고**: LightGBM은 Apple Silicon GPU를 공식 지원하지 않습니다. MLX는 Apple이 만든 ML 프레임워크로 통합 GPU를 자동으로 활용합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 매매 전략
|
|
||||||
|
|
||||||
### 기술 지표 신호 (15분봉)
|
|
||||||
|
|
||||||
| 지표 | 롱 조건 | 숏 조건 | 가중치 |
|
|
||||||
|------|---------|---------|--------|
|
|
||||||
| RSI (14) | < 35 | > 65 | 1 |
|
|
||||||
| MACD 크로스 | 골든크로스 | 데드크로스 | 2 |
|
|
||||||
| 볼린저 밴드 | 하단 이탈 | 상단 돌파 | 1 |
|
|
||||||
| EMA 정배열 (9/21/50) | 정배열 | 역배열 | 1 |
|
|
||||||
| Stochastic RSI | < 20 + K>D | > 80 + K<D | 1 |
|
|
||||||
| 거래량 | 20MA × 1.5 이상 시 신호 강화 | — | 보조 |
|
|
||||||
|
|
||||||
**진입 조건**: 가중치 합계 ≥ 3 + (거래량 급증 또는 가중치 합계 ≥ 4)
|
|
||||||
**손절/익절**: ATR × 1.5 / ATR × 3.0 (리스크:리워드 = 1:2)
|
|
||||||
**ML 필터**: 예측 확률 ≥ 0.60 이어야 최종 진입
|
|
||||||
|
|
||||||
### 반대 시그널 재진입
|
|
||||||
|
|
||||||
보유 포지션과 반대 방향 신호가 발생하면:
|
|
||||||
1. 기존 포지션 즉시 청산 (미체결 SL/TP 주문 취소 포함)
|
|
||||||
2. ML 필터 통과 시 반대 방향으로 즉시 재진입
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CI/CD
|
|
||||||
|
|
||||||
`main` 브랜치에 푸시하면 Jenkins 파이프라인이 자동으로 실행됩니다.
|
|
||||||
|
|
||||||
1. **Notify Build Start** — Discord 빌드 시작 알림
|
|
||||||
2. **Git Clone from Gitea** — 소스 체크아웃
|
|
||||||
3. **Build Docker Image** — Docker 이미지 빌드 (`:{BUILD_NUMBER}` + `:latest` 태그)
|
|
||||||
4. **Push to Gitea Registry** — Gitea Container Registry(`10.1.10.28:3000`)에 푸시
|
|
||||||
5. **Deploy to Prod LXC** — 운영 LXC 서버(`10.1.10.24`)에 자동 배포 (`docker compose pull && up -d`)
|
|
||||||
6. **Cleanup** — 빌드 서버 로컬 이미지 정리
|
|
||||||
|
|
||||||
빌드 성공/실패 결과는 Discord로 자동 알림됩니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 대시보드
|
|
||||||
|
|
||||||
봇 로그를 실시간으로 파싱하여 거래 내역, 수익 통계, 차트를 웹에서 조회할 수 있는 모니터링 대시보드입니다.
|
|
||||||
|
|
||||||
### 기술 스택
|
|
||||||
|
|
||||||
- **프론트엔드**: React 18 + Vite + Recharts, Nginx 정적 서빙
|
|
||||||
- **백엔드**: FastAPI + SQLite, 로그 파서(5초 주기 폴링)
|
|
||||||
- **배포**: Docker Compose 3컨테이너 (`dashboard-ui`, `dashboard-api`, `cointrader`)
|
|
||||||
|
|
||||||
### 주요 화면
|
|
||||||
|
|
||||||
| 탭 | 내용 |
|
|
||||||
|----|------|
|
|
||||||
| **Overview** | 총 수익, 승률, 거래 수, 최대 수익/손실 KPI + 일별 PnL 차트 + 누적 수익 곡선 |
|
|
||||||
| **Trades** | 전체 거래 내역 — 진입/청산가, 방향, 레버리지, 기술 지표(RSI, MACD, ATR), SL/TP, 순익 상세 |
|
|
||||||
| **Chart** | XRP/USDT 15분봉 가격 차트 + RSI 지표 + ADX 추세 강도 |
|
|
||||||
|
|
||||||
### API 엔드포인트
|
|
||||||
|
|
||||||
| 엔드포인트 | 설명 |
|
|
||||||
|-----------|------|
|
|
||||||
| `GET /api/position` | 현재 포지션 + 봇 상태 |
|
|
||||||
| `GET /api/trades` | 청산 거래 내역 (페이지네이션) |
|
|
||||||
| `GET /api/daily` | 일별 PnL 집계 |
|
|
||||||
| `GET /api/stats` | 전체 통계 (총 거래, 승률, 수수료 등) |
|
|
||||||
| `GET /api/candles` | 최근 캔들 + 기술 지표 |
|
|
||||||
| `GET /api/health` | 헬스 체크 |
|
|
||||||
| `POST /api/reset` | DB 초기화 + 로그 파서 재시작 |
|
|
||||||
|
|
||||||
### 실행
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
대시보드는 `http://<서버IP>:8080`에서 접속할 수 있습니다. 봇 로그를 읽기 전용으로 마운트하여 봇 코드를 수정하지 않는 디커플드 설계입니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 테스트
|
## 테스트
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 전체 테스트
|
# 전체 테스트 (138개)
|
||||||
bash scripts/run_tests.sh
|
bash scripts/run_tests.sh
|
||||||
|
|
||||||
# 특정 키워드 필터
|
# 특정 키워드 필터
|
||||||
@@ -305,29 +340,134 @@ bash scripts/run_tests.sh -k bot
|
|||||||
pytest tests/ -v
|
pytest tests/ -v
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
모든 외부 API(Binance, Discord)는 `unittest.mock.AsyncMock`으로 대체되며, 비동기 테스트는 `@pytest.mark.asyncio`를 사용합니다.
|
||||||
|
|
||||||
## 환경변수 레퍼런스
|
## ML 모델 학습
|
||||||
|
|
||||||
| 변수 | 기본값 | 설명 |
|
봇은 모델 파일이 없으면 ML 필터 없이 동작합니다. 모델을 학습하려면:
|
||||||
|------|--------|------|
|
|
||||||
| `BINANCE_API_KEY` | — | Binance API 키 |
|
|
||||||
| `BINANCE_API_SECRET` | — | Binance API 시크릿 |
|
|
||||||
| `SYMBOLS` | `XRPUSDT` | 거래 심볼 목록 (쉼표 구분, 예: `XRPUSDT,TRXUSDT,DOGEUSDT`) |
|
|
||||||
| `CORRELATION_SYMBOLS` | `BTCUSDT,ETHUSDT` | 상관관계 심볼 (BTC/ETH 수익률·상대강도 피처용) |
|
|
||||||
| `LEVERAGE` | `10` | 레버리지 배수 |
|
|
||||||
| `MAX_SAME_DIRECTION` | `2` | 동일 방향 최대 포지션 수 (LONG 2개면 3번째 LONG 차단) |
|
|
||||||
| `DISCORD_WEBHOOK_URL` | — | Discord 웹훅 URL |
|
|
||||||
| `MARGIN_MAX_RATIO` | `0.50` | 최대 증거금 비율 (잔고 대비 50%) |
|
|
||||||
| `MARGIN_MIN_RATIO` | `0.20` | 최소 증거금 비율 (잔고 대비 20%) |
|
|
||||||
| `MARGIN_DECAY_RATE` | `0.0006` | 잔고 증가 시 증거금 비율 감소 속도 |
|
|
||||||
| `NO_ML_FILTER` | — | `true`/`1`/`yes` 설정 시 ML 필터 완전 비활성화 — 모델 로드 없이 모든 신호 허용 |
|
|
||||||
| `ML_THRESHOLD` | `0.55` | ML 필터 예측 확률 임계값 — 이 값 이상이어야 진입 허용 (기본값 0.55) |
|
|
||||||
|
|
||||||
---
|
### 전체 파이프라인 (권장)
|
||||||
|
|
||||||
## 주의사항
|
```bash
|
||||||
|
# 전체 심볼 학습 + 배포
|
||||||
|
bash scripts/train_and_deploy.sh
|
||||||
|
|
||||||
> **이 봇은 실제 자산을 거래합니다.** 운영 전 반드시 Binance Testnet에서 충분히 검증하세요.
|
# 단일 심볼만 학습 + 배포
|
||||||
> 과거 수익이 미래 수익을 보장하지 않습니다. 투자 손실에 대한 책임은 사용자 본인에게 있습니다.
|
bash scripts/train_and_deploy.sh --symbol TRXUSDT
|
||||||
> 성투기원합니다.
|
|
||||||
|
# MLX GPU 학습 (Apple Silicon, 단일 심볼)
|
||||||
|
bash scripts/train_and_deploy.sh mlx --symbol XRPUSDT
|
||||||
|
|
||||||
|
# 학습만 (배포 없이)
|
||||||
|
bash scripts/train_and_deploy.sh lgbm 0
|
||||||
|
```
|
||||||
|
|
||||||
|
> **자동 분기**: `data/{symbol}/combined_15m.parquet`가 없으면 1년치 전체 수집, 있으면 35일치 Upsert로 자동 전환.
|
||||||
|
|
||||||
|
### 단계별 수동 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 과거 데이터 수집
|
||||||
|
python scripts/fetch_history.py --symbol TRXUSDT --interval 15m --days 365
|
||||||
|
|
||||||
|
# 2. LightGBM 모델 학습
|
||||||
|
python scripts/train_model.py --symbol TRXUSDT
|
||||||
|
|
||||||
|
# 3. 서버에 모델 배포
|
||||||
|
bash scripts/deploy_model.sh --symbol TRXUSDT
|
||||||
|
```
|
||||||
|
|
||||||
|
> **모델 핫리로드**: 봇 실행 중 모델 파일을 교체하면, 다음 캔들 마감 시 자동으로 감지해 리로드합니다.
|
||||||
|
|
||||||
|
### 하이퍼파라미터 튜닝 (Optuna)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 심볼별 튜닝 (50 trials, 5폴드 Walk-Forward, ~30분)
|
||||||
|
python scripts/tune_hyperparams.py --symbol XRPUSDT
|
||||||
|
|
||||||
|
# 빠른 테스트 (10 trials, 3폴드, ~5분)
|
||||||
|
python scripts/tune_hyperparams.py --symbol TRXUSDT --trials 10 --folds 3
|
||||||
|
```
|
||||||
|
|
||||||
|
결과는 `models/{symbol}/tune_results_YYYYMMDD_HHMMSS.json`에 저장됩니다. Optuna가 찾은 파라미터는 과적합 위험이 있으므로 폴드별 AUC 분산과 개선폭을 반드시 검토하세요.
|
||||||
|
|
||||||
|
### Apple Silicon GPU 가속 (M1/M2/M3/M4)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install mlx # Apple Silicon 전용, requirements.txt에 미포함
|
||||||
|
bash scripts/train_and_deploy.sh mlx --symbol XRPUSDT
|
||||||
|
```
|
||||||
|
|
||||||
|
MLX로 학습한 모델은 ONNX 포맷으로 export되어 Linux 서버에서 `onnxruntime`으로 추론합니다.
|
||||||
|
|
||||||
|
## 전략 파라미터 스윕
|
||||||
|
|
||||||
|
기술 지표 전략의 최적 파라미터를 Walk-Forward 백테스트로 탐색합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 전체 스윕 (324개 조합, ~30분)
|
||||||
|
python scripts/strategy_sweep.py --symbols XRPUSDT --train-months 3 --test-months 1
|
||||||
|
```
|
||||||
|
|
||||||
|
5개 파라미터 × 3~4개 값 = 324개 조합을 순차 테스트:
|
||||||
|
|
||||||
|
| 파라미터 | 값 | 설명 |
|
||||||
|
|---------|------|------|
|
||||||
|
| `ATR_SL_MULT` | 1.0, 1.5, 2.0 | 손절 ATR 배수 |
|
||||||
|
| `ATR_TP_MULT` | 2.0, 3.0, 4.0 | 익절 ATR 배수 |
|
||||||
|
| `SIGNAL_THRESHOLD` | 3, 4, 5 | 최소 가중치 점수 |
|
||||||
|
| `ADX_THRESHOLD` | 0, 20, 25, 30 | ADX 필터 |
|
||||||
|
| `VOL_MULTIPLIER` | 1.5, 2.0, 2.5 | 거래량 급증 배수 |
|
||||||
|
|
||||||
|
> **핵심 발견**: ADX ≥ 25 필터가 가장 영향력 있는 파라미터. 횡보장 노이즈 신호를 효과적으로 필터링.
|
||||||
|
|
||||||
|
## 주간 전략 리포트
|
||||||
|
|
||||||
|
매주 자동으로 전략 성능을 측정하고 Discord로 리포트를 전송합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 수동 실행 (데이터 수집 스킵)
|
||||||
|
python scripts/weekly_report.py --skip-fetch
|
||||||
|
|
||||||
|
# 전체 실행 (데이터 수집 포함)
|
||||||
|
python scripts/weekly_report.py
|
||||||
|
|
||||||
|
# 특정 날짜 리포트
|
||||||
|
python scripts/weekly_report.py --date 2026-03-07
|
||||||
|
```
|
||||||
|
|
||||||
|
**리포트 내용:**
|
||||||
|
- Walk-Forward 백테스트 성능 (심볼별 PF/승률/MDD)
|
||||||
|
- 운영 대시보드 API에서 실전 트레이드 통계 조회 (거래 수/순수익/승률)
|
||||||
|
- 성능 추이 (최근 4주 PF/승률/MDD 변화)
|
||||||
|
- ML 재도전 체크리스트 (3개 조건 자동 판단)
|
||||||
|
- PF < 1.0 시 파라미터 스윕 대안 제시
|
||||||
|
|
||||||
|
> 실전 데이터는 운영 대시보드 API(`GET /api/trades`, `GET /api/stats`)에서 조회합니다. `DASHBOARD_API_URL` 환경변수로 주소를 설정하세요.
|
||||||
|
|
||||||
|
**크론탭 설정:**
|
||||||
|
```bash
|
||||||
|
# 매주 일요일 새벽 3시 KST
|
||||||
|
0 18 * * 6 cd /app && python scripts/weekly_report.py >> logs/cron.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
`main` 브랜치에 푸시하면 Jenkins 파이프라인이 자동 실행됩니다.
|
||||||
|
|
||||||
|
1. **Notify Build Start** — Discord 빌드 시작 알림
|
||||||
|
2. **Git Clone from Gitea** — 소스 체크아웃
|
||||||
|
3. **Build Docker Image** — Docker 이미지 빌드 (`:{BUILD_NUMBER}` + `:latest`)
|
||||||
|
4. **Push to Gitea Registry** — Container Registry에 푸시
|
||||||
|
5. **Deploy to Prod** — 운영 서버에 자동 배포 (`docker compose pull && up -d`)
|
||||||
|
6. **Cleanup** — 로컬 이미지 정리
|
||||||
|
|
||||||
|
빌드 성공/실패 결과는 Discord로 자동 알림됩니다.
|
||||||
|
|
||||||
|
## 설계 문서
|
||||||
|
|
||||||
|
모든 설계 문서와 구현 계획은 `docs/plans/`에 저장됩니다.
|
||||||
|
|
||||||
|
- `YYYY-MM-DD-feature-name-design.md` — 설계 결정 문서
|
||||||
|
- `YYYY-MM-DD-feature-name-plan.md` — 단계별 구현 계획
|
||||||
|
- [ARCHITECTURE.md](./ARCHITECTURE.md) — 전체 아키텍처 (5-레이어, MLOps 파이프라인, 동작 시나리오, 테스트 커버리지)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.12-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN pip install --no-cache-dir fastapi uvicorn
|
RUN pip install --no-cache-dir fastapi uvicorn
|
||||||
COPY log_parser.py .
|
COPY log_parser.py .
|
||||||
|
|||||||
@@ -5,27 +5,31 @@ dashboard_api.py — 멀티심볼 대시보드 API
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
from fastapi import FastAPI, Query
|
from fastapi import FastAPI, Query, Header, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pathlib import Path
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db")
|
DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db")
|
||||||
|
PARSER_PID_FILE = os.environ.get("PARSER_PID_FILE", "/tmp/parser.pid")
|
||||||
|
DASHBOARD_RESET_KEY = os.environ.get("DASHBOARD_RESET_KEY", "")
|
||||||
|
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "").split(",") if os.environ.get("CORS_ORIGINS") else ["*"]
|
||||||
|
|
||||||
app = FastAPI(title="Trading Dashboard API")
|
app = FastAPI(title="Trading Dashboard API")
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=CORS_ORIGINS,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def get_db():
|
def get_db():
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
try:
|
try:
|
||||||
yield conn
|
yield conn
|
||||||
finally:
|
finally:
|
||||||
@@ -37,9 +41,9 @@ def get_symbols():
|
|||||||
"""활성 심볼 목록 반환."""
|
"""활성 심볼 목록 반환."""
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
"SELECT key FROM bot_status WHERE key LIKE '%:last_start'"
|
"SELECT DISTINCT key FROM bot_status WHERE key LIKE '%:%'"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
symbols = [r["key"].split(":")[0] for r in rows]
|
symbols = {r["key"].split(":")[0] for r in rows}
|
||||||
return {"symbols": sorted(symbols)}
|
return {"symbols": sorted(symbols)}
|
||||||
|
|
||||||
|
|
||||||
@@ -64,7 +68,7 @@ def get_position(symbol: Optional[str] = None):
|
|||||||
def get_trades(
|
def get_trades(
|
||||||
symbol: Optional[str] = None,
|
symbol: Optional[str] = None,
|
||||||
limit: int = Query(50, ge=1, le=500),
|
limit: int = Query(50, ge=1, le=500),
|
||||||
offset: int = 0,
|
offset: int = Query(0, ge=0),
|
||||||
):
|
):
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
if symbol:
|
if symbol:
|
||||||
@@ -166,28 +170,28 @@ def health():
|
|||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
cnt = db.execute("SELECT COUNT(*) as c FROM candles").fetchone()["c"]
|
cnt = db.execute("SELECT COUNT(*) as c FROM candles").fetchone()["c"]
|
||||||
return {"status": "ok", "candles_count": cnt}
|
return {"status": "ok", "candles_count": cnt}
|
||||||
except Exception as e:
|
except Exception:
|
||||||
return {"status": "error", "detail": str(e)}
|
return {"status": "error", "detail": "database unavailable"}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/reset")
|
@app.post("/api/reset")
|
||||||
def reset_db():
|
def reset_db(x_api_key: Optional[str] = Header(None)):
|
||||||
|
"""DB 초기화 + 파서에 SIGHUP으로 재파싱 요청."""
|
||||||
|
# C1: API key 인증 (DASHBOARD_RESET_KEY가 설정된 경우)
|
||||||
|
if DASHBOARD_RESET_KEY and x_api_key != DASHBOARD_RESET_KEY:
|
||||||
|
raise HTTPException(status_code=403, detail="invalid api key")
|
||||||
|
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
for table in ["trades", "daily_pnl", "parse_state", "bot_status", "candles"]:
|
for table in ["trades", "daily_pnl", "parse_state", "bot_status", "candles"]:
|
||||||
db.execute(f"DELETE FROM {table}")
|
db.execute(f"DELETE FROM {table}")
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
import subprocess, signal
|
# C2: PID file + SIGHUP으로 파서에 재파싱 요청 (프로세스 재시작 불필요)
|
||||||
for pid_str in os.listdir("/proc") if os.path.isdir("/proc") else []:
|
|
||||||
if not pid_str.isdigit():
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
with open(f"/proc/{pid_str}/cmdline", "r") as f:
|
with open(PARSER_PID_FILE) as f:
|
||||||
cmdline = f.read()
|
pid = int(f.read().strip())
|
||||||
if "log_parser.py" in cmdline and str(os.getpid()) != pid_str:
|
os.kill(pid, signal.SIGHUP)
|
||||||
os.kill(int(pid_str), signal.SIGTERM)
|
except (FileNotFoundError, ValueError, ProcessLookupError, OSError):
|
||||||
except (FileNotFoundError, PermissionError, ProcessLookupError, OSError):
|
|
||||||
pass
|
pass
|
||||||
subprocess.Popen(["python", "log_parser.py"])
|
|
||||||
|
|
||||||
return {"status": "ok", "message": "DB 초기화 완료, 파서 재시작됨"}
|
return {"status": "ok", "message": "DB 초기화 완료, 파서 재파싱 시작"}
|
||||||
|
|||||||
@@ -6,13 +6,29 @@ echo "LOG_DIR=${LOG_DIR:-/app/logs}"
|
|||||||
echo "DB_PATH=${DB_PATH:-/app/data/dashboard.db}"
|
echo "DB_PATH=${DB_PATH:-/app/data/dashboard.db}"
|
||||||
|
|
||||||
# 로그 파서를 백그라운드로 실행
|
# 로그 파서를 백그라운드로 실행
|
||||||
python log_parser.py &
|
python -u log_parser.py &
|
||||||
PARSER_PID=$!
|
PARSER_PID=$!
|
||||||
echo "Log parser started (PID: $PARSER_PID)"
|
echo "Log parser started (PID: $PARSER_PID)"
|
||||||
|
|
||||||
# 파서가 기존 로그를 처리할 시간 부여
|
# 파서가 기존 로그를 처리할 시간 부여
|
||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
# FastAPI 서버 실행
|
# SIGTERM/SIGINT → 파서에도 전달 후 대기
|
||||||
|
cleanup() {
|
||||||
|
echo "Shutting down..."
|
||||||
|
kill -TERM "$PARSER_PID" 2>/dev/null
|
||||||
|
wait "$PARSER_PID" 2>/dev/null
|
||||||
|
kill -TERM "$UVICORN_PID" 2>/dev/null
|
||||||
|
wait "$UVICORN_PID" 2>/dev/null
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
trap cleanup SIGTERM SIGINT
|
||||||
|
|
||||||
|
# FastAPI 서버를 백그라운드로 실행 (exec 대신 — 셸이 PID 1을 유지해야 signal forwarding 가능)
|
||||||
echo "Starting API server on :8080"
|
echo "Starting API server on :8080"
|
||||||
exec uvicorn dashboard_api:app --host 0.0.0.0 --port 8080 --log-level info
|
uvicorn dashboard_api:app --host 0.0.0.0 --port 8080 --log-level info &
|
||||||
|
UVICORN_PID=$!
|
||||||
|
|
||||||
|
# 자식 프로세스 중 하나라도 종료되면 전체 종료
|
||||||
|
wait -n "$PARSER_PID" "$UVICORN_PID" 2>/dev/null
|
||||||
|
cleanup
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import time
|
|||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import threading
|
import signal
|
||||||
|
import sys
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -19,12 +20,13 @@ from pathlib import Path
|
|||||||
LOG_DIR = os.environ.get("LOG_DIR", "/app/logs")
|
LOG_DIR = os.environ.get("LOG_DIR", "/app/logs")
|
||||||
DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db")
|
DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db")
|
||||||
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "5")) # 초
|
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "5")) # 초
|
||||||
|
PID_FILE = os.environ.get("PARSER_PID_FILE", "/tmp/parser.pid")
|
||||||
|
|
||||||
# ── 정규식 패턴 (멀티심볼 [SYMBOL] 프리픽스 포함) ─────────────────
|
# ── 정규식 패턴 (멀티심볼 [SYMBOL] 프리픽스 포함) ─────────────────
|
||||||
PATTERNS = {
|
PATTERNS = {
|
||||||
"signal": re.compile(
|
"signal": re.compile(
|
||||||
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||||
r".*\[(?P<symbol>\w+)\] 신호: (?P<signal>\w+) \| 현재가: (?P<price>[\d.]+) USDT"
|
r".*\[(?P<symbol>\w+)\] 신호: (?P<signal>\w+) \|.*현재가: (?P<price>[\d.]+)"
|
||||||
),
|
),
|
||||||
|
|
||||||
"adx": re.compile(
|
"adx": re.compile(
|
||||||
@@ -68,6 +70,12 @@ PATTERNS = {
|
|||||||
r".*\[(?P<symbol>\w+)\] 오늘 누적 PnL: (?P<pnl>[+\-\d.]+) USDT"
|
r".*\[(?P<symbol>\w+)\] 오늘 누적 PnL: (?P<pnl>[+\-\d.]+) USDT"
|
||||||
),
|
),
|
||||||
|
|
||||||
|
"position_monitor": re.compile(
|
||||||
|
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||||
|
r".*\[(?P<symbol>\w+)\] 포지션 모니터 \| (?P<direction>\w+) \| "
|
||||||
|
r"현재가=(?P<price>[\d.]+) \| PnL=(?P<pnl>[+\-\d.]+) USDT \((?P<pnl_pct>[+\-\d.]+)%\)"
|
||||||
|
),
|
||||||
|
|
||||||
"bot_start": re.compile(
|
"bot_start": re.compile(
|
||||||
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||||
r".*\[(?P<symbol>\w+)\] 봇 시작, 레버리지 (?P<leverage>\d+)x"
|
r".*\[(?P<symbol>\w+)\] 봇 시작, 레버리지 (?P<leverage>\d+)x"
|
||||||
@@ -88,25 +96,30 @@ PATTERNS = {
|
|||||||
class LogParser:
|
class LogParser:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
self.conn = sqlite3.connect(DB_PATH)
|
self.conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
self.conn.row_factory = sqlite3.Row
|
self.conn.row_factory = sqlite3.Row
|
||||||
self.conn.execute("PRAGMA journal_mode=WAL")
|
self.conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
self.conn.execute("PRAGMA busy_timeout=5000")
|
||||||
self._init_db()
|
self._init_db()
|
||||||
|
|
||||||
self._file_positions = {}
|
self._file_positions = {}
|
||||||
self._current_positions = {} # {symbol: position_dict}
|
self._current_positions = {} # {symbol: position_dict}
|
||||||
self._pending_candles = {} # {symbol: {ts_key: {data}}}
|
self._pending_candles = {} # {symbol: {ts_key: {data}}}
|
||||||
self._balance = 0
|
self._balance = 0
|
||||||
|
self._shutdown = False
|
||||||
|
self._dirty = False # batch commit 플래그
|
||||||
|
|
||||||
|
# PID 파일 기록
|
||||||
|
with open(PID_FILE, "w") as f:
|
||||||
|
f.write(str(os.getpid()))
|
||||||
|
|
||||||
|
# 시그널 핸들러
|
||||||
|
signal.signal(signal.SIGTERM, self._handle_sigterm)
|
||||||
|
signal.signal(signal.SIGHUP, self._handle_sighup)
|
||||||
|
|
||||||
def _init_db(self):
|
def _init_db(self):
|
||||||
self.conn.executescript("""
|
self.conn.executescript("""
|
||||||
DROP TABLE IF EXISTS trades;
|
CREATE TABLE IF NOT EXISTS trades (
|
||||||
DROP TABLE IF EXISTS candles;
|
|
||||||
DROP TABLE IF EXISTS daily_pnl;
|
|
||||||
DROP TABLE IF EXISTS bot_status;
|
|
||||||
DROP TABLE IF EXISTS parse_state;
|
|
||||||
|
|
||||||
CREATE TABLE trades (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
symbol TEXT NOT NULL,
|
symbol TEXT NOT NULL,
|
||||||
direction TEXT NOT NULL,
|
direction TEXT NOT NULL,
|
||||||
@@ -128,10 +141,11 @@ class LogParser:
|
|||||||
net_pnl REAL,
|
net_pnl REAL,
|
||||||
status TEXT NOT NULL DEFAULT 'OPEN',
|
status TEXT NOT NULL DEFAULT 'OPEN',
|
||||||
close_reason TEXT,
|
close_reason TEXT,
|
||||||
extra TEXT
|
extra TEXT,
|
||||||
|
UNIQUE(symbol, entry_time, direction)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE candles (
|
CREATE TABLE IF NOT EXISTS candles (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
symbol TEXT NOT NULL,
|
symbol TEXT NOT NULL,
|
||||||
ts TEXT NOT NULL,
|
ts TEXT NOT NULL,
|
||||||
@@ -144,7 +158,7 @@ class LogParser:
|
|||||||
UNIQUE(symbol, ts)
|
UNIQUE(symbol, ts)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE daily_pnl (
|
CREATE TABLE IF NOT EXISTS daily_pnl (
|
||||||
symbol TEXT NOT NULL,
|
symbol TEXT NOT NULL,
|
||||||
date TEXT NOT NULL,
|
date TEXT NOT NULL,
|
||||||
cumulative_pnl REAL DEFAULT 0,
|
cumulative_pnl REAL DEFAULT 0,
|
||||||
@@ -155,24 +169,46 @@ class LogParser:
|
|||||||
PRIMARY KEY(symbol, date)
|
PRIMARY KEY(symbol, date)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE bot_status (
|
CREATE TABLE IF NOT EXISTS bot_status (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT,
|
value TEXT,
|
||||||
updated_at TEXT
|
updated_at TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE parse_state (
|
CREATE TABLE IF NOT EXISTS parse_state (
|
||||||
filepath TEXT PRIMARY KEY,
|
filepath TEXT PRIMARY KEY,
|
||||||
position INTEGER DEFAULT 0
|
position INTEGER DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_candles_symbol_ts ON candles(symbol, ts);
|
CREATE INDEX IF NOT EXISTS idx_candles_symbol_ts ON candles(symbol, ts);
|
||||||
CREATE INDEX idx_trades_status ON trades(status);
|
CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status);
|
||||||
CREATE INDEX idx_trades_symbol ON trades(symbol);
|
CREATE INDEX IF NOT EXISTS idx_trades_symbol ON trades(symbol);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_trades_unique
|
||||||
|
ON trades(symbol, entry_time, direction);
|
||||||
""")
|
""")
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
self._migrate_deduplicate()
|
||||||
self._load_state()
|
self._load_state()
|
||||||
|
|
||||||
|
def _migrate_deduplicate(self):
|
||||||
|
"""기존 DB에 중복 trades가 있으면 제거 (가장 오래된 id만 유지)."""
|
||||||
|
dupes = self.conn.execute("""
|
||||||
|
SELECT symbol, entry_time, direction, MIN(id) AS keep_id, COUNT(*) AS cnt
|
||||||
|
FROM trades
|
||||||
|
GROUP BY symbol, entry_time, direction
|
||||||
|
HAVING cnt > 1
|
||||||
|
""").fetchall()
|
||||||
|
if not dupes:
|
||||||
|
return
|
||||||
|
for row in dupes:
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM trades WHERE symbol=? AND entry_time=? AND direction=? AND id!=?",
|
||||||
|
(row["symbol"], row["entry_time"], row["direction"], row["keep_id"]),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
total = sum(r["cnt"] - 1 for r in dupes)
|
||||||
|
print(f"[LogParser] 마이그레이션: 중복 trades {total}건 제거")
|
||||||
|
|
||||||
def _load_state(self):
|
def _load_state(self):
|
||||||
rows = self.conn.execute("SELECT filepath, position FROM parse_state").fetchall()
|
rows = self.conn.execute("SELECT filepath, position FROM parse_state").fetchall()
|
||||||
self._file_positions = {r["filepath"]: r["position"] for r in rows}
|
self._file_positions = {r["filepath"]: r["position"] for r in rows}
|
||||||
@@ -192,7 +228,47 @@ class LogParser:
|
|||||||
"ON CONFLICT(filepath) DO UPDATE SET position=?",
|
"ON CONFLICT(filepath) DO UPDATE SET position=?",
|
||||||
(filepath, pos, pos)
|
(filepath, pos, pos)
|
||||||
)
|
)
|
||||||
|
self._dirty = True
|
||||||
|
|
||||||
|
def _handle_sigterm(self, signum, frame):
|
||||||
|
"""Graceful shutdown — DB 커넥션을 안전하게 닫음."""
|
||||||
|
print("[LogParser] SIGTERM 수신 — 종료")
|
||||||
|
self._shutdown = True
|
||||||
|
try:
|
||||||
|
if self._dirty:
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
self.conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
os.unlink(PID_FILE)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
def _handle_sighup(self, signum, frame):
|
||||||
|
"""SIGHUP → 파싱 상태 초기화, 처음부터 재파싱."""
|
||||||
|
print("[LogParser] SIGHUP 수신 — 상태 초기화, 재파싱 시작")
|
||||||
|
self._file_positions = {}
|
||||||
|
self._current_positions = {}
|
||||||
|
self._pending_candles = {}
|
||||||
|
self.conn.execute("DELETE FROM parse_state")
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def _batch_commit(self):
|
||||||
|
"""배치 커밋 — _dirty 플래그가 설정된 경우에만 커밋."""
|
||||||
|
if self._dirty:
|
||||||
|
self.conn.commit()
|
||||||
|
self._dirty = False
|
||||||
|
|
||||||
|
def _cleanup_pending_candles(self, max_per_symbol=50):
|
||||||
|
"""오래된 pending candle 데이터 정리 (I4: 메모리 누적 방지)."""
|
||||||
|
for symbol in list(self._pending_candles):
|
||||||
|
pending = self._pending_candles[symbol]
|
||||||
|
if len(pending) > max_per_symbol:
|
||||||
|
keys = sorted(pending.keys())
|
||||||
|
for k in keys[:-max_per_symbol]:
|
||||||
|
del pending[k]
|
||||||
|
|
||||||
def _set_status(self, key, value):
|
def _set_status(self, key, value):
|
||||||
now = datetime.now().isoformat()
|
now = datetime.now().isoformat()
|
||||||
@@ -201,12 +277,12 @@ class LogParser:
|
|||||||
"ON CONFLICT(key) DO UPDATE SET value=?, updated_at=?",
|
"ON CONFLICT(key) DO UPDATE SET value=?, updated_at=?",
|
||||||
(key, str(value), now, str(value), now)
|
(key, str(value), now, str(value), now)
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self._dirty = True
|
||||||
|
|
||||||
# ── 메인 루프 ────────────────────────────────────────────────
|
# ── 메인 루프 ────────────────────────────────────────────────
|
||||||
def run(self):
|
def run(self):
|
||||||
print(f"[LogParser] 시작 — LOG_DIR={LOG_DIR}, DB={DB_PATH}, 폴링={POLL_INTERVAL}s")
|
print(f"[LogParser] 시작 — LOG_DIR={LOG_DIR}, DB={DB_PATH}, 폴링={POLL_INTERVAL}s")
|
||||||
while True:
|
while not self._shutdown:
|
||||||
try:
|
try:
|
||||||
self._scan_logs()
|
self._scan_logs()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -214,12 +290,11 @@ class LogParser:
|
|||||||
time.sleep(POLL_INTERVAL)
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
def _scan_logs(self):
|
def _scan_logs(self):
|
||||||
log_files = sorted(glob.glob(os.path.join(LOG_DIR, "bot_*.log")))
|
log_files = sorted(set(glob.glob(os.path.join(LOG_DIR, "bot*.log"))))
|
||||||
main_log = os.path.join(LOG_DIR, "bot.log")
|
|
||||||
if os.path.exists(main_log):
|
|
||||||
log_files.append(main_log)
|
|
||||||
for filepath in log_files:
|
for filepath in log_files:
|
||||||
self._parse_file(filepath)
|
self._parse_file(filepath)
|
||||||
|
self._batch_commit()
|
||||||
|
self._cleanup_pending_candles()
|
||||||
|
|
||||||
def _parse_file(self, filepath):
|
def _parse_file(self, filepath):
|
||||||
last_pos = self._file_positions.get(filepath, 0)
|
last_pos = self._file_positions.get(filepath, 0)
|
||||||
@@ -272,6 +347,15 @@ class LogParser:
|
|||||||
self._set_status("ml_threshold", m.group("threshold"))
|
self._set_status("ml_threshold", m.group("threshold"))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 포지션 모니터 (5분 간격 현재가·PnL 갱신)
|
||||||
|
m = PATTERNS["position_monitor"].search(line)
|
||||||
|
if m:
|
||||||
|
symbol = m.group("symbol")
|
||||||
|
self._set_status(f"{symbol}:current_price", m.group("price"))
|
||||||
|
self._set_status(f"{symbol}:unrealized_pnl", m.group("pnl"))
|
||||||
|
self._set_status(f"{symbol}:unrealized_pnl_pct", m.group("pnl_pct"))
|
||||||
|
return
|
||||||
|
|
||||||
# 포지션 복구 (재시작 시)
|
# 포지션 복구 (재시작 시)
|
||||||
m = PATTERNS["position_recover"].search(line)
|
m = PATTERNS["position_recover"].search(line)
|
||||||
if m:
|
if m:
|
||||||
@@ -355,7 +439,7 @@ class LogParser:
|
|||||||
price, signal,
|
price, signal,
|
||||||
extra.get("adx"), extra.get("oi"), extra.get("oi_change"), extra.get("funding")),
|
extra.get("adx"), extra.get("oi"), extra.get("oi_change"), extra.get("funding")),
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self._dirty = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[LogParser] 캔들 저장 에러: {e}")
|
print(f"[LogParser] 캔들 저장 에러: {e}")
|
||||||
return
|
return
|
||||||
@@ -387,7 +471,7 @@ class LogParser:
|
|||||||
ON CONFLICT(symbol, date) DO UPDATE SET cumulative_pnl=?, last_updated=?""",
|
ON CONFLICT(symbol, date) DO UPDATE SET cumulative_pnl=?, last_updated=?""",
|
||||||
(symbol, day, pnl, ts, pnl, ts)
|
(symbol, day, pnl, ts, pnl, ts)
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self._dirty = True
|
||||||
self._set_status(f"{symbol}:daily_pnl", str(pnl))
|
self._set_status(f"{symbol}:daily_pnl", str(pnl))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -396,7 +480,11 @@ class LogParser:
|
|||||||
leverage=None, sl=None, tp=None, is_recovery=False,
|
leverage=None, sl=None, tp=None, is_recovery=False,
|
||||||
rsi=None, macd_hist=None, atr=None):
|
rsi=None, macd_hist=None, atr=None):
|
||||||
if leverage is None:
|
if leverage is None:
|
||||||
leverage = 10
|
row = self.conn.execute(
|
||||||
|
"SELECT value FROM bot_status WHERE key=?",
|
||||||
|
(f"{symbol}:leverage",),
|
||||||
|
).fetchone()
|
||||||
|
leverage = int(row["value"]) if row else 10
|
||||||
|
|
||||||
# 중복 체크 — 같은 심볼+방향의 OPEN 포지션이 이미 있으면 스킵
|
# 중복 체크 — 같은 심볼+방향의 OPEN 포지션이 이미 있으면 스킵
|
||||||
current = self._current_positions.get(symbol)
|
current = self._current_positions.get(symbol)
|
||||||
@@ -417,7 +505,7 @@ class LogParser:
|
|||||||
return
|
return
|
||||||
|
|
||||||
cur = self.conn.execute(
|
cur = self.conn.execute(
|
||||||
"""INSERT INTO trades(symbol, direction, entry_time, entry_price,
|
"""INSERT OR IGNORE INTO trades(symbol, direction, entry_time, entry_price,
|
||||||
quantity, leverage, sl, tp, status, extra, rsi, macd_hist, atr)
|
quantity, leverage, sl, tp, status, extra, rsi, macd_hist, atr)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
(symbol, direction, ts,
|
(symbol, direction, ts,
|
||||||
@@ -425,7 +513,7 @@ class LogParser:
|
|||||||
json.dumps({"recovery": is_recovery}),
|
json.dumps({"recovery": is_recovery}),
|
||||||
rsi, macd_hist, atr),
|
rsi, macd_hist, atr),
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self._dirty = True
|
||||||
self._current_positions[symbol] = {
|
self._current_positions[symbol] = {
|
||||||
"id": cur.lastrowid,
|
"id": cur.lastrowid,
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
@@ -447,6 +535,7 @@ class LogParser:
|
|||||||
|
|
||||||
if not open_trades:
|
if not open_trades:
|
||||||
print(f"[LogParser] 경고: {symbol} 청산 감지했으나 열린 포지션 없음")
|
print(f"[LogParser] 경고: {symbol} 청산 감지했으나 열린 포지션 없음")
|
||||||
|
self._current_positions.pop(symbol, None)
|
||||||
return
|
return
|
||||||
|
|
||||||
primary_id = open_trades[0]["id"]
|
primary_id = open_trades[0]["id"]
|
||||||
@@ -461,6 +550,8 @@ class LogParser:
|
|||||||
reason, primary_id)
|
reason, primary_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._dirty = True
|
||||||
|
|
||||||
if len(open_trades) > 1:
|
if len(open_trades) > 1:
|
||||||
stale_ids = [r["id"] for r in open_trades[1:]]
|
stale_ids = [r["id"] for r in open_trades[1:]]
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
@@ -469,21 +560,24 @@ class LogParser:
|
|||||||
)
|
)
|
||||||
print(f"[LogParser] {symbol} 중복 OPEN 거래 {len(stale_ids)}건 삭제")
|
print(f"[LogParser] {symbol} 중복 OPEN 거래 {len(stale_ids)}건 삭제")
|
||||||
|
|
||||||
# 심볼별 일별 요약
|
# 심볼별 일별 요약 (trades 테이블에서 재계산 — idempotent)
|
||||||
day = ts[:10]
|
day = ts[:10]
|
||||||
win = 1 if net_pnl > 0 else 0
|
row = self.conn.execute(
|
||||||
loss = 1 if net_pnl <= 0 else 0
|
"""SELECT COUNT(*) as cnt,
|
||||||
|
SUM(CASE WHEN net_pnl > 0 THEN 1 ELSE 0 END) as wins,
|
||||||
|
SUM(CASE WHEN net_pnl <= 0 THEN 1 ELSE 0 END) as losses
|
||||||
|
FROM trades WHERE status='CLOSED' AND symbol=? AND exit_time LIKE ?""",
|
||||||
|
(symbol, f"{day}%"),
|
||||||
|
).fetchone()
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""INSERT INTO daily_pnl(symbol, date, cumulative_pnl, trade_count, wins, losses, last_updated)
|
"""INSERT INTO daily_pnl(symbol, date, trade_count, wins, losses, last_updated)
|
||||||
VALUES(?, ?, ?, 1, ?, ?, ?)
|
VALUES(?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(symbol, date) DO UPDATE SET
|
ON CONFLICT(symbol, date) DO UPDATE SET
|
||||||
trade_count = trade_count + 1,
|
trade_count=?, wins=?, losses=?, last_updated=?""",
|
||||||
wins = wins + ?,
|
(symbol, day, row["cnt"], row["wins"], row["losses"], ts,
|
||||||
losses = losses + ?,
|
row["cnt"], row["wins"], row["losses"], ts),
|
||||||
last_updated = ?""",
|
|
||||||
(symbol, day, net_pnl, win, loss, ts, win, loss, ts)
|
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self._dirty = True
|
||||||
|
|
||||||
self._set_status(f"{symbol}:position_status", "NONE")
|
self._set_status(f"{symbol}:position_status", "NONE")
|
||||||
print(f"[LogParser] {symbol} 포지션 청산: {reason} @ {exit_price}, PnL={net_pnl}")
|
print(f"[LogParser] {symbol} 포지션 청산: {reason} @ {exit_price}, PnL={net_pnl}")
|
||||||
@@ -492,4 +586,10 @@ class LogParser:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = LogParser()
|
parser = LogParser()
|
||||||
|
try:
|
||||||
parser.run()
|
parser.run()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(PID_FILE)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|||||||
3
dashboard/ui/.dockerignore
Normal file
3
dashboard/ui/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
FROM node:20-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json .
|
COPY package.json package-lock.json .
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
111
dashboard/ui/dist/assets/index-50uRhrJe.js
vendored
Normal file
111
dashboard/ui/dist/assets/index-50uRhrJe.js
vendored
Normal file
File diff suppressed because one or more lines are too long
20
dashboard/ui/dist/index.html
vendored
Normal file
20
dashboard/ui/dist/index.html
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Trading Dashboard</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,700&family=JetBrains+Mono:wght@300;400;500;600&display=swap" rel="stylesheet" />
|
||||||
|
<link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700&display=swap" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { background: #08080f; }
|
||||||
|
::-webkit-scrollbar { width: 5px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; }
|
||||||
|
</style>
|
||||||
|
<script type="module" crossorigin src="/assets/index-50uRhrJe.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -10,7 +10,9 @@ server {
|
|||||||
|
|
||||||
# API 프록시 → 백엔드 컨테이너
|
# API 프록시 → 백엔드 컨테이너
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://dashboard-api:8080;
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
set $backend http://dashboard-api:8080;
|
||||||
|
proxy_pass $backend;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
}
|
}
|
||||||
|
|||||||
2055
dashboard/ui/package-lock.json
generated
Normal file
2055
dashboard/ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -267,6 +267,7 @@ export default function App() {
|
|||||||
const [lastUpdate, setLastUpdate] = useState(null);
|
const [lastUpdate, setLastUpdate] = useState(null);
|
||||||
|
|
||||||
const [symbols, setSymbols] = useState([]);
|
const [symbols, setSymbols] = useState([]);
|
||||||
|
const symbolsRef = useRef([]);
|
||||||
const [selectedSymbol, setSelectedSymbol] = useState(null); // null = ALL
|
const [selectedSymbol, setSelectedSymbol] = useState(null); // null = ALL
|
||||||
|
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
@@ -277,24 +278,29 @@ export default function App() {
|
|||||||
const [positions, setPositions] = useState([]);
|
const [positions, setPositions] = useState([]);
|
||||||
const [botStatus, setBotStatus] = useState({});
|
const [botStatus, setBotStatus] = useState({});
|
||||||
const [trades, setTrades] = useState([]);
|
const [trades, setTrades] = useState([]);
|
||||||
|
const [tradesTotal, setTradesTotal] = useState(0);
|
||||||
|
const [tradesPage, setTradesPage] = useState(0);
|
||||||
const [daily, setDaily] = useState([]);
|
const [daily, setDaily] = useState([]);
|
||||||
const [candles, setCandles] = useState([]);
|
const [candles, setCandles] = useState([]);
|
||||||
|
|
||||||
/* ── 데이터 폴링 ─────────────────────────────────────────── */
|
/* ── 데이터 폴링 ─────────────────────────────────────────── */
|
||||||
const fetchAll = useCallback(async () => {
|
const fetchAll = useCallback(async () => {
|
||||||
const sym = selectedSymbol ? `?symbol=${selectedSymbol}` : "";
|
const sym = selectedSymbol ? `?symbol=${selectedSymbol}` : "";
|
||||||
const symRequired = selectedSymbol || symbols[0] || "XRPUSDT";
|
const symRequired = selectedSymbol || symbolsRef.current[0] || "XRPUSDT";
|
||||||
|
|
||||||
const [symRes, sRes, pRes, tRes, dRes, cRes] = await Promise.all([
|
const [symRes, sRes, pRes, tRes, dRes, cRes] = await Promise.all([
|
||||||
api("/symbols"),
|
api("/symbols"),
|
||||||
api(`/stats${sym}`),
|
api(`/stats${sym}`),
|
||||||
api(`/position${sym}`),
|
api(`/position${sym}`),
|
||||||
api(`/trades${sym}${sym ? "&" : "?"}limit=50`),
|
api(`/trades${sym}${sym ? "&" : "?"}limit=50&offset=${tradesPage * 50}`),
|
||||||
api(`/daily${sym}`),
|
api(`/daily${sym}`),
|
||||||
api(`/candles?symbol=${symRequired}&limit=96`),
|
api(`/candles?symbol=${symRequired}&limit=96`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (symRes?.symbols) setSymbols(symRes.symbols);
|
if (symRes?.symbols) {
|
||||||
|
symbolsRef.current = symRes.symbols;
|
||||||
|
setSymbols(symRes.symbols);
|
||||||
|
}
|
||||||
if (sRes && sRes.total_trades !== undefined) {
|
if (sRes && sRes.total_trades !== undefined) {
|
||||||
setStats(sRes);
|
setStats(sRes);
|
||||||
setIsLive(true);
|
setIsLive(true);
|
||||||
@@ -304,10 +310,13 @@ export default function App() {
|
|||||||
setPositions(pRes.positions || []);
|
setPositions(pRes.positions || []);
|
||||||
if (pRes.bot) setBotStatus(pRes.bot);
|
if (pRes.bot) setBotStatus(pRes.bot);
|
||||||
}
|
}
|
||||||
if (tRes?.trades) setTrades(tRes.trades);
|
if (tRes?.trades) {
|
||||||
|
setTrades(tRes.trades);
|
||||||
|
setTradesTotal(tRes.total || tRes.trades.length);
|
||||||
|
}
|
||||||
if (dRes?.daily) setDaily(dRes.daily);
|
if (dRes?.daily) setDaily(dRes.daily);
|
||||||
if (cRes?.candles) setCandles(cRes.candles);
|
if (cRes?.candles) setCandles(cRes.candles);
|
||||||
}, [selectedSymbol, symbols]);
|
}, [selectedSymbol, tradesPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAll();
|
fetchAll();
|
||||||
@@ -399,7 +408,20 @@ export default function App() {
|
|||||||
{/* 오픈 포지션 — 복수 표시 */}
|
{/* 오픈 포지션 — 복수 표시 */}
|
||||||
{positions.length > 0 && (
|
{positions.length > 0 && (
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
{positions.map((pos) => (
|
{positions.map((pos) => {
|
||||||
|
const curP = parseFloat(botStatus[`${pos.symbol}:current_price`] || 0);
|
||||||
|
const entP = parseFloat(pos.entry_price || 0);
|
||||||
|
const isShort = pos.direction === "SHORT";
|
||||||
|
const uPnl = botStatus[`${pos.symbol}:unrealized_pnl`];
|
||||||
|
const uPnlPct = botStatus[`${pos.symbol}:unrealized_pnl_pct`];
|
||||||
|
const pnlPct = uPnlPct != null
|
||||||
|
? parseFloat(uPnlPct)
|
||||||
|
: (entP > 0 && curP > 0
|
||||||
|
? ((isShort ? entP - curP : curP - entP) / entP * 100 * (pos.leverage || 10))
|
||||||
|
: null);
|
||||||
|
const pnlUsdt = uPnl != null ? parseFloat(uPnl) : null;
|
||||||
|
const posPnlColor = pnlPct > 0 ? S.green : pnlPct < 0 ? S.red : S.text3;
|
||||||
|
return (
|
||||||
<div key={pos.id} style={{
|
<div key={pos.id} style={{
|
||||||
background: "linear-gradient(135deg,rgba(99,102,241,0.08) 0%,rgba(99,102,241,0.02) 100%)",
|
background: "linear-gradient(135deg,rgba(99,102,241,0.08) 0%,rgba(99,102,241,0.02) 100%)",
|
||||||
border: "1px solid rgba(99,102,241,0.15)", borderRadius: 14,
|
border: "1px solid rgba(99,102,241,0.15)", borderRadius: 14,
|
||||||
@@ -410,17 +432,24 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||||
<Badge
|
<Badge
|
||||||
bg={pos.direction === "SHORT" ? "rgba(239,68,68,0.12)" : "rgba(52,211,153,0.12)"}
|
bg={isShort ? "rgba(239,68,68,0.12)" : "rgba(52,211,153,0.12)"}
|
||||||
color={pos.direction === "SHORT" ? S.red : S.green}
|
color={isShort ? S.red : S.green}
|
||||||
>
|
>
|
||||||
{pos.direction} {pos.leverage || 10}x
|
{pos.direction} {pos.leverage || 10}x
|
||||||
</Badge>
|
</Badge>
|
||||||
<span style={{ fontSize: 14, fontWeight: 700, fontFamily: S.mono }}>
|
<span style={{ fontSize: 14, fontWeight: 700, fontFamily: S.mono }}>
|
||||||
{fmt(pos.entry_price)}
|
{fmt(pos.entry_price)}
|
||||||
</span>
|
</span>
|
||||||
|
{pnlPct !== null && (
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, fontFamily: S.mono, color: posPnlColor }}>
|
||||||
|
{pnlUsdt != null ? `${pnlUsdt > 0 ? "+" : ""}${pnlUsdt.toFixed(4)}` : ""}
|
||||||
|
{` (${pnlPct > 0 ? "+" : ""}${pnlPct.toFixed(2)}%)`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -432,7 +461,7 @@ export default function App() {
|
|||||||
padding: 4, width: "fit-content",
|
padding: 4, width: "fit-content",
|
||||||
}}>
|
}}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedSymbol(null)}
|
onClick={() => { setSelectedSymbol(null); setTradesPage(0); }}
|
||||||
style={{
|
style={{
|
||||||
background: selectedSymbol === null ? "rgba(99,102,241,0.15)" : "transparent",
|
background: selectedSymbol === null ? "rgba(99,102,241,0.15)" : "transparent",
|
||||||
border: "none",
|
border: "none",
|
||||||
@@ -444,7 +473,7 @@ export default function App() {
|
|||||||
{symbols.map((sym) => (
|
{symbols.map((sym) => (
|
||||||
<button
|
<button
|
||||||
key={sym}
|
key={sym}
|
||||||
onClick={() => setSelectedSymbol(sym)}
|
onClick={() => { setSelectedSymbol(sym); setTradesPage(0); }}
|
||||||
style={{
|
style={{
|
||||||
background: selectedSymbol === sym ? "rgba(99,102,241,0.15)" : "transparent",
|
background: selectedSymbol === sym ? "rgba(99,102,241,0.15)" : "transparent",
|
||||||
border: "none",
|
border: "none",
|
||||||
@@ -570,7 +599,7 @@ export default function App() {
|
|||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
전체 {trades.length}건 보기 →
|
전체 {tradesTotal}건 보기 →
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -583,7 +612,7 @@ export default function App() {
|
|||||||
fontSize: 10, color: S.text3, letterSpacing: 1.2,
|
fontSize: 10, color: S.text3, letterSpacing: 1.2,
|
||||||
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 12,
|
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 12,
|
||||||
}}>
|
}}>
|
||||||
전체 거래 내역 ({trades.length}건)
|
전체 거래 내역 ({tradesTotal}건)
|
||||||
</div>
|
</div>
|
||||||
{trades.map((t) => (
|
{trades.map((t) => (
|
||||||
<TradeRow
|
<TradeRow
|
||||||
@@ -593,6 +622,38 @@ export default function App() {
|
|||||||
onToggle={() => setExpanded(expanded === t.id ? null : t.id)}
|
onToggle={() => setExpanded(expanded === t.id ? null : t.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{tradesTotal > 50 && (
|
||||||
|
<div style={{
|
||||||
|
display: "flex", justifyContent: "center", alignItems: "center",
|
||||||
|
gap: 12, marginTop: 14,
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
disabled={tradesPage === 0}
|
||||||
|
onClick={() => setTradesPage((p) => Math.max(0, p - 1))}
|
||||||
|
style={{
|
||||||
|
fontSize: 11, fontFamily: S.mono, padding: "6px 14px",
|
||||||
|
background: tradesPage === 0 ? "transparent" : "rgba(99,102,241,0.1)",
|
||||||
|
color: tradesPage === 0 ? S.text4 : S.indigo,
|
||||||
|
border: `1px solid ${tradesPage === 0 ? S.border : "rgba(99,102,241,0.2)"}`,
|
||||||
|
borderRadius: 8, cursor: tradesPage === 0 ? "default" : "pointer",
|
||||||
|
}}
|
||||||
|
>← 이전</button>
|
||||||
|
<span style={{ fontSize: 11, color: S.text3, fontFamily: S.mono }}>
|
||||||
|
{tradesPage * 50 + 1}–{Math.min((tradesPage + 1) * 50, tradesTotal)} / {tradesTotal}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
disabled={(tradesPage + 1) * 50 >= tradesTotal}
|
||||||
|
onClick={() => setTradesPage((p) => p + 1)}
|
||||||
|
style={{
|
||||||
|
fontSize: 11, fontFamily: S.mono, padding: "6px 14px",
|
||||||
|
background: (tradesPage + 1) * 50 >= tradesTotal ? "transparent" : "rgba(99,102,241,0.1)",
|
||||||
|
color: (tradesPage + 1) * 50 >= tradesTotal ? S.text4 : S.indigo,
|
||||||
|
border: `1px solid ${(tradesPage + 1) * 50 >= tradesTotal ? S.border : "rgba(99,102,241,0.2)"}`,
|
||||||
|
borderRadius: 8, cursor: (tradesPage + 1) * 50 >= tradesTotal ? "default" : "pointer",
|
||||||
|
}}
|
||||||
|
>다음 →</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -624,17 +685,22 @@ export default function App() {
|
|||||||
display: "grid", gridTemplateColumns: "1fr 1fr",
|
display: "grid", gridTemplateColumns: "1fr 1fr",
|
||||||
gap: 10, marginTop: 12,
|
gap: 10, marginTop: 12,
|
||||||
}}>
|
}}>
|
||||||
<ChartBox title="RSI">
|
<ChartBox title="OI 변화율">
|
||||||
<ResponsiveContainer width="100%" height={150}>
|
<ResponsiveContainer width="100%" height={150}>
|
||||||
<LineChart data={candles.map((c) => ({ ts: fmtTime(c.ts), rsi: c.rsi }))}>
|
<AreaChart data={candles.map((c) => ({ ts: fmtTime(c.ts), oi_change: c.oi_change }))}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gOI" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={S.amber} stopOpacity={0.15} />
|
||||||
|
<stop offset="100%" stopColor={S.amber} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
|
||||||
<XAxis dataKey="ts" {...axisStyle} interval="preserveStartEnd" />
|
<XAxis dataKey="ts" {...axisStyle} interval="preserveStartEnd" />
|
||||||
<YAxis domain={[0, 100]} {...axisStyle} />
|
<YAxis {...axisStyle} />
|
||||||
<Tooltip content={<ChartTooltip />} />
|
<Tooltip content={<ChartTooltip />} />
|
||||||
<Line type="monotone" dataKey={() => 70} stroke="rgba(248,113,113,0.2)" strokeDasharray="4 4" dot={false} name="과매수" />
|
<Line type="monotone" dataKey={() => 0} stroke="rgba(255,255,255,0.1)" strokeDasharray="4 4" dot={false} name="기준선" />
|
||||||
<Line type="monotone" dataKey={() => 30} stroke="rgba(139,92,246,0.2)" strokeDasharray="4 4" dot={false} name="과매도" />
|
<Area type="monotone" dataKey="oi_change" name="OI변화율" stroke={S.amber} strokeWidth={1.5} fill="url(#gOI)" dot={false} />
|
||||||
<Line type="monotone" dataKey="rsi" name="RSI" stroke={S.amber} strokeWidth={1.5} dot={false} />
|
</AreaChart>
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</ChartBox>
|
</ChartBox>
|
||||||
|
|
||||||
@@ -673,10 +739,16 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
const key = prompt("Reset API Key를 입력하세요:");
|
||||||
|
if (!key) return;
|
||||||
if (!confirm("DB를 초기화하고 로그를 처음부터 다시 파싱합니다. 계속할까요?")) return;
|
if (!confirm("DB를 초기화하고 로그를 처음부터 다시 파싱합니다. 계속할까요?")) return;
|
||||||
try {
|
try {
|
||||||
const r = await fetch("/api/reset", { method: "POST" });
|
const r = await fetch("/api/reset", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "X-API-Key": key },
|
||||||
|
});
|
||||||
if (r.ok) { alert("초기화 완료. 잠시 후 데이터가 다시 채워집니다."); location.reload(); }
|
if (r.ok) { alert("초기화 완료. 잠시 후 데이터가 다시 채워집니다."); location.reload(); }
|
||||||
|
else if (r.status === 403) alert("API Key가 올바르지 않습니다.");
|
||||||
else alert("초기화 실패: " + r.statusText);
|
else alert("초기화 실패: " + r.statusText);
|
||||||
} catch (e) { alert("초기화 실패: " + e.message); }
|
} catch (e) { alert("초기화 실패: " + e.message); }
|
||||||
}}
|
}}
|
||||||
|
|||||||
BIN
data/avaxusdt/combined_15m.parquet
Normal file
BIN
data/avaxusdt/combined_15m.parquet
Normal file
Binary file not shown.
Binary file not shown.
BIN
data/linkusdt/combined_15m.parquet
Normal file
BIN
data/linkusdt/combined_15m.parquet
Normal file
Binary file not shown.
BIN
data/solusdt/combined_15m.parquet
Normal file
BIN
data/solusdt/combined_15m.parquet
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -23,6 +23,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Seoul
|
- TZ=Asia/Seoul
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
- LOG_DIR=/app/logs
|
- LOG_DIR=/app/logs
|
||||||
- DB_PATH=/app/data/dashboard.db
|
- DB_PATH=/app/data/dashboard.db
|
||||||
- POLL_INTERVAL=5
|
- POLL_INTERVAL=5
|
||||||
@@ -51,5 +52,40 @@ services:
|
|||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
|
mtf-bot:
|
||||||
|
image: git.gihyeon.com/gihyeon/cointrader:latest
|
||||||
|
container_name: mtf-bot
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Seoul
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
volumes:
|
||||||
|
- ./logs:/app/logs
|
||||||
|
- ./data:/app/data
|
||||||
|
entrypoint: ["python", "main_mtf.py"]
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
|
ls-ratio-collector:
|
||||||
|
image: git.gihyeon.com/gihyeon/cointrader:latest
|
||||||
|
container_name: ls-ratio-collector
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Seoul
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
entrypoint: ["sh", "scripts/collect_ls_ratio_loop.sh"]
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "5m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
dashboard-data:
|
dashboard-data:
|
||||||
|
|||||||
129
docs/decisions/2026-03-21-ml-off-xrp-only.md
Normal file
129
docs/decisions/2026-03-21-ml-off-xrp-only.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# 의사결정 로그: ML 필터 비활성화 & XRP 단독 운영
|
||||||
|
|
||||||
|
**일자**: 2026-03-21
|
||||||
|
**결정자**: gihyeon
|
||||||
|
**상태**: 확정, 운영 반영 완료
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ML 필터를 왜 껐는가
|
||||||
|
|
||||||
|
### 결론: ML OFF > ML ON (전 심볼)
|
||||||
|
|
||||||
|
Walk-Forward 검증 결과, ML 필터를 끈 상태가 모든 심볼에서 더 나은 성과를 보였다.
|
||||||
|
|
||||||
|
| 심볼 | ML OFF PF | ML ON PF | 차이 | ML OFF Return | ML ON Return |
|
||||||
|
|------|-----------|----------|------|---------------|--------------|
|
||||||
|
| **XRPUSDT** | **1.16** | 0.71 | -0.45 (61%↓) | +12.17% | -25.62% |
|
||||||
|
| DOGEUSDT | 1.18 | 0.78 | -0.40 (34%↓) | +16.11% | -28.50% |
|
||||||
|
| SOLUSDT | 0.09 | 0.25 | — | -321.85% | -48.83% |
|
||||||
|
|
||||||
|
### 원인 분석
|
||||||
|
|
||||||
|
**1) Ablation 실험 — 모델이 독립적 알파를 제공하지 못함**
|
||||||
|
- 실험 A: 전체 26개 피처 (baseline AUC)
|
||||||
|
- 실험 B: signal_strength 제거
|
||||||
|
- 실험 C: signal_strength + side 제거
|
||||||
|
- **A→C AUC 하락: 0.08~0.09** (판정 기준: ≤0.05 유용, 0.05~0.10 조건부, ≥0.10 재설계)
|
||||||
|
- 해석: 모델이 기존 기술적 신호(RSI, MACD, ADX)를 단순 재확인하는 수준. 독립적 예측력 부재.
|
||||||
|
|
||||||
|
**2) 학습 데이터 부족**
|
||||||
|
- Walk-Forward 각 폴드 학습 세트에 유효 신호 ~27건
|
||||||
|
- 1:1 언더샘플링 후 양성 샘플 ~13건/폴드 → LightGBM 학습에 극히 부족
|
||||||
|
- 과적합 → 일반화 실패
|
||||||
|
|
||||||
|
**3) Purged Gap 적용 후 성능 추가 하락**
|
||||||
|
- 라벨 생성에 24캔들(6h) lookahead 사용 → 학습/검증 사이에 24캔들 embargo 추가
|
||||||
|
- 이전에 label leakage로 부풀려진 성능이 정정됨
|
||||||
|
|
||||||
|
### 운영 설정
|
||||||
|
```
|
||||||
|
NO_ML_FILTER=true # .env
|
||||||
|
```
|
||||||
|
모델 파일은 유지 (향후 재검증용). `ml_filter.py`의 hot-reload 로직도 그대로 남겨둠.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. SOL/DOGE/TRX를 왜 뺐는가
|
||||||
|
|
||||||
|
### 결론: XRP만 PF > 1.0 달성
|
||||||
|
|
||||||
|
| 심볼 | Strategy Sweep 최고 PF | Walk-Forward PF (ML OFF) | 판정 |
|
||||||
|
|------|----------------------|--------------------------|------|
|
||||||
|
| **XRPUSDT** | 1.68 | **1.16** | ✅ 운영 유지 |
|
||||||
|
| DOGEUSDT | 1.80 | 1.18* | ❌ 제외 |
|
||||||
|
| TRXUSDT | 3.87 (16건) | — | ❌ 제외 |
|
||||||
|
| SOLUSDT | 2.83 | **0.09** | ❌ 제외 |
|
||||||
|
|
||||||
|
*DOGE PF 1.18은 WR 25%로 소수 대형 승리에 의존 → 안정성 부족
|
||||||
|
|
||||||
|
### 핵심 교훈: 과적합 탐지
|
||||||
|
|
||||||
|
**SOLUSDT 사례가 가장 극적:**
|
||||||
|
- Strategy Sweep (1년 전체 백테스트): PF 2.83, Return +90.93%
|
||||||
|
- Walk-Forward (시계열 CV): PF 0.09, Return -321.85%
|
||||||
|
- **과적합 정도: PF 2.83 → 0.09 (97% 하락)**
|
||||||
|
|
||||||
|
→ 전체 기간 백테스트 결과만으로 심볼을 선택하면 안 됨. 반드시 Walk-Forward로 검증해야 함.
|
||||||
|
|
||||||
|
### 운영 설정
|
||||||
|
```
|
||||||
|
SYMBOLS=XRPUSDT # .env (이전: XRPUSDT,SOLUSDT,DOGEUSDT,TRXUSDT)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ML을 다시 켜려면 어떤 조건이 필요한가
|
||||||
|
|
||||||
|
### 필수 조건 (AND)
|
||||||
|
|
||||||
|
1. **데이터 양**: Walk-Forward 폴드당 유효 신호 100건 이상
|
||||||
|
- 현재 ~27건 → 약 4배 필요
|
||||||
|
- 방법: (a) 더 긴 수집 기간 (1년→3년), (b) 15m→5m 타임프레임 (데이터 3배), (c) 새 피처로 유효 신호 비율 증가
|
||||||
|
|
||||||
|
2. **독립적 알파**: Ablation A→C AUC 하락 ≤ 0.05
|
||||||
|
- signal_strength와 side를 제거해도 모델이 독립적으로 예측할 수 있어야 함
|
||||||
|
- 현재 0.08~0.09 → 새 피처(L/S ratio, OI 파생 등)가 이 갭을 메워야 함
|
||||||
|
|
||||||
|
3. **Walk-Forward 검증**: ML ON PF > ML OFF PF (최소 0.1 이상 차이)
|
||||||
|
- 단순히 PF > 1.0이 아니라, ML OFF 대비 개선이 있어야 함
|
||||||
|
- 검증 거래 수 50건 이상
|
||||||
|
|
||||||
|
4. **과적합 지표**: Strategy Sweep PF vs Walk-Forward PF 비율 < 2.0
|
||||||
|
- SOL처럼 Sweep 2.83 / WF 0.09 = 31배 차이 → 극심한 과적합
|
||||||
|
- 비율 2.0 이하면 합리적 범위
|
||||||
|
|
||||||
|
### 유망한 다음 시도
|
||||||
|
|
||||||
|
| 개선 방향 | 기대 효과 | 현재 상태 |
|
||||||
|
|-----------|-----------|-----------|
|
||||||
|
| **L/S Ratio 피처 추가** | 독립적 알파 (상관 0.12~0.14) | 수집 시작 (2026-03-22), 1개월 뒤 검증 가능 |
|
||||||
|
| **학습 데이터 3년 확보** | 폴드당 샘플 3배 증가 | 미착수 |
|
||||||
|
| **Cross-symbol 피처** | BTC/ETH 탑 트레이더 동향 → XRP 예측 | L/S ratio 수집 후 가능 |
|
||||||
|
| **다른 모델 (XGBoost, CatBoost)** | 소규모 데이터에 더 적합할 수 있음 | 미착수 |
|
||||||
|
|
||||||
|
### 재검증 타임라인
|
||||||
|
|
||||||
|
```
|
||||||
|
2026-03-22: L/S ratio 수집 시작 (top_acct + global, 3심볼)
|
||||||
|
2026-04-22: 1개월 데이터 축적 (~17,000건)
|
||||||
|
→ 상관분석 재실행 (5일 → 30일 데이터로 신뢰도 확인)
|
||||||
|
→ L/S ratio 피처를 ML에 추가하여 Ablation 재실험
|
||||||
|
→ Walk-Forward ML ON vs OFF 재비교
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 관련 문서 & 코드
|
||||||
|
|
||||||
|
| 참조 | 위치 |
|
||||||
|
|------|------|
|
||||||
|
| ML 비활성화 커밋 | `dacefaa` (docs: update for XRP-only operation) |
|
||||||
|
| ML 비교 결과 (XRP) | `results/xrpusdt/ml_comparison_20260321_200332.json` |
|
||||||
|
| ML 비교 결과 (DOGE) | `results/dogeusdt/ml_comparison_20260321_200334.json` |
|
||||||
|
| Strategy Sweep 결과 | `results/{symbol}/strategy_sweep_*.json` |
|
||||||
|
| Purged Gap 계획 | `docs/plans/2026-03-21-purged-gap-and-ablation.md` |
|
||||||
|
| ML 검증 파이프라인 | `docs/plans/2026-03-21-ml-validation-pipeline.md` |
|
||||||
|
| ML 검증 결과 | `docs/plans/2026-03-21-ml-validation-result.md` |
|
||||||
|
| L/S Ratio 수집 스크립트 | `scripts/collect_ls_ratio.py` |
|
||||||
|
| 운영 설정 | `.env` → `NO_ML_FILTER=true`, `SYMBOLS=XRPUSDT` |
|
||||||
169
docs/plans/2026-03-06-multi-symbol-dashboard-design.md
Normal file
169
docs/plans/2026-03-06-multi-symbol-dashboard-design.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Multi-Symbol Dashboard Design
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
멀티심볼 트레이딩(XRP, TRX, DOGE) 지원 이후 대시보드가 단일 심볼 기준으로 되어있어 수정 필요. 봇 로그 형식 통일, 파서/DB/API/UI 전체 레이어 변경.
|
||||||
|
|
||||||
|
## 접근 방식
|
||||||
|
|
||||||
|
**A안 채택**: 기존 단일 DB에 `symbol` 컬럼 추가. 대시보드 DB는 로그 파싱으로 재생성 가능하므로 초기화 비용 없음.
|
||||||
|
|
||||||
|
## 1. 봇 로그 수정
|
||||||
|
|
||||||
|
모든 핵심 로그에 `[SYMBOL]` 프리픽스를 일관되게 추가.
|
||||||
|
|
||||||
|
변경 대상 (`src/bot.py`):
|
||||||
|
- `신호: {signal} | 현재가:` → `[{self.symbol}] 신호: ...`
|
||||||
|
- `{signal} 진입: 가격=` → `[{self.symbol}] {signal} 진입: ...`
|
||||||
|
- `기존 포지션 복구:` → `[{self.symbol}] 기존 포지션 복구: ...`
|
||||||
|
- `기준 잔고 설정:` → `[{self.symbol}] 기준 잔고 설정: ...`
|
||||||
|
- `포지션 청산(...)` → `[{self.symbol}] 포지션 청산(...)`
|
||||||
|
- `OI=..., OI변화율=...` → `[{self.symbol}] OI=...` (debug→info로 변경 또는 그대로 debug 유지)
|
||||||
|
|
||||||
|
변경 대상 (`src/user_data_stream.py`):
|
||||||
|
- `청산 감지({reason}):` → `[{self.symbol}] 청산 감지({reason}): ...`
|
||||||
|
|
||||||
|
이미 `[{self.symbol}]`이 있는 로그는 그대로 유지.
|
||||||
|
|
||||||
|
## 2. Log Parser (`log_parser.py`)
|
||||||
|
|
||||||
|
### 정규식 변경
|
||||||
|
|
||||||
|
모든 패턴에 `\[(?P<symbol>\w+)\]` 프리픽스 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"signal": re.compile(
|
||||||
|
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||||
|
r".*\[(?P<symbol>\w+)\] 신호: (?P<signal>\w+) \| 현재가: (?P<price>[\d.]+) USDT"
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
### 상태 추적 멀티심볼 대응
|
||||||
|
|
||||||
|
- `_current_position: dict` → `_current_positions: dict[str, dict]` (심볼별)
|
||||||
|
- `_pending_candle: dict` → `_pending_candles: dict[str, dict[str, dict]]` (심볼별 타임스탬프별)
|
||||||
|
- `_bot_config["symbol"]` 제거, 정규식에서 심볼 직접 파싱
|
||||||
|
|
||||||
|
### 핸들러 변경
|
||||||
|
|
||||||
|
**`_handle_entry`**: symbol을 정규식에서 직접 받음. 중복 체크를 `symbol+direction` 기준으로.
|
||||||
|
|
||||||
|
**`_handle_close`**: `WHERE status='OPEN' AND symbol=?`로 해당 심볼만 닫음.
|
||||||
|
|
||||||
|
### bot_status 키 형식
|
||||||
|
|
||||||
|
- 심볼별: `{symbol}:current_price`, `{symbol}:position_status`, `{symbol}:current_signal` 등
|
||||||
|
- 전역: `balance`, `ml_threshold` 그대로
|
||||||
|
|
||||||
|
## 3. DB 스키마 변경
|
||||||
|
|
||||||
|
### candles 테이블
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE candles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
price REAL NOT NULL,
|
||||||
|
signal TEXT,
|
||||||
|
adx REAL,
|
||||||
|
oi REAL,
|
||||||
|
oi_change REAL,
|
||||||
|
funding_rate REAL,
|
||||||
|
UNIQUE(symbol, ts)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_candles_symbol_ts ON candles(symbol, ts);
|
||||||
|
```
|
||||||
|
|
||||||
|
### daily_pnl 테이블
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE daily_pnl (
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
cumulative_pnl REAL DEFAULT 0,
|
||||||
|
trade_count INTEGER DEFAULT 0,
|
||||||
|
wins INTEGER DEFAULT 0,
|
||||||
|
losses INTEGER DEFAULT 0,
|
||||||
|
last_updated TEXT,
|
||||||
|
PRIMARY KEY(symbol, date)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### trades 테이블
|
||||||
|
|
||||||
|
기존 `symbol` 컬럼 있음. `DEFAULT 'XRPUSDT'` 제거, 파서에서 항상 명시적으로 심볼 전달.
|
||||||
|
|
||||||
|
### bot_status 테이블
|
||||||
|
|
||||||
|
스키마 변경 없음. 키 네이밍만 `{symbol}:{key}` 형태로 변경.
|
||||||
|
|
||||||
|
### 마이그레이션
|
||||||
|
|
||||||
|
`_init_db()`에서 `DROP TABLE IF EXISTS` → 재생성. 기존 데이터는 로그 재파싱으로 복구.
|
||||||
|
|
||||||
|
## 4. API (`dashboard_api.py`)
|
||||||
|
|
||||||
|
모든 엔드포인트에 `symbol` 쿼리 파라미터 추가. 없으면 전체.
|
||||||
|
|
||||||
|
### 변경 엔드포인트
|
||||||
|
|
||||||
|
| 엔드포인트 | 변경 |
|
||||||
|
|-----------|------|
|
||||||
|
| `GET /api/position` | 심볼별 OPEN 포지션 목록 반환. `{"positions": [...], "bot": {...}}` |
|
||||||
|
| `GET /api/trades` | `?symbol=` 필터 추가 |
|
||||||
|
| `GET /api/stats` | `?symbol=` 필터 추가 |
|
||||||
|
| `GET /api/daily` | `?symbol=` 필터 추가 |
|
||||||
|
| `GET /api/candles` | `?symbol=` 필수 파라미터 |
|
||||||
|
|
||||||
|
### 새 엔드포인트
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/symbols → {"symbols": ["XRPUSDT", "TRXUSDT", "DOGEUSDT"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`bot_status`에서 `{symbol}:last_start` 키가 있는 심볼 목록 반환.
|
||||||
|
|
||||||
|
## 5. UI (`App.jsx`)
|
||||||
|
|
||||||
|
### 헤더
|
||||||
|
|
||||||
|
- "XRP/USDT" 하드코딩 제거 → `Live · 3 symbols`
|
||||||
|
- 오픈 포지션 카드를 심볼별 복수 표시 (가로 나열)
|
||||||
|
|
||||||
|
### 심볼 필터 탭
|
||||||
|
|
||||||
|
기존 탭(Overview/Trades/Chart) 위에 심볼 필터 추가: `ALL | XRP | TRX | DOGE`
|
||||||
|
- `/api/symbols`에서 동적 생성
|
||||||
|
- `ALL`: 전체 합산, 개별 심볼: 해당 심볼만
|
||||||
|
|
||||||
|
### Overview 탭
|
||||||
|
|
||||||
|
- `ALL`: 전체 합산 StatCard + 일별 PnL + 최근 거래(심볼 뱃지 표시)
|
||||||
|
- 개별 심볼: 해당 심볼만
|
||||||
|
|
||||||
|
### Trades 탭
|
||||||
|
|
||||||
|
- 선택된 심볼로 필터링
|
||||||
|
|
||||||
|
### Chart 탭
|
||||||
|
|
||||||
|
- `ALL` 선택 시 첫 번째 심볼 자동 선택 (캔들은 심볼별)
|
||||||
|
- 차트 제목 동적: `{SYMBOL}/USDT 15m 가격`
|
||||||
|
|
||||||
|
### 데이터 페칭
|
||||||
|
|
||||||
|
- `fetchAll`에서 선택된 심볼을 쿼리 파라미터로 전달
|
||||||
|
- 심볼 변경 시 즉시 리페치
|
||||||
|
|
||||||
|
## 6. 변경 범위 요약
|
||||||
|
|
||||||
|
| 레이어 | 파일 | 변경 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 봇 | `src/bot.py` | 로그에 `[SYMBOL]` 프리픽스 추가 |
|
||||||
|
| 봇 | `src/user_data_stream.py` | 청산 로그에 `[SYMBOL]` 프리픽스 추가 |
|
||||||
|
| 파서 | `dashboard/api/log_parser.py` | 정규식, 상태 추적, 핸들러 멀티심볼 대응 |
|
||||||
|
| API | `dashboard/api/dashboard_api.py` | `symbol` 파라미터, `/api/symbols` |
|
||||||
|
| UI | `dashboard/ui/src/App.jsx` | 심볼 필터 탭, 복수 포지션, 동적 헤더 |
|
||||||
|
|
||||||
|
봇 이미지와 대시보드 이미지 모두 재빌드 필요.
|
||||||
1259
docs/plans/2026-03-06-multi-symbol-dashboard-plan.md
Normal file
1259
docs/plans/2026-03-06-multi-symbol-dashboard-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,47 +1,53 @@
|
|||||||
# Strategy Parameter Sweep Plan
|
# 전략 파라미터 스윕 계획
|
||||||
|
|
||||||
**Date**: 2026-03-06
|
**날짜**: 2026-03-06
|
||||||
**Status**: Completed
|
**상태**: 완료
|
||||||
|
|
||||||
## Goal
|
## 목표
|
||||||
|
|
||||||
Find profitable parameter combinations for the base technical indicator strategy (ML OFF) using walk-forward backtesting, targeting PF >= 1.0 as foundation for ML redesign.
|
Walk-Forward 백테스트를 활용하여 기본 기술 지표 전략(ML OFF)의 수익성 높은 파라미터 조합을 탐색하고, PF >= 1.0을 ML 재설계의 기반으로 확보한다.
|
||||||
|
|
||||||
## Background
|
## 배경
|
||||||
|
|
||||||
Walk-forward backtest revealed the current XRP strategy is unprofitable (PF 0.71, -641 PnL). The strategy parameter sweep systematically tests 324 combinations of 5 parameters to find profitable regimes.
|
Walk-Forward 백테스트 결과 현재 XRP 전략이 비수익적(PF 0.71, -641 PnL)으로 확인되었다. 전략 파라미터 스윕은 5개 파라미터의 324개 조합을 체계적으로 테스트하여 수익 구간을 탐색한다.
|
||||||
|
|
||||||
## Parameters Swept
|
## 스윕 파라미터
|
||||||
|
|
||||||
| Parameter | Values | Description |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| `atr_sl_mult` | 1.0, 1.5, 2.0 | Stop-loss ATR multiplier |
|
|
||||||
| `atr_tp_mult` | 2.0, 3.0, 4.0 | Take-profit ATR multiplier |
|
|
||||||
| `signal_threshold` | 3, 4, 5 | Min weighted indicator score for entry |
|
|
||||||
| `adx_threshold` | 0, 20, 25, 30 | ADX filter (0=disabled, N=require ADX>=N) |
|
|
||||||
| `volume_multiplier` | 1.5, 2.0, 2.5 | Volume surge detection multiplier |
|
|
||||||
|
|
||||||
Total combinations: 3 x 3 x 3 x 4 x 3 = **324**
|
| 파라미터 | 값 | 설명 |
|
||||||
|
| ------------------- | ------------- | ------------------------------------------- |
|
||||||
|
| `atr_sl_mult` | 1.0, 1.5, 2.0 | 손절 ATR 배수 |
|
||||||
|
| `atr_tp_mult` | 2.0, 3.0, 4.0 | 익절 ATR 배수 |
|
||||||
|
| `signal_threshold` | 3, 4, 5 | 진입을 위한 최소 가중치 지표 점수 |
|
||||||
|
| `adx_threshold` | 0, 20, 25, 30 | ADX 필터 (0=비활성, N=ADX>=N 필요) |
|
||||||
|
| `volume_multiplier` | 1.5, 2.0, 2.5 | 거래량 급증 감지 배수 |
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
### Files Modified
|
총 조합: 3 x 3 x 3 x 4 x 3 = **324**
|
||||||
- `src/indicators.py` — `get_signal()` accepts `signal_threshold`, `adx_threshold`, `volume_multiplier` params
|
|
||||||
- `src/dataset_builder.py` — `_calc_signals()` accepts same params for vectorized computation
|
|
||||||
- `src/backtester.py` — `BacktestConfig` includes strategy params; `WalkForwardBacktester` propagates them to test folds
|
|
||||||
|
|
||||||
### Files Created
|
## 구현
|
||||||
- `scripts/strategy_sweep.py` — CLI tool for parameter grid sweep
|
|
||||||
|
|
||||||
### Bug Fix
|
### 수정된 파일
|
||||||
- `WalkForwardBacktester` was not passing `signal_threshold`, `adx_threshold`, `volume_multiplier`, or `use_ml` to fold `BacktestConfig`. All signal params were silently using defaults, making ADX/volume/threshold sweeps have zero effect.
|
|
||||||
|
|
||||||
## Results (XRPUSDT, Walk-Forward 3/1)
|
- `src/indicators.py` — `get_signal()`에 `signal_threshold`, `adx_threshold`, `volume_multiplier` 파라미터 추가
|
||||||
|
- `src/dataset_builder.py` — `_calc_signals()`에 동일 파라미터를 받아 벡터화 계산에 적용
|
||||||
|
- `src/backtester.py` — `BacktestConfig`에 전략 파라미터 포함; `WalkForwardBacktester`가 테스트 폴드에 전파
|
||||||
|
|
||||||
### Top 10 Combinations
|
### 신규 생성 파일
|
||||||
|
|
||||||
| Rank | SL×ATR | TP×ATR | Signal | ADX | Vol | Trades | WinRate | PF | MDD | PnL | Sharpe |
|
- `scripts/strategy_sweep.py` — 파라미터 그리드 스윕 CLI 도구
|
||||||
|------|--------|--------|--------|-----|-----|--------|---------|-----|-----|------|--------|
|
|
||||||
|
### 버그 수정
|
||||||
|
|
||||||
|
- `WalkForwardBacktester`가 `signal_threshold`, `adx_threshold`, `volume_multiplier`, `use_ml`을 폴드 `BacktestConfig`에 전달하지 않는 버그 수정. 모든 신호 파라미터가 기본값으로 적용되어 ADX/거래량/임계값 스윕이 효과 없이 실행되고 있었음.
|
||||||
|
|
||||||
|
## 결과 (XRPUSDT, Walk-Forward 3/1)
|
||||||
|
|
||||||
|
### 상위 10개 조합
|
||||||
|
|
||||||
|
|
||||||
|
| 순위 | SL×ATR | TP×ATR | 신호 | ADX | 거래량 | 거래 수 | 승률 | PF | MDD | PnL | 샤프 |
|
||||||
|
| ---- | ------ | ------ | ---- | --- | ------ | ------- | ------- | ---- | ----- | ---- | ---- |
|
||||||
| 1 | 1.5 | 4.0 | 3 | 30 | 2.5 | 19 | 52.6% | 2.39 | 7.0% | +469 | 61.0 |
|
| 1 | 1.5 | 4.0 | 3 | 30 | 2.5 | 19 | 52.6% | 2.39 | 7.0% | +469 | 61.0 |
|
||||||
| 2 | 1.5 | 2.0 | 3 | 30 | 2.5 | 19 | 68.4% | 2.23 | 6.5% | +282 | 61.2 |
|
| 2 | 1.5 | 2.0 | 3 | 30 | 2.5 | 19 | 68.4% | 2.23 | 6.5% | +282 | 61.2 |
|
||||||
| 3 | 1.0 | 2.0 | 3 | 30 | 2.5 | 19 | 57.9% | 1.98 | 5.0% | +213 | 50.8 |
|
| 3 | 1.0 | 2.0 | 3 | 30 | 2.5 | 19 | 57.9% | 1.98 | 5.0% | +213 | 50.8 |
|
||||||
@@ -53,28 +59,27 @@ Total combinations: 3 x 3 x 3 x 4 x 3 = **324**
|
|||||||
| 9 | 1.5 | 2.0 | 3 | 25 | 2.5 | 28 | 57.1% | 1.62 | 10.3% | +229 | 35.7 |
|
| 9 | 1.5 | 2.0 | 3 | 25 | 2.5 | 28 | 57.1% | 1.62 | 10.3% | +229 | 35.7 |
|
||||||
| 10 | 2.0 | 2.0 | 3 | 25 | 2.5 | 27 | 66.7% | 1.57 | 12.0% | +217 | 33.3 |
|
| 10 | 2.0 | 2.0 | 3 | 25 | 2.5 | 27 | 66.7% | 1.57 | 12.0% | +217 | 33.3 |
|
||||||
|
|
||||||
### Current Production (Rank 93/324)
|
|
||||||
| SL×ATR | TP×ATR | Signal | ADX | Vol | Trades | WinRate | PF | MDD | PnL |
|
### 현재 프로덕션 (324개 중 93위)
|
||||||
|--------|--------|--------|-----|-----|--------|---------|-----|-----|------|
|
|
||||||
|
|
||||||
|
| SL×ATR | TP×ATR | 신호 | ADX | 거래량 | 거래 수 | 승률 | PF | MDD | PnL |
|
||||||
|
| ------ | ------ | ---- | --- | ------ | ------- | ------- | ---- | ----- | ---- |
|
||||||
| 1.5 | 3.0 | 3 | 0 | 1.5 | 118 | 30.5% | 0.71 | 65.9% | -641 |
|
| 1.5 | 3.0 | 3 | 0 | 1.5 | 118 | 30.5% | 0.71 | 65.9% | -641 |
|
||||||
|
|
||||||
### Key Findings
|
|
||||||
|
|
||||||
1. **ADX filter is the single most impactful parameter.** All top 10 results use ADX >= 25, with ADX=30 dominating the top 5. This filters out sideways/ranging markets where signals are noise.
|
### 핵심 발견 사항
|
||||||
|
|
||||||
2. **Volume multiplier 2.5 dominates.** Higher volume thresholds ensure entries only on strong conviction (genuine breakouts vs. noise).
|
1. **ADX 필터가 가장 영향력 있는 단일 파라미터.** 상위 10개 결과 모두 ADX >= 25를 사용하며, 상위 5개는 ADX=30이 지배적. 횡보/박스권 시장에서 노이즈 신호를 필터링한다.
|
||||||
|
2. **거래량 배수 2.5가 지배적.** 높은 거래량 임계값은 진정한 돌파에서만 진입을 보장한다 (노이즈 대비 실질 돌파).
|
||||||
|
3. **신호 임계값 3이 최적.** 더 높은 임계값(4, 5)은 대부분의 ADX 필터링 구간에서 거래가 너무 적거나 0건이었다.
|
||||||
|
4. **SL/TP 비율보다 진입 필터가 더 중요.** 상위 결과는 모든 SL/TP 조합에 걸쳐 있지만, 모두 ADX=25-30 + Vol=2.5를 공유한다.
|
||||||
|
5. **필터 적용 시 거래 수가 크게 감소.** 상위 조합은 19-39건 vs 현재 118건. 적지만 높은 품질의 진입.
|
||||||
|
6. **324개 중 41개 조합이 PF >= 1.0 달성** (12.7%).
|
||||||
|
|
||||||
3. **Signal threshold 3 is optimal.** Higher thresholds (4, 5) produced too few trades or zero trades in most ADX-filtered regimes.
|
## 권장 다음 단계
|
||||||
|
|
||||||
4. **SL/TP ratios matter less than entry filters.** The top results span all SL/TP combos, but all share ADX=25-30 + Vol=2.5.
|
1. **프로덕션 기본값 업데이트**: ADX=25, volume_multiplier=2.0을 보수적 선택으로 적용 (ADX=30보다 더 많은 거래 확보)
|
||||||
|
2. **TRXUSDT, DOGEUSDT에서 검증**: ADX 필터가 XRP에만 특화된 것이 아닌지 확인
|
||||||
5. **Trade count drops significantly with filters.** Top combos have 19-39 trades vs. 118 for current. Fewer but higher quality entries.
|
3. **ML 모델 재학습**: 업데이트된 전략 파라미터로 — ML 필터가 수익성 있는 기반 위에서 개선 가능
|
||||||
|
4. **수익 구간 주변 세밀 스윕**: ADX [25-35], Vol [2.0-3.0]
|
||||||
6. **41 combinations achieved PF >= 1.0** out of 324 total (12.7%).
|
|
||||||
|
|
||||||
## Recommended Next Steps
|
|
||||||
|
|
||||||
1. **Update production defaults**: ADX=25, volume_multiplier=2.0 as a conservative choice (more trades than ADX=30)
|
|
||||||
2. **Validate on TRXUSDT and DOGEUSDT** to confirm ADX filter is not XRP-specific
|
|
||||||
3. **Retrain ML models** with updated strategy params — the ML filter should now have a profitable base to improve upon
|
|
||||||
4. **Fine-tune sweep** around the profitable zone: ADX [25-35], Vol [2.0-3.0]
|
|
||||||
|
|||||||
146
docs/plans/2026-03-07-code-review-improvements.md
Normal file
146
docs/plans/2026-03-07-code-review-improvements.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# 코드 리뷰 개선 사항
|
||||||
|
|
||||||
|
**날짜**: 2026-03-07
|
||||||
|
**상태**: 부분 완료 (#1/#2/#4/#5/#6/#8 완료, #9 보류, #3/#7/#10~13 다음 스프린트)
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
전체 코드베이스 리뷰에서 발견된 버그, 엣지 케이스, 로직 오류를 우선순위별로 정리하고 수정한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical (즉시 수정 필요)
|
||||||
|
|
||||||
|
### 1. OI 변화율 계산 시 Division by Zero
|
||||||
|
|
||||||
|
**파일**: `src/bot.py:120`
|
||||||
|
|
||||||
|
`_prev_oi`가 0.0일 때 `(current_oi - self._prev_oi) / self._prev_oi`에서 ZeroDivisionError 발생. `get_open_interest()` 실패 시 0.0을 반환하므로 실제로 발생 가능.
|
||||||
|
|
||||||
|
**수정**: `_prev_oi == 0.0`이면 `oi_change = 0.0`으로 처리.
|
||||||
|
|
||||||
|
### 2. 누적 트레이드 수 계산 로직 오류
|
||||||
|
|
||||||
|
**파일**: `scripts/weekly_report.py:415-423`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 현재 (잘못됨) — max()로 비교하여 누적이 아닌 최대값만 가져옴
|
||||||
|
cumulative = live_count
|
||||||
|
for rpath in sorted(rdir.glob("report_*.json")):
|
||||||
|
cumulative = max(cumulative, prev.get("live_trades", {}).get("count", 0))
|
||||||
|
```
|
||||||
|
|
||||||
|
ML 재학습 트리거 조건(`≥ 150건`)이 제대로 작동하지 않음.
|
||||||
|
|
||||||
|
**수정**: 이전 리포트의 `live_trades.count`를 합산하도록 변경.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Important (이번 주 수정 권장)
|
||||||
|
|
||||||
|
### 3. Training-Serving Skew (OI/펀딩비 피처)
|
||||||
|
|
||||||
|
**파일**: `src/dataset_builder.py` vs `src/ml_features.py`
|
||||||
|
|
||||||
|
- 학습 시: OI=0 구간을 NaN으로 마스킹 후 z-score
|
||||||
|
- 서빙 시: OI 값을 그대로 NaN으로 설정
|
||||||
|
|
||||||
|
ML 활성화 시 학습/서빙 간 피처 분포 불일치 발생. 현재 ML OFF이므로 당장은 영향 없지만, ML 재활성화 전 반드시 수정 필요.
|
||||||
|
|
||||||
|
### 4. `fetch_history.py` — API 실패/Rate Limit 미처리
|
||||||
|
|
||||||
|
**파일**: `scripts/fetch_history.py:46-61`
|
||||||
|
|
||||||
|
`futures_klines()` 호출에 retry 로직이 없음. Rate limit(429) 발생 시 예외로 크래시. `weekly_report.py`의 subprocess가 무한 대기할 수 있음.
|
||||||
|
|
||||||
|
**수정**: `tenacity` 또는 수동 retry 로직 추가 (최대 3회, exponential backoff).
|
||||||
|
|
||||||
|
### 5. Parquet Upsert 시 중복 타임스탬프 미제거
|
||||||
|
|
||||||
|
**파일**: `scripts/fetch_history.py:314`
|
||||||
|
|
||||||
|
`sort_index()`만 하고 `drop_duplicates()`를 하지 않음. API 응답에 중복 타임스탬프가 있으면 지표 계산이 이중 계산됨.
|
||||||
|
|
||||||
|
**수정**: `sort_index()` 앞에 `df[~df.index.duplicated(keep='last')]` 추가.
|
||||||
|
|
||||||
|
### 6. `record_pnl()`에 asyncio.Lock 미사용
|
||||||
|
|
||||||
|
**파일**: `src/risk_manager.py:55`
|
||||||
|
|
||||||
|
`record_pnl()`이 `self.daily_pnl`을 수정하지만 `async with self._lock`을 사용하지 않음. 멀티심볼 환경에서 동시 호출 시 일일 손실 한도 체크가 부정확할 수 있음.
|
||||||
|
|
||||||
|
**수정**: `record_pnl()`을 async로 변경하고 `async with self._lock:` 추가.
|
||||||
|
|
||||||
|
### 7. 백테스터 Equity Curve 미구현
|
||||||
|
|
||||||
|
**파일**: `src/backtester.py:509-510`
|
||||||
|
|
||||||
|
`_record_equity()`가 `pass`로 비어 있음. MDD 계산이 실현 PnL 기준이지 포트폴리오 가치(미실현 PnL 포함) 기준이 아님. MDD가 과소평가될 수 있음.
|
||||||
|
|
||||||
|
**수정**: 미실현 PnL을 포함한 equity 계산 구현.
|
||||||
|
|
||||||
|
### 8. User Data Stream — exit_price 기본값 0.0
|
||||||
|
|
||||||
|
**파일**: `src/user_data_stream.py:95`
|
||||||
|
|
||||||
|
`order.get("ap", "0")`에서 필드 누락 시 exit_price=0.0으로 설정되어 PnL이 완전히 잘못 계산됨.
|
||||||
|
|
||||||
|
**수정**: `exit_price == 0.0`이면 청산 처리를 스킵하고 WARNING 로그 출력.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Minor (다음 스프린트)
|
||||||
|
|
||||||
|
### 9. 거래량 급증 진입 조건 의도 불일치
|
||||||
|
|
||||||
|
**파일**: `src/indicators.py:115-118`
|
||||||
|
|
||||||
|
`(vol_surge or long_signals >= signal_threshold + 1)` — 거래량 급증만으로도 진입 허용됨. "강한 신호 + 거래량 급증"이 의도라면 AND 조건이어야 하는데, 현재 OR로 구현됨. 현재 전략 파라미터 스윕 결과(ADX=25, Vol=2.5)에서는 큰 문제 없으나, 의도를 확인하고 정리 필요.
|
||||||
|
|
||||||
|
### 10. ML 모델 피처 불일치 시 Silent Failure
|
||||||
|
|
||||||
|
**파일**: `src/ml_filter.py:152`
|
||||||
|
|
||||||
|
ONNX 모델과 현재 FEATURE_COLS가 다르면 예외를 잡고 `False`를 반환(모든 신호 차단). 사용자에게 원인이 보이지 않아 디버깅이 어려움.
|
||||||
|
|
||||||
|
**수정**: 피처 수 불일치는 WARNING이 아닌 ERROR로 로깅하고, 최초 발생 시 Discord 알림 전송.
|
||||||
|
|
||||||
|
### 11. `train_model.py` — 빈 데이터셋 미처리
|
||||||
|
|
||||||
|
**파일**: `scripts/train_model.py:196`
|
||||||
|
|
||||||
|
`generate_dataset_vectorized()`가 빈 DataFrame을 반환하면 Walk-Forward 검증에서 step=0이 되어 무한 루프 가능.
|
||||||
|
|
||||||
|
**수정**: 빈 데이터셋 시 `ValueError("No samples generated")` raise.
|
||||||
|
|
||||||
|
### 12. `data_stream.py` — AsyncClient 생성 실패 시 전체 크래시
|
||||||
|
|
||||||
|
**파일**: `src/data_stream.py:79-82`
|
||||||
|
|
||||||
|
네트워크 단절 상태에서 봇 시작 시 `AsyncClient.create()` 실패로 모든 심볼이 함께 크래시.
|
||||||
|
|
||||||
|
**수정**: retry with exponential backoff (최대 5회) 추가.
|
||||||
|
|
||||||
|
### 13. `fetch_history.py` — Parquet 타임존 처리 불일치
|
||||||
|
|
||||||
|
**파일**: `scripts/fetch_history.py:286-289`
|
||||||
|
|
||||||
|
`tz_localize("UTC")` 호출 시 기존 데이터가 실제로 UTC인지 검증하지 않음. 타임존이 다른 데이터가 섞이면 OI/펀딩비 병합이 시간축으로 어긋남.
|
||||||
|
|
||||||
|
**수정**: `tz_localize(tz='UTC', ambiguous='raise', nonexistent='raise')` 사용.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 수정 우선순위
|
||||||
|
|
||||||
|
| 우선순위 | 이슈 | 난이도 | 영향도 |
|
||||||
|
|---------|------|--------|--------|
|
||||||
|
| 즉시 | #1 OI division by zero | 5분 | 봇 크래시 |
|
||||||
|
| 즉시 | #2 누적 트레이드 계산 | 5분 | ML 트리거 오작동 |
|
||||||
|
| 이번주 | #4 fetch_history retry | 30분 | 데이터 수집 행 |
|
||||||
|
| 이번주 | #5 Parquet 중복 제거 | 5분 | 지표 이중 계산 |
|
||||||
|
| 이번주 | #6 record_pnl Lock | 5분 | 리스크 한도 부정확 |
|
||||||
|
| 이번주 | #8 exit_price=0 방어 | 10분 | PnL 오계산 |
|
||||||
|
| ML 재활성화 전 | #3 Training-Serving skew | 30분 | 예측 품질 저하 |
|
||||||
|
| 다음 스프린트 | #7 Equity curve 구현 | 1시간 | MDD 과소평가 |
|
||||||
|
| 다음 스프린트 | #9-13 기타 | 각 10-30분 | 안정성 개선 |
|
||||||
1071
docs/plans/2026-03-07-weekly-report-plan.md
Normal file
1071
docs/plans/2026-03-07-weekly-report-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
53
docs/plans/2026-03-19-critical-bugfixes.md
Normal file
53
docs/plans/2026-03-19-critical-bugfixes.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Critical Bugfixes Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Fix 4 critical bugs identified in code review (C5, C1, C3, C8)
|
||||||
|
|
||||||
|
**Architecture:** Direct fixes to backtester.py, bot.py, main.py — no new files needed
|
||||||
|
|
||||||
|
**Tech Stack:** Python asyncio, signal handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: C5 — Backtester double fee deduction + atr≤0 fee leak
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/backtester.py:494-501`
|
||||||
|
|
||||||
|
- [x] Remove `self.balance -= entry_fee` at L496. The fee is already deducted in `_close_position` via `net_pnl = gross_pnl - entry_fee - exit_fee`.
|
||||||
|
- [x] This also fixes the atr≤0 early return bug — since balance is no longer modified before ATR check, early return doesn't leak fees.
|
||||||
|
|
||||||
|
## Task 2: C1 — SL/TP atomicity with retry and emergency close
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py:461-475`
|
||||||
|
|
||||||
|
- [x] Wrap SL/TP placement in `_place_sl_tp_with_retry()` with 3 retries and 1s backoff
|
||||||
|
- [x] Track `sl_placed` and `tp_placed` independently to avoid re-placing successful orders
|
||||||
|
- [x] On final failure, call `_emergency_close()` which market-closes the position and notifies via Discord
|
||||||
|
- [x] `_emergency_close` also handles its own failure with critical log + Discord alert
|
||||||
|
|
||||||
|
## Task 3: C3 — PnL double recording race condition
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py` (init, _on_position_closed, _position_monitor)
|
||||||
|
|
||||||
|
- [x] Add `self._close_lock = asyncio.Lock()` to `__init__`
|
||||||
|
- [x] Wrap `_on_position_closed` body with `async with self._close_lock`
|
||||||
|
- [x] Wrap SYNC path in `_position_monitor` with `async with self._close_lock`
|
||||||
|
- [x] Add double-check after lock acquisition in monitor (callback may have already processed)
|
||||||
|
|
||||||
|
## Task 4: C8 — Graceful shutdown with signal handler
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `main.py`
|
||||||
|
|
||||||
|
- [x] Add `signal.SIGTERM` and `signal.SIGINT` handlers via `loop.add_signal_handler()`
|
||||||
|
- [x] Use `asyncio.Event` + `asyncio.wait(FIRST_COMPLETED)` pattern
|
||||||
|
- [x] `_graceful_shutdown()`: cancel all open orders per bot (with 5s timeout), then cancel tasks
|
||||||
|
- [x] Log shutdown progress for each symbol
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- [x] All 138 existing tests pass (0 failures)
|
||||||
108
docs/plans/2026-03-21-code-review-fixes-r2.md
Normal file
108
docs/plans/2026-03-21-code-review-fixes-r2.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Code Review Fixes Round 2 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Fix 9 issues from code review re-evaluation (2 Critical, 3 Important, 4 Minor)
|
||||||
|
|
||||||
|
**Architecture:** Targeted fixes across risk_manager, exchange, bot, config, ml_filter. No new files — all modifications to existing modules.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, asyncio, python-binance, LightGBM, ONNX Runtime
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: #2 Critical — Balance reservation lock for concurrent entry
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/risk_manager.py` — add `_entry_lock` to serialize entry flow
|
||||||
|
- Modify: `src/bot.py:405-413` — acquire entry lock around balance read → order
|
||||||
|
- Test: `tests/test_risk_manager.py`
|
||||||
|
|
||||||
|
The simplest fix: add an asyncio.Lock in RiskManager that serializes the entire _open_position flow across all bots. This prevents two bots from reading the same balance simultaneously.
|
||||||
|
|
||||||
|
- [ ] Add `_entry_lock = asyncio.Lock()` to RiskManager
|
||||||
|
- [ ] Add `async def entry_lock(self)` context manager
|
||||||
|
- [ ] In bot.py `_open_position`, wrap balance read + order under `async with self.risk.entry_lock()`
|
||||||
|
- [ ] Add test for concurrent entry serialization
|
||||||
|
- [ ] Run tests
|
||||||
|
|
||||||
|
### Task 2: #3 Critical — SYNC PnL startTime + single query
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/exchange.py:166-185` — add `start_time` param to `get_recent_income`
|
||||||
|
- Modify: `src/bot.py:75-82` — record `_entry_time` on position open
|
||||||
|
- Modify: `src/bot.py:620-629` — pass `start_time` to income query
|
||||||
|
- Test: `tests/test_exchange.py`
|
||||||
|
|
||||||
|
- [ ] Add `_entry_time: int | None = None` to TradingBot
|
||||||
|
- [ ] Set `_entry_time = int(time.time() * 1000)` on entry and recovery
|
||||||
|
- [ ] Add `start_time` parameter to `get_recent_income()`
|
||||||
|
- [ ] Use start_time in SYNC fallback
|
||||||
|
- [ ] Add test
|
||||||
|
- [ ] Run tests
|
||||||
|
|
||||||
|
### Task 3: #1 Important — Thread-safe Client access
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/exchange.py` — add `threading.Lock` per instance
|
||||||
|
|
||||||
|
- [ ] Add `self._api_lock = threading.Lock()` in `__init__`
|
||||||
|
- [ ] Wrap all `run_in_executor` lambdas with lock acquisition
|
||||||
|
- [ ] Add test
|
||||||
|
- [ ] Run tests
|
||||||
|
|
||||||
|
### Task 4: #4 Important — reset_daily async with lock
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/risk_manager.py:61-64` — make async + lock
|
||||||
|
- Modify: `main.py:22` — await reset_daily
|
||||||
|
- Test: `tests/test_risk_manager.py`
|
||||||
|
|
||||||
|
- [ ] Convert `reset_daily` to async, add lock
|
||||||
|
- [ ] Update `_daily_reset_loop` call
|
||||||
|
- [ ] Add test
|
||||||
|
- [ ] Run tests
|
||||||
|
|
||||||
|
### Task 5: #8 Important — exchange_info cache TTL
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/exchange.py:25-34` — add TTL (24h)
|
||||||
|
|
||||||
|
- [ ] Add `_exchange_info_time: float = 0.0`
|
||||||
|
- [ ] Check TTL in `_get_exchange_info`
|
||||||
|
- [ ] Add test
|
||||||
|
- [ ] Run tests
|
||||||
|
|
||||||
|
### Task 6: #7 Minor — Pass pre-computed indicators to _open_position
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py:392,415,736` — pass df_with_indicators
|
||||||
|
|
||||||
|
- [ ] Add `df_with_indicators` parameter to `_open_position`
|
||||||
|
- [ ] Use passed df instead of re-creating Indicators
|
||||||
|
- [ ] Run tests
|
||||||
|
|
||||||
|
### Task 7: #11 Minor — Config input validation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/config.py:39` — add range checks
|
||||||
|
- Test: `tests/test_config.py`
|
||||||
|
|
||||||
|
- [ ] Add validation for LEVERAGE, MARGIN ratios, ML_THRESHOLD
|
||||||
|
- [ ] Add test for invalid values
|
||||||
|
- [ ] Run tests
|
||||||
|
|
||||||
|
### Task 8: #12 Minor — Dynamic correlation symbol access
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot.py:196-198` — iterate dynamically
|
||||||
|
|
||||||
|
- [ ] Replace hardcoded [0]/[1] with dict-based access
|
||||||
|
- [ ] Run tests
|
||||||
|
|
||||||
|
### Task 9: #14 Minor — Normalize NaN handling for LightGBM
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ml_filter.py:144-147` — apply nan_to_num for LightGBM too
|
||||||
|
|
||||||
|
- [ ] Add `np.nan_to_num` to LightGBM path
|
||||||
|
- [ ] Run tests
|
||||||
66
docs/plans/2026-03-21-dashboard-code-review-r2.md
Normal file
66
docs/plans/2026-03-21-dashboard-code-review-r2.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Dashboard Code Review R2
|
||||||
|
|
||||||
|
**날짜**: 2026-03-21
|
||||||
|
**상태**: Completed
|
||||||
|
**커밋**: e362329
|
||||||
|
|
||||||
|
## 원본 리뷰 (23건) → 재평가 결과
|
||||||
|
|
||||||
|
원래 23건의 코드 리뷰 항목 중 15건이 과잉 지적으로 판단되어 삭제, 5건은 Low로 하향, 3건만 Medium 유지. 이후 내부망 전용 환경 감안하여 #3 Health 503도 Low로 하향.
|
||||||
|
|
||||||
|
## 삭제 (15건)
|
||||||
|
|
||||||
|
| # | 이슈 | 삭제 사유 |
|
||||||
|
|---|------|----------|
|
||||||
|
| 1 | SQL Injection — reset_db | 테이블명 하드코딩 리스트, 코드 명백 |
|
||||||
|
| 4 | CORS wildcard + CSRF | X-API-Key 커스텀 헤더가 preflight 강제, CSRF 벡터 없음 |
|
||||||
|
| 5 | get_symbols 쿼리 비효율 | bot_status 수십 건, 최적화 불필요 |
|
||||||
|
| 6 | Signal handler sys.exit() | _shutdown=True → commit → close → exit 순서 이미 방어적 |
|
||||||
|
| 7 | SIGHUP DB 미초기화 | reset_db API가 5개 테이블 DELETE 후 SIGHUP, 설계상 올바름 |
|
||||||
|
| 8 | stale trade 삭제 f-string | parameterized query 패턴, 안전 |
|
||||||
|
| 9 | 파싱 순서 의존성 | 각 패턴이 서로 다른 한국어 키워드, 충돌 불가 |
|
||||||
|
| 10 | PID 파일 경쟁 조건 | Docker 단일 인스턴스 |
|
||||||
|
| 12 | 인라인 스타일 | S 객체로 변수화, 이 규모에서 CSS framework은 오버엔지니어링 |
|
||||||
|
| 15 | fmtTime 라벨 충돌 | 96캔들=24시간, 날짜 겹칠 일 없음 |
|
||||||
|
| 16 | prompt()/confirm() | 관리자 전용 드문 기능, 네이티브 dialog 적절 |
|
||||||
|
| 17 | 함수형 dataKey | 차트 2개 기준선, 성능 영향 0 |
|
||||||
|
| 20 | API Dockerfile requirements 미분리 | 패키지 2개, requirements.txt 오버헤드만 추가 |
|
||||||
|
| 21 | Nginx resolver 하드코딩 | Docker-only 환경 |
|
||||||
|
| 23 | private 메서드 직접 테스트 | 파서 핵심 로직 검증, 자주 리팩토링될 구조 아님 |
|
||||||
|
|
||||||
|
## Low로 하향 (5건, 수정 미진행)
|
||||||
|
|
||||||
|
| # | 이슈 | 하향 사유 |
|
||||||
|
|---|------|----------|
|
||||||
|
| 2 | DB PRAGMA 반복 | SQLite connect()는 파일 open 수준, 15초 폴링에서 병목 아님 |
|
||||||
|
| 11 | App.jsx 모놀리식 | 737줄이나 컴포넌트 파일 내 잘 분리, 추가 기능 계획 없으면 YAGNI |
|
||||||
|
| 13 | 부분 API 실패 | 이전 값 유지가 전체 초기화보다 나은 동작, 15초 후 자동 복구 |
|
||||||
|
| 18 | pos.id undefined | _handle_entry 중복 체크로 발생 확률 극히 낮음 |
|
||||||
|
| 22 | 테스트 환경변수 오염 | dashboard_api import하는 테스트 파일 하나뿐 |
|
||||||
|
|
||||||
|
## Low로 하향 (내부망 감안)
|
||||||
|
|
||||||
|
| # | 이슈 | 하향 사유 |
|
||||||
|
|---|------|----------|
|
||||||
|
| 3 | Health 에러 시 200→503 | 내부망 전용, 로드밸런서 health check 시나리오 없음 |
|
||||||
|
|
||||||
|
## 수정 완료 (2건)
|
||||||
|
|
||||||
|
### #14 Trades 페이지네이션 (Medium)
|
||||||
|
|
||||||
|
**문제**: API가 offset 파라미터를 지원하는데 프론트엔드에서 항상 `limit=50&offset=0`만 호출. tradesTotal > 50이면 나머지를 볼 수 없음.
|
||||||
|
|
||||||
|
**수정** (`dashboard/ui/src/App.jsx`):
|
||||||
|
- `tradesPage` state 추가
|
||||||
|
- fetchAll API 호출에 `offset=${tradesPage * 50}` 반영
|
||||||
|
- `useCallback` dependency에 `tradesPage` 추가
|
||||||
|
- 심볼 변경 시 `setTradesPage(0)` 리셋
|
||||||
|
- Trades 탭 하단에 이전/다음 페이지네이션 컨트롤 추가 (범위 표시: `1–50 / 총건수`)
|
||||||
|
|
||||||
|
### #19 package-lock.json + npm ci (Medium)
|
||||||
|
|
||||||
|
**문제**: `dashboard/ui/Dockerfile`에서 `COPY package.json .` + `npm install`만 사용. package-lock.json이 존재하는데 활용하지 않아 빌드 재현성 미보장.
|
||||||
|
|
||||||
|
**수정** (`dashboard/ui/Dockerfile`):
|
||||||
|
- `COPY package.json package-lock.json .`
|
||||||
|
- `RUN npm ci`
|
||||||
686
docs/plans/2026-03-21-ml-pipeline-fixes.md
Normal file
686
docs/plans/2026-03-21-ml-pipeline-fixes.md
Normal file
@@ -0,0 +1,686 @@
|
|||||||
|
# ML Pipeline Fixes Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** ML 파이프라인의 학습-서빙 불일치(SL/TP 배수, 언더샘플링, 정규화)와 백테스트 정확도 이슈를 수정하여 모델 평가 체계와 실전 환경을 일치시킨다.
|
||||||
|
|
||||||
|
**Architecture:** `dataset_builder.py`의 하드코딩 SL/TP 상수를 파라미터화하고, 모든 호출부(train_model, train_mlx_model, tune_hyperparams, backtester)가 동일한 값을 주입하도록 변경. MLX 학습의 이중 정규화 제거. 백테스터의 에퀴티 커브에 미실현 PnL 반영. MLFilter에 factory method 추가.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, LightGBM, MLX, pandas, numpy, pytest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| 파일 | 변경 유형 | 역할 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `src/dataset_builder.py` | Modify | SL/TP 상수 → 파라미터화 |
|
||||||
|
| `src/ml_filter.py` | Modify | `from_model()` factory method 추가 |
|
||||||
|
| `src/mlx_filter.py` | Modify | fit()에 `normalize` 파라미터 추가 |
|
||||||
|
| `src/backtester.py` | Modify | 에퀴티 미실현 PnL, MLFilter factory, initial_balance |
|
||||||
|
| `src/backtest_validator.py` | Modify | initial_balance 하드코딩 제거 |
|
||||||
|
| `scripts/train_model.py` | Modify | 레거시 상수 제거, SL/TP 전달 |
|
||||||
|
| `scripts/train_mlx_model.py` | Modify | 이중 정규화 제거, stratified_undersample 적용 |
|
||||||
|
| `scripts/tune_hyperparams.py` | Modify | SL/TP 전달 |
|
||||||
|
| `tests/test_dataset_builder.py` | Modify | SL/TP 파라미터 테스트 추가 |
|
||||||
|
| `tests/test_ml_pipeline_fixes.py` | Create | 신규 수정사항 전용 테스트 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: SL/TP 배수 파라미터화 — dataset_builder.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/dataset_builder.py:14-16, 322-383, 385-494`
|
||||||
|
- Test: `tests/test_dataset_builder.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 기존 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh -k "dataset_builder"`
|
||||||
|
Expected: 모든 테스트 PASS
|
||||||
|
|
||||||
|
- [ ] **Step 2: 파라미터화 테스트 작성**
|
||||||
|
|
||||||
|
`tests/test_ml_pipeline_fixes.py`에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
from src.dataset_builder import generate_dataset_vectorized, _calc_labels_vectorized
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def signal_df():
|
||||||
|
"""시그널이 발생하는 데이터."""
|
||||||
|
rng = np.random.default_rng(7)
|
||||||
|
n = 800
|
||||||
|
trend = np.linspace(1.5, 3.0, n)
|
||||||
|
noise = np.cumsum(rng.normal(0, 0.04, n))
|
||||||
|
close = np.clip(trend + noise, 0.01, None)
|
||||||
|
high = close * (1 + rng.uniform(0, 0.015, n))
|
||||||
|
low = close * (1 - rng.uniform(0, 0.015, n))
|
||||||
|
volume = rng.uniform(1e6, 3e6, n)
|
||||||
|
volume[::30] *= 3.0
|
||||||
|
return pd.DataFrame({
|
||||||
|
"open": close, "high": high, "low": low,
|
||||||
|
"close": close, "volume": volume,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_sltp_params_are_passed_through(signal_df):
|
||||||
|
"""SL/TP 배수가 generate_dataset_vectorized에 전달되어야 한다."""
|
||||||
|
r1 = generate_dataset_vectorized(
|
||||||
|
signal_df, atr_sl_mult=1.5, atr_tp_mult=2.0,
|
||||||
|
adx_threshold=0, volume_multiplier=1.5,
|
||||||
|
)
|
||||||
|
r2 = generate_dataset_vectorized(
|
||||||
|
signal_df, atr_sl_mult=2.0, atr_tp_mult=2.0,
|
||||||
|
adx_threshold=0, volume_multiplier=1.5,
|
||||||
|
)
|
||||||
|
# SL이 다르면 레이블 분포가 달라져야 한다
|
||||||
|
if len(r1) > 0 and len(r2) > 0:
|
||||||
|
# 정확히 같은 분포일 확률은 매우 낮음
|
||||||
|
assert not (r1["label"].values == r2["label"].values).all() or len(r1) != len(r2), \
|
||||||
|
"SL 배수가 다르면 레이블이 달라져야 한다"
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_sltp_backward_compatible(signal_df):
|
||||||
|
"""SL/TP 파라미터 미지정 시 기존 기본값(1.5, 2.0)으로 동작해야 한다."""
|
||||||
|
r_default = generate_dataset_vectorized(
|
||||||
|
signal_df, adx_threshold=0, volume_multiplier=1.5,
|
||||||
|
)
|
||||||
|
r_explicit = generate_dataset_vectorized(
|
||||||
|
signal_df, atr_sl_mult=1.5, atr_tp_mult=2.0,
|
||||||
|
adx_threshold=0, volume_multiplier=1.5,
|
||||||
|
)
|
||||||
|
if len(r_default) > 0:
|
||||||
|
assert len(r_default) == len(r_explicit)
|
||||||
|
assert (r_default["label"].values == r_explicit["label"].values).all()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 테스트 실패 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py -v`
|
||||||
|
Expected: FAIL — `generate_dataset_vectorized() got an unexpected keyword argument 'atr_sl_mult'`
|
||||||
|
|
||||||
|
- [ ] **Step 4: dataset_builder.py 수정**
|
||||||
|
|
||||||
|
`src/dataset_builder.py` 변경:
|
||||||
|
|
||||||
|
1. 모듈 상수 `ATR_SL_MULT`, `ATR_TP_MULT`는 기본값으로 유지 (하위 호환)
|
||||||
|
2. `_calc_labels_vectorized`에 `atr_sl_mult`, `atr_tp_mult` 파라미터 추가
|
||||||
|
3. `generate_dataset_vectorized`에 `atr_sl_mult`, `atr_tp_mult` 파라미터 추가하여 `_calc_labels_vectorized`에 전달
|
||||||
|
|
||||||
|
```python
|
||||||
|
# _calc_labels_vectorized 시그니처 변경:
|
||||||
|
def _calc_labels_vectorized(
|
||||||
|
d: pd.DataFrame,
|
||||||
|
feat: pd.DataFrame,
|
||||||
|
sig_idx: np.ndarray,
|
||||||
|
atr_sl_mult: float = ATR_SL_MULT,
|
||||||
|
atr_tp_mult: float = ATR_TP_MULT,
|
||||||
|
) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
|
||||||
|
# 함수 본문 (lines 350-355) 변경:
|
||||||
|
# 변경 전:
|
||||||
|
# sl = entry - atr * ATR_SL_MULT
|
||||||
|
# tp = entry + atr * ATR_TP_MULT
|
||||||
|
# 변경 후:
|
||||||
|
if signal == "LONG":
|
||||||
|
sl = entry - atr * atr_sl_mult
|
||||||
|
tp = entry + atr * atr_tp_mult
|
||||||
|
else:
|
||||||
|
sl = entry + atr * atr_sl_mult
|
||||||
|
tp = entry - atr * atr_tp_mult
|
||||||
|
|
||||||
|
# generate_dataset_vectorized 시그니처 변경:
|
||||||
|
def generate_dataset_vectorized(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
btc_df: pd.DataFrame | None = None,
|
||||||
|
eth_df: pd.DataFrame | None = None,
|
||||||
|
time_weight_decay: float = 0.0,
|
||||||
|
negative_ratio: int = 0,
|
||||||
|
signal_threshold: int = 3,
|
||||||
|
adx_threshold: float = 25,
|
||||||
|
volume_multiplier: float = 2.5,
|
||||||
|
atr_sl_mult: float = ATR_SL_MULT, # 추가
|
||||||
|
atr_tp_mult: float = ATR_TP_MULT, # 추가
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
|
||||||
|
# _calc_labels_vectorized 호출 시 전달:
|
||||||
|
# labels, valid_mask = _calc_labels_vectorized(
|
||||||
|
# d, feat_all, sig_idx,
|
||||||
|
# atr_sl_mult=atr_sl_mult, atr_tp_mult=atr_tp_mult,
|
||||||
|
# )
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py tests/test_dataset_builder.py -v`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/dataset_builder.py tests/test_ml_pipeline_fixes.py
|
||||||
|
git commit -m "feat(ml): parameterize SL/TP multipliers in dataset_builder"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 호출부 SL/TP 전달 — train_model, train_mlx_model, tune_hyperparams, backtester
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/train_model.py:57-58, 217-221, 358-362, 448-452`
|
||||||
|
- Modify: `scripts/train_mlx_model.py:61, 179`
|
||||||
|
- Modify: `scripts/tune_hyperparams.py:67`
|
||||||
|
- Modify: `src/backtester.py:739-746`
|
||||||
|
|
||||||
|
- [ ] **Step 1: train_model.py 수정**
|
||||||
|
|
||||||
|
1. 레거시 모듈 상수 `ATR_SL_MULT=1.5`, `ATR_TP_MULT=3.0` (line 57-58)을 삭제
|
||||||
|
2. `main()`의 argparse에 `--sl-mult` (기본 2.0), `--tp-mult` (기본 2.0) CLI 인자 추가
|
||||||
|
3. `train()`, `walk_forward_auc()`, `compare()` 함수에 `atr_sl_mult`, `atr_tp_mult` 파라미터 추가하여 `generate_dataset_vectorized`에 전달
|
||||||
|
|
||||||
|
```python
|
||||||
|
# argparse에 추가:
|
||||||
|
parser.add_argument("--sl-mult", type=float, default=2.0, help="SL ATR 배수 (기본 2.0)")
|
||||||
|
parser.add_argument("--tp-mult", type=float, default=2.0, help="TP ATR 배수 (기본 2.0)")
|
||||||
|
|
||||||
|
# train() 시그니처:
|
||||||
|
def train(data_path, time_weight_decay=2.0, tuned_params_path=None,
|
||||||
|
atr_sl_mult=2.0, atr_tp_mult=2.0):
|
||||||
|
|
||||||
|
# train() 내:
|
||||||
|
dataset = generate_dataset_vectorized(
|
||||||
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
|
time_weight_decay=time_weight_decay,
|
||||||
|
negative_ratio=5,
|
||||||
|
atr_sl_mult=atr_sl_mult,
|
||||||
|
atr_tp_mult=atr_tp_mult,
|
||||||
|
)
|
||||||
|
|
||||||
|
# main()에서 호출:
|
||||||
|
train(args.data, ..., atr_sl_mult=args.sl_mult, atr_tp_mult=args.tp_mult)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: train_mlx_model.py 수정**
|
||||||
|
|
||||||
|
동일하게 `--sl-mult`, `--tp-mult` CLI 인자 추가. `train_mlx()`, `walk_forward_auc()` 함수에 파라미터 전달.
|
||||||
|
|
||||||
|
- [ ] **Step 3: tune_hyperparams.py 수정**
|
||||||
|
|
||||||
|
`--sl-mult`, `--tp-mult` CLI 인자 추가. `load_dataset()` 함수에 파라미터 전달.
|
||||||
|
|
||||||
|
- [ ] **Step 4: backtester.py WalkForward 수정**
|
||||||
|
|
||||||
|
`WalkForwardBacktester._train_model()` (line 739-746)에서 `generate_dataset_vectorized` 호출 시 `self.cfg.atr_sl_mult`, `self.cfg.atr_tp_mult` 전달:
|
||||||
|
|
||||||
|
```python
|
||||||
|
dataset = generate_dataset_vectorized(
|
||||||
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
|
time_weight_decay=self.cfg.time_weight_decay,
|
||||||
|
negative_ratio=self.cfg.negative_ratio,
|
||||||
|
signal_threshold=self.cfg.signal_threshold,
|
||||||
|
adx_threshold=self.cfg.adx_threshold,
|
||||||
|
volume_multiplier=self.cfg.volume_multiplier,
|
||||||
|
atr_sl_mult=self.cfg.atr_sl_mult,
|
||||||
|
atr_tp_mult=self.cfg.atr_tp_mult,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 전체 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/train_model.py scripts/train_mlx_model.py scripts/tune_hyperparams.py src/backtester.py
|
||||||
|
git commit -m "fix(ml): pass SL/TP multipliers to dataset generation — align train/serve"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: 백테스터 에퀴티 커브 미실현 PnL 반영
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/backtester.py:571-578`
|
||||||
|
- Test: `tests/test_ml_pipeline_fixes.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 테스트 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_equity_curve_includes_unrealized_pnl():
|
||||||
|
"""에퀴티 커브에 미실현 PnL이 반영되어야 한다."""
|
||||||
|
from src.backtester import Backtester, BacktestConfig, Position
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
cfg = BacktestConfig(symbols=["TEST"], initial_balance=1000.0)
|
||||||
|
bt = Backtester.__new__(Backtester)
|
||||||
|
bt.cfg = cfg
|
||||||
|
bt.balance = 1000.0
|
||||||
|
bt._peak_equity = 1000.0
|
||||||
|
bt.equity_curve = []
|
||||||
|
|
||||||
|
# LONG 포지션: 진입가 100, 현재가는 candle row로 전달
|
||||||
|
bt.positions = {"TEST": Position(
|
||||||
|
symbol="TEST", side="LONG", entry_price=100.0,
|
||||||
|
quantity=10.0, sl=95.0, tp=110.0,
|
||||||
|
entry_time=pd.Timestamp("2026-01-01"), entry_fee=0.4,
|
||||||
|
)}
|
||||||
|
|
||||||
|
# candle row에 close=105 → 미실현 PnL = (105-100)*10 = 50
|
||||||
|
row = pd.Series({"close": 105.0})
|
||||||
|
bt._record_equity(pd.Timestamp("2026-01-01 00:15:00"), current_prices={"TEST": 105.0})
|
||||||
|
|
||||||
|
last = bt.equity_curve[-1]
|
||||||
|
assert last["equity"] == 1050.0, f"Expected 1050.0 (1000+50), got {last['equity']}"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py::test_equity_curve_includes_unrealized_pnl -v`
|
||||||
|
Expected: FAIL
|
||||||
|
|
||||||
|
- [ ] **Step 3: _record_equity 수정**
|
||||||
|
|
||||||
|
`src/backtester.py`의 `_record_equity` 메서드를 수정:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _record_equity(self, ts: pd.Timestamp, current_prices: dict[str, float] | None = None):
|
||||||
|
unrealized = 0.0
|
||||||
|
for sym, pos in self.positions.items():
|
||||||
|
price = (current_prices or {}).get(sym)
|
||||||
|
if price is not None:
|
||||||
|
if pos.side == "LONG":
|
||||||
|
unrealized += (price - pos.entry_price) * pos.quantity
|
||||||
|
else:
|
||||||
|
unrealized += (pos.entry_price - price) * pos.quantity
|
||||||
|
equity = self.balance + unrealized
|
||||||
|
self.equity_curve.append({"timestamp": str(ts), "equity": round(equity, 4)})
|
||||||
|
if equity > self._peak_equity:
|
||||||
|
self._peak_equity = equity
|
||||||
|
```
|
||||||
|
|
||||||
|
메인 루프 호출부(`run()` 내 `_record_equity` 호출)도 수정:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# run() 메인 루프 내:
|
||||||
|
current_prices = {}
|
||||||
|
for sym in self.cfg.symbols:
|
||||||
|
idx = ... # 현재 캔들 인덱스
|
||||||
|
current_prices[sym] = float(all_indicators[sym].iloc[...]["close"])
|
||||||
|
self._record_equity(ts, current_prices=current_prices)
|
||||||
|
```
|
||||||
|
|
||||||
|
메인 루프의 이벤트는 `(ts, sym, candle_idx)` 튜플로, 타임스탬프별로 정렬되어 있다 (line 426: `events.sort(key=lambda x: (x[0], x[1]))`). 같은 타임스탬프에 여러 심볼 이벤트가 올 수 있다.
|
||||||
|
|
||||||
|
구현: 이벤트 루프 직전에 `latest_prices: dict[str, float] = {}` 초기화. 각 이벤트에서 `latest_prices[sym] = float(row["close"])` 업데이트. `_record_equity`는 **매 이벤트마다** 호출 (현재 동작 유지). `latest_prices`는 점진적으로 축적되므로, 첫 번째 심볼 이벤트 시점에 다른 심볼은 이전 캔들의 가격이 사용된다. 이는 15분봉 기반에서 미미한 차이이며, 타임스탬프 그룹핑을 도입하면 코드 복잡도가 불필요하게 증가한다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# run() 메인 루프 변경:
|
||||||
|
latest_prices: dict[str, float] = {}
|
||||||
|
|
||||||
|
for ts, sym, candle_idx in events:
|
||||||
|
# ... 기존 로직
|
||||||
|
row = df_ind.iloc[candle_idx]
|
||||||
|
latest_prices[sym] = float(row["close"])
|
||||||
|
|
||||||
|
self._record_equity(ts, current_prices=latest_prices)
|
||||||
|
# ... 나머지 기존 로직 (SL/TP 체크, 진입 등)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py -v`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/backtester.py tests/test_ml_pipeline_fixes.py
|
||||||
|
git commit -m "fix(backtest): include unrealized PnL in equity curve for accurate MDD"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: MLX 이중 정규화 제거
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/mlx_filter.py:139-155`
|
||||||
|
- Modify: `scripts/train_mlx_model.py:218-240`
|
||||||
|
- Test: `tests/test_ml_pipeline_fixes.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 테스트 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_mlx_no_double_normalization():
|
||||||
|
"""MLXFilter.fit()에 normalize=False를 전달하면 내부 정규화를 건너뛰어야 한다."""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from src.mlx_filter import MLXFilter
|
||||||
|
from src.ml_features import FEATURE_COLS
|
||||||
|
|
||||||
|
n_features = len(FEATURE_COLS)
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
X = pd.DataFrame(
|
||||||
|
rng.standard_normal((100, n_features)).astype(np.float32),
|
||||||
|
columns=FEATURE_COLS,
|
||||||
|
)
|
||||||
|
y = pd.Series(rng.integers(0, 2, 100).astype(np.float32))
|
||||||
|
|
||||||
|
model = MLXFilter(input_dim=n_features, hidden_dim=16, epochs=1, batch_size=32)
|
||||||
|
model.fit(X, y, normalize=False)
|
||||||
|
|
||||||
|
# normalize=False면 _mean=0, _std=1이어야 한다
|
||||||
|
assert np.allclose(model._mean, 0.0), "normalize=False시 mean은 0이어야 한다"
|
||||||
|
assert np.allclose(model._std, 1.0, atol=1e-7), "normalize=False시 std는 1이어야 한다"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py::test_mlx_no_double_normalization -v`
|
||||||
|
Expected: FAIL — `fit() got an unexpected keyword argument 'normalize'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: mlx_filter.py 수정**
|
||||||
|
|
||||||
|
`MLXFilter.fit()` 시그니처에 `normalize: bool = True` 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def fit(
|
||||||
|
self,
|
||||||
|
X: pd.DataFrame,
|
||||||
|
y: pd.Series,
|
||||||
|
sample_weight: np.ndarray | None = None,
|
||||||
|
normalize: bool = True,
|
||||||
|
) -> "MLXFilter":
|
||||||
|
X_np = X[FEATURE_COLS].values.astype(np.float32)
|
||||||
|
y_np = y.values.astype(np.float32)
|
||||||
|
|
||||||
|
if normalize:
|
||||||
|
mean_vals = np.nanmean(X_np, axis=0)
|
||||||
|
self._mean = np.nan_to_num(mean_vals, nan=0.0)
|
||||||
|
std_vals = np.nanstd(X_np, axis=0)
|
||||||
|
self._std = np.nan_to_num(std_vals, nan=1.0) + 1e-8
|
||||||
|
X_np = (X_np - self._mean) / self._std
|
||||||
|
X_np = np.nan_to_num(X_np, nan=0.0)
|
||||||
|
else:
|
||||||
|
self._mean = np.zeros(X_np.shape[1], dtype=np.float32)
|
||||||
|
self._std = np.ones(X_np.shape[1], dtype=np.float32)
|
||||||
|
X_np = np.nan_to_num(X_np, nan=0.0)
|
||||||
|
# ... 나머지 동일
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: train_mlx_model.py walk-forward 수정**
|
||||||
|
|
||||||
|
`walk_forward_auc()` (line 218-240)에서 이중 정규화 해킹을 제거:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 변경 전 (해킹):
|
||||||
|
# mean = X_tr_bal.mean(axis=0)
|
||||||
|
# std = X_tr_bal.std(axis=0) + 1e-8
|
||||||
|
# X_tr_norm = (X_tr_bal - mean) / std
|
||||||
|
# X_val_norm = (X_val_raw - mean) / std
|
||||||
|
# ...
|
||||||
|
# model.fit(X_tr_df, pd.Series(y_tr_bal), sample_weight=w_tr_bal)
|
||||||
|
# model._mean = np.zeros(...)
|
||||||
|
# model._std = np.ones(...)
|
||||||
|
|
||||||
|
# 변경 후 (깔끔):
|
||||||
|
X_tr_df = pd.DataFrame(X_tr_bal, columns=FEATURE_COLS)
|
||||||
|
X_val_df = pd.DataFrame(X_val_raw, columns=FEATURE_COLS)
|
||||||
|
|
||||||
|
model = MLXFilter(...)
|
||||||
|
model.fit(X_tr_df, pd.Series(y_tr_bal), sample_weight=w_tr_bal)
|
||||||
|
# fit() 내부에서 학습 데이터 기준으로 정규화
|
||||||
|
# predict_proba()에서 동일한 mean/std 적용
|
||||||
|
|
||||||
|
proba = model.predict_proba(X_val_df)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py -v`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/mlx_filter.py scripts/train_mlx_model.py tests/test_ml_pipeline_fixes.py
|
||||||
|
git commit -m "fix(mlx): remove double normalization in walk-forward validation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: MLX에 stratified_undersample 적용
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/train_mlx_model.py:88-104, 207-212`
|
||||||
|
|
||||||
|
- [ ] **Step 1: train_mlx_model.py train 함수 수정**
|
||||||
|
|
||||||
|
`train_mlx()` (line 88-104)의 단순 언더샘플링을 `stratified_undersample`로 교체:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 변경 전:
|
||||||
|
# pos_idx = np.where(y_train == 1)[0]
|
||||||
|
# neg_idx = np.where(y_train == 0)[0]
|
||||||
|
# if len(neg_idx) > len(pos_idx):
|
||||||
|
# np.random.seed(42)
|
||||||
|
# neg_idx = np.random.choice(neg_idx, size=len(pos_idx), replace=False)
|
||||||
|
# balanced_idx = np.concatenate([pos_idx, neg_idx])
|
||||||
|
# np.random.shuffle(balanced_idx)
|
||||||
|
|
||||||
|
# 변경 후:
|
||||||
|
from src.dataset_builder import stratified_undersample
|
||||||
|
|
||||||
|
source = dataset["source"].values if "source" in dataset.columns else np.full(len(dataset), "signal")
|
||||||
|
source_train = source[:split]
|
||||||
|
balanced_idx = stratified_undersample(y_train.values, source_train, seed=42)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: walk_forward_auc도 동일하게 수정**
|
||||||
|
|
||||||
|
`walk_forward_auc()` (line 207-212)도 `stratified_undersample`로 교체.
|
||||||
|
|
||||||
|
- [ ] **Step 3: negative_ratio 파라미터 추가**
|
||||||
|
|
||||||
|
`train_mlx()` 및 `walk_forward_auc()` 내 `generate_dataset_vectorized` 호출 모두에 `negative_ratio=5` 추가 (LightGBM과 동일):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# train_mlx() 내:
|
||||||
|
dataset = generate_dataset_vectorized(
|
||||||
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
|
time_weight_decay=time_weight_decay,
|
||||||
|
negative_ratio=5,
|
||||||
|
atr_sl_mult=2.0,
|
||||||
|
atr_tp_mult=2.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# walk_forward_auc() 내 (line 179-181):
|
||||||
|
dataset = generate_dataset_vectorized(
|
||||||
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
|
time_weight_decay=time_weight_decay,
|
||||||
|
negative_ratio=5,
|
||||||
|
atr_sl_mult=2.0,
|
||||||
|
atr_tp_mult=2.0,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 전체 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/train_mlx_model.py
|
||||||
|
git commit -m "fix(mlx): use stratified_undersample consistent with LightGBM"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: MLFilter factory method + backtest_validator initial_balance
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ml_filter.py`
|
||||||
|
- Modify: `src/backtester.py:320-329`
|
||||||
|
- Modify: `src/backtest_validator.py:123`
|
||||||
|
- Test: `tests/test_ml_pipeline_fixes.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: MLFilter factory method 테스트**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_ml_filter_from_model():
|
||||||
|
"""MLFilter.from_model()로 LightGBM 모델을 주입할 수 있어야 한다."""
|
||||||
|
from src.ml_filter import MLFilter
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
mock_model = MagicMock()
|
||||||
|
mock_model.predict_proba.return_value = [[0.3, 0.7]]
|
||||||
|
|
||||||
|
mf = MLFilter.from_model(mock_model, threshold=0.55)
|
||||||
|
assert mf.is_model_loaded()
|
||||||
|
assert mf.active_backend == "LightGBM"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py::test_ml_filter_from_model -v`
|
||||||
|
Expected: FAIL — `MLFilter has no attribute 'from_model'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: ml_filter.py에 from_model 추가**
|
||||||
|
|
||||||
|
```python
|
||||||
|
@classmethod
|
||||||
|
def from_model(cls, model, threshold: float = 0.55) -> "MLFilter":
|
||||||
|
"""외부에서 학습된 LightGBM 모델을 주입하여 MLFilter를 생성한다.
|
||||||
|
backtester walk-forward에서 사용."""
|
||||||
|
instance = cls.__new__(cls)
|
||||||
|
instance._disabled = False
|
||||||
|
instance._onnx_session = None
|
||||||
|
instance._lgbm_model = model
|
||||||
|
instance._threshold = threshold
|
||||||
|
instance._onnx_path = Path("/dev/null")
|
||||||
|
instance._lgbm_path = Path("/dev/null")
|
||||||
|
instance._loaded_onnx_mtime = 0.0
|
||||||
|
instance._loaded_lgbm_mtime = 0.0
|
||||||
|
return instance
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: backtester.py에서 factory method 사용**
|
||||||
|
|
||||||
|
`backtester.py:320-329`의 직접 조작 코드를 교체:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 변경 전:
|
||||||
|
# mf = MLFilter.__new__(MLFilter)
|
||||||
|
# mf._disabled = False
|
||||||
|
# mf._onnx_session = None
|
||||||
|
# mf._lgbm_model = ml_models[sym]
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# 변경 후:
|
||||||
|
mf = MLFilter.from_model(ml_models[sym], threshold=self.cfg.ml_threshold)
|
||||||
|
self.ml_filters[sym] = mf
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: backtest_validator.py initial_balance 수정**
|
||||||
|
|
||||||
|
`src/backtest_validator.py:123`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 변경 전:
|
||||||
|
# balance = 1000.0
|
||||||
|
|
||||||
|
# 변경 후 (cfg는 항상 BacktestConfig이므로 hasattr 불필요):
|
||||||
|
balance = cfg.initial_balance
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py -v && bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 7: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ml_filter.py src/backtester.py src/backtest_validator.py tests/test_ml_pipeline_fixes.py
|
||||||
|
git commit -m "refactor(ml): add MLFilter.from_model(), fix validator initial_balance"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: 레거시 코드 정리 + 최종 검증
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/train_model.py:56-103` (레거시 `_process_index`, `generate_dataset` 함수)
|
||||||
|
- Modify: `tests/test_dataset_builder.py:76-93` (레거시 비교 테스트)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 레거시 함수 사용 여부 확인**
|
||||||
|
|
||||||
|
`scripts/train_model.py`의 `_process_index()`, `generate_dataset()` 함수는 현재 `tests/test_dataset_builder.py:84`에서만 참조됨. 이 테스트는 레거시와 벡터화 버전의 샘플 수 비교인데, 두 버전의 SL/TP가 다르므로 (레거시 TP=3.0 vs 벡터화 TP=2.0) 비교 자체가 무의미.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 레거시 비교 테스트 제거**
|
||||||
|
|
||||||
|
`tests/test_dataset_builder.py`에서 `test_matches_original_generate_dataset` 함수를 삭제.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 레거시 함수에 deprecation 경고 추가**
|
||||||
|
|
||||||
|
`scripts/train_model.py`의 `generate_dataset()`, `_process_index()` 함수 상단에:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
def generate_dataset(df: pd.DataFrame, n_jobs: int | None = None) -> pd.DataFrame:
|
||||||
|
"""[Deprecated] generate_dataset_vectorized()를 사용할 것."""
|
||||||
|
warnings.warn(
|
||||||
|
"generate_dataset()는 deprecated. generate_dataset_vectorized()를 사용하세요.",
|
||||||
|
DeprecationWarning, stacklevel=2,
|
||||||
|
)
|
||||||
|
# ... 기존 코드
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 전체 테스트 실행**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/train_model.py tests/test_dataset_builder.py
|
||||||
|
git commit -m "chore: deprecate legacy dataset generation, remove stale comparison test"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: README/ARCHITECTURE 동기화 + CLAUDE.md 업데이트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md` (plan history table)
|
||||||
|
- Modify: `README.md` (필요시)
|
||||||
|
- Modify: `ARCHITECTURE.md` (필요시)
|
||||||
|
|
||||||
|
- [ ] **Step 1: CLAUDE.md plan history 업데이트**
|
||||||
|
|
||||||
|
`CLAUDE.md`의 plan history 테이블에 추가:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| 2026-03-21 | `ml-pipeline-fixes` (plan) | Completed |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 최종 전체 테스트**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs: update plan history with ml-pipeline-fixes"
|
||||||
|
```
|
||||||
303
docs/plans/2026-03-21-ml-validation-pipeline.md
Normal file
303
docs/plans/2026-03-21-ml-validation-pipeline.md
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
# ML Validation Pipeline Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** ML 필터의 실전 가치를 검증하는 `--compare-ml` CLI를 추가하여, 완화된 임계값에서 ML on/off Walk-Forward 백테스트를 자동 비교하고 PF/승률/MDD 개선폭을 리포트한다.
|
||||||
|
|
||||||
|
**Architecture:** `scripts/run_backtest.py`에 `--compare-ml` 플래그를 추가한다. 이 플래그가 활성화되면 WalkForwardBacktester를 `use_ml=True`와 `use_ml=False`로 각각 실행하고, 결과를 나란히 비교하는 리포트를 출력한다. 기존 `Backtester`/`WalkForwardBacktester` 코드는 변경하지 않는다.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, LightGBM, src/backtester.py (기존 모듈 재사용)
|
||||||
|
|
||||||
|
**선행 완료 항목 (이미 구현됨):**
|
||||||
|
- ✅ 학습 전용 상수 (TRAIN_SIGNAL_THRESHOLD=2, TRAIN_ADX_THRESHOLD=15, etc.)
|
||||||
|
- ✅ Purged gap (embargo=LOOKAHEAD) in all walk-forward functions
|
||||||
|
- ✅ Ablation A/B/C CLI (`--ablation`)
|
||||||
|
- ✅ `BacktestConfig.use_ml` 플래그
|
||||||
|
- ✅ `run_backtest.py --no-ml` 지원
|
||||||
|
|
||||||
|
**판단 기준 (합의됨):**
|
||||||
|
- ML on vs ML off의 **상대 PF 개선폭**으로 판단 (절대 기준 아님)
|
||||||
|
- PF 개선 + 승률 개선 + MDD 감소 → 투입 가치 있음
|
||||||
|
- PF 변화 미미 → ML 기여 낮음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| 파일 | 변경 유형 | 역할 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `scripts/run_backtest.py` | Modify | `--compare-ml` CLI + 비교 리포트 |
|
||||||
|
| `CLAUDE.md` | Modify | plan history 업데이트 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `--compare-ml` CLI 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/run_backtest.py:29-55, 151-211`
|
||||||
|
|
||||||
|
- [ ] **Step 1: argparse에 --compare-ml 추가**
|
||||||
|
|
||||||
|
`scripts/run_backtest.py`의 `parse_args()` 함수에:
|
||||||
|
|
||||||
|
```python
|
||||||
|
p.add_argument("--compare-ml", action="store_true",
|
||||||
|
help="ML on vs off Walk-Forward 비교 (--walk-forward 자동 활성화)")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: compare_ml 함수 작성**
|
||||||
|
|
||||||
|
`scripts/run_backtest.py`에 `compare_ml()` 함수 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def compare_ml(symbols: list[str], args):
|
||||||
|
"""ML on vs ML off Walk-Forward 백테스트 비교.
|
||||||
|
|
||||||
|
완화된 임계값(threshold=2)에서 ML 필터의 실질적 가치를 검증한다.
|
||||||
|
판단 기준: 상대 PF 개선폭 (절대 기준 아님).
|
||||||
|
"""
|
||||||
|
base_kwargs = dict(
|
||||||
|
symbols=symbols,
|
||||||
|
start=args.start,
|
||||||
|
end=args.end,
|
||||||
|
initial_balance=args.balance,
|
||||||
|
leverage=args.leverage,
|
||||||
|
fee_pct=args.fee,
|
||||||
|
slippage_pct=args.slippage,
|
||||||
|
ml_threshold=args.ml_threshold,
|
||||||
|
atr_sl_mult=args.sl_atr,
|
||||||
|
atr_tp_mult=args.tp_atr,
|
||||||
|
signal_threshold=args.signal_threshold,
|
||||||
|
adx_threshold=args.adx_threshold,
|
||||||
|
volume_multiplier=args.vol_multiplier,
|
||||||
|
train_months=args.train_months,
|
||||||
|
test_months=args.test_months,
|
||||||
|
)
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for label, use_ml in [("ML OFF", False), ("ML ON", True)]:
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" Walk-Forward 백테스트: {label}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
cfg = WalkForwardConfig(**base_kwargs, use_ml=use_ml)
|
||||||
|
wf = WalkForwardBacktester(cfg)
|
||||||
|
result = wf.run()
|
||||||
|
results[label] = result
|
||||||
|
print_summary(result["summary"], cfg, mode="walk_forward")
|
||||||
|
if result.get("folds"):
|
||||||
|
print_fold_table(result["folds"])
|
||||||
|
|
||||||
|
# 비교 리포트
|
||||||
|
_print_comparison(results, symbols)
|
||||||
|
|
||||||
|
# 결과 저장
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
if len(symbols) == 1:
|
||||||
|
out_dir = Path(f"results/{symbols[0].lower()}")
|
||||||
|
else:
|
||||||
|
out_dir = Path("results/combined")
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = out_dir / f"ml_comparison_{ts}.json"
|
||||||
|
|
||||||
|
comparison = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"symbols": symbols,
|
||||||
|
"ml_off": results["ML OFF"]["summary"],
|
||||||
|
"ml_on": results["ML ON"]["summary"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def sanitize(obj):
|
||||||
|
if isinstance(obj, bool):
|
||||||
|
return obj
|
||||||
|
if isinstance(obj, (int, float)):
|
||||||
|
if isinstance(obj, float) and obj == float("inf"):
|
||||||
|
return "Infinity"
|
||||||
|
return obj
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: sanitize(v) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [sanitize(v) for v in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(sanitize(comparison), f, indent=2, ensure_ascii=False)
|
||||||
|
print(f"\n비교 결과 저장: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_comparison(results: dict, symbols: list[str]):
|
||||||
|
"""ML on vs off 비교 리포트 출력."""
|
||||||
|
off = results["ML OFF"]["summary"]
|
||||||
|
on = results["ML ON"]["summary"]
|
||||||
|
|
||||||
|
print(f"\n{'='*64}")
|
||||||
|
print(f" ML ON vs OFF 비교 ({', '.join(symbols)})")
|
||||||
|
print(f"{'='*64}")
|
||||||
|
print(f" {'지표':<20} {'ML OFF':>12} {'ML ON':>12} {'Delta':>12}")
|
||||||
|
print(f"{'─'*64}")
|
||||||
|
|
||||||
|
metrics = [
|
||||||
|
("총 거래", "total_trades", "d"),
|
||||||
|
("총 PnL (USDT)", "total_pnl", ".2f"),
|
||||||
|
("수익률 (%)", "return_pct", ".2f"),
|
||||||
|
("승률 (%)", "win_rate", ".1f"),
|
||||||
|
("Profit Factor", "profit_factor", ".2f"),
|
||||||
|
("MDD (%)", "max_drawdown_pct", ".2f"),
|
||||||
|
("Sharpe", "sharpe_ratio", ".2f"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for label, key, fmt in metrics:
|
||||||
|
v_off = off.get(key, 0)
|
||||||
|
v_on = on.get(key, 0)
|
||||||
|
# inf 처리
|
||||||
|
if v_off == float("inf"):
|
||||||
|
v_off_str = "INF"
|
||||||
|
else:
|
||||||
|
v_off_str = f"{v_off:{fmt}}"
|
||||||
|
if v_on == float("inf"):
|
||||||
|
v_on_str = "INF"
|
||||||
|
else:
|
||||||
|
v_on_str = f"{v_on:{fmt}}"
|
||||||
|
|
||||||
|
if isinstance(v_off, (int, float)) and isinstance(v_on, (int, float)) \
|
||||||
|
and v_off != float("inf") and v_on != float("inf"):
|
||||||
|
delta = v_on - v_off
|
||||||
|
sign = "+" if delta > 0 else ""
|
||||||
|
delta_str = f"{sign}{delta:{fmt}}"
|
||||||
|
else:
|
||||||
|
delta_str = "N/A"
|
||||||
|
|
||||||
|
print(f" {label:<20} {v_off_str:>12} {v_on_str:>12} {delta_str:>12}")
|
||||||
|
|
||||||
|
# 판정
|
||||||
|
pf_off = off.get("profit_factor", 0)
|
||||||
|
pf_on = on.get("profit_factor", 0)
|
||||||
|
wr_off = off.get("win_rate", 0)
|
||||||
|
wr_on = on.get("win_rate", 0)
|
||||||
|
mdd_off = off.get("max_drawdown_pct", 0)
|
||||||
|
mdd_on = on.get("max_drawdown_pct", 0)
|
||||||
|
|
||||||
|
print(f"{'─'*64}")
|
||||||
|
|
||||||
|
if pf_off == float("inf") or pf_on == float("inf"):
|
||||||
|
print(f" 판정: PF=INF — 한쪽 모드에서 손실 거래 없음 (거래 수 부족 가능), 판단 보류")
|
||||||
|
elif pf_off == 0:
|
||||||
|
print(f" 판정: ML OFF PF=0 — baseline 거래 없음, 판단 불가")
|
||||||
|
else:
|
||||||
|
pf_improvement = pf_on - pf_off
|
||||||
|
wr_improvement = wr_on - wr_off
|
||||||
|
mdd_improvement = mdd_off - mdd_on # MDD는 낮을수록 좋음
|
||||||
|
|
||||||
|
# 판정 임계값 (초기값 — 실제 백테스트 결과를 보고 조정 가능)
|
||||||
|
improvements = []
|
||||||
|
if pf_improvement > 0.1:
|
||||||
|
improvements.append(f"PF +{pf_improvement:.2f}")
|
||||||
|
if wr_improvement > 2.0:
|
||||||
|
improvements.append(f"승률 +{wr_improvement:.1f}%p")
|
||||||
|
if mdd_improvement > 1.0:
|
||||||
|
improvements.append(f"MDD -{mdd_improvement:.1f}%p")
|
||||||
|
|
||||||
|
if len(improvements) >= 2:
|
||||||
|
verdict = f"✅ ML 필터 투입 가치 있음 ({', '.join(improvements)})"
|
||||||
|
elif len(improvements) == 1:
|
||||||
|
verdict = f"⚠️ ML 필터 조건부 투입 ({improvements[0]}, 다른 지표 변화 미미)"
|
||||||
|
else:
|
||||||
|
verdict = f"❌ ML 필터 기여 미미 (PF {pf_improvement:+.2f}, 승률 {wr_improvement:+.1f}%p)"
|
||||||
|
print(f" 판정: {verdict}")
|
||||||
|
|
||||||
|
print(f"{'='*64}\n")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: main()에 --compare-ml 분기 추가**
|
||||||
|
|
||||||
|
`scripts/run_backtest.py`의 `main()` 함수에서 `if args.walk_forward:` 블록 **앞에** 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if args.compare_ml:
|
||||||
|
if args.no_ml:
|
||||||
|
logger.warning("--no-ml is ignored when using --compare-ml")
|
||||||
|
compare_ml(symbols, args)
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 전체 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS (기존 테스트 영향 없음)
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/run_backtest.py
|
||||||
|
git commit -m "feat(backtest): add --compare-ml for ML on/off walk-forward comparison"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: CLAUDE.md 업데이트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: plan history 업데이트**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| 2026-03-21 | `ml-validation-pipeline` (plan) | Completed |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs: update plan history with ml-validation-pipeline"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 후 실행 가이드
|
||||||
|
|
||||||
|
### Phase 1: Ablation 진단 (이미 구현됨)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 심볼별 ablation 실행
|
||||||
|
python scripts/train_model.py --symbol XRPUSDT --ablation
|
||||||
|
python scripts/train_model.py --symbol SOLUSDT --ablation
|
||||||
|
python scripts/train_model.py --symbol DOGEUSDT --ablation
|
||||||
|
```
|
||||||
|
|
||||||
|
판단:
|
||||||
|
- A→C 드롭 ≤ 0.05 → Phase 2로 진행
|
||||||
|
- A→C 드롭 ≥ 0.10 → ML 재설계 필요 (중단)
|
||||||
|
|
||||||
|
### Phase 2: ML on/off 비교 (이 플랜에서 구현)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 완화된 임계값(threshold=2)로 ML 비교
|
||||||
|
python scripts/run_backtest.py --symbol XRPUSDT --compare-ml \
|
||||||
|
--signal-threshold 2 --adx-threshold 15 --vol-multiplier 1.5 --walk-forward
|
||||||
|
|
||||||
|
python scripts/run_backtest.py --symbol SOLUSDT --compare-ml \
|
||||||
|
--signal-threshold 2 --adx-threshold 15 --vol-multiplier 1.5 --walk-forward
|
||||||
|
|
||||||
|
python scripts/run_backtest.py --symbol DOGEUSDT --compare-ml \
|
||||||
|
--signal-threshold 2 --adx-threshold 15 --vol-multiplier 1.5 --walk-forward
|
||||||
|
```
|
||||||
|
|
||||||
|
판단: 상대 PF 개선폭으로 ML 가치 평가
|
||||||
|
|
||||||
|
### Phase 3: 실전 점진적 전환 (코드 변경 불필요)
|
||||||
|
|
||||||
|
Phase 1, 2 모두 긍정적이면 `.env`로 1심볼부터 적용:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env에 추가 (1심볼만 먼저)
|
||||||
|
SIGNAL_THRESHOLD_XRPUSDT=2
|
||||||
|
ADX_THRESHOLD_XRPUSDT=15
|
||||||
|
VOL_MULTIPLIER_XRPUSDT=1.5
|
||||||
|
|
||||||
|
# 나머지 심볼은 기존 값 유지
|
||||||
|
# SIGNAL_THRESHOLD_SOLUSDT=3 (기본값)
|
||||||
|
# SIGNAL_THRESHOLD_DOGEUSDT=3 (기본값)
|
||||||
|
```
|
||||||
|
|
||||||
|
1~2주 운영 후 kill switch 미발동 + PnL 양호하면 나머지 심볼도 전환.
|
||||||
399
docs/plans/2026-03-21-purged-gap-and-ablation.md
Normal file
399
docs/plans/2026-03-21-purged-gap-and-ablation.md
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
# Purged Gap + Feature Ablation Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Walk-Forward 검증에 purged gap(embargo)을 추가하여 레이블 누수를 제거하고, feature ablation으로 signal_strength/side 의존도를 진단하여 ML 필터의 실질적 예측력을 검증한다.
|
||||||
|
|
||||||
|
**Architecture:** 3개의 walk-forward 함수(train_model.py, train_mlx_model.py, tune_hyperparams.py)의 검증 시작 인덱스에 `LOOKAHEAD` 만큼의 embargo를 추가한다. `train_model.py`에 `--ablation` CLI 플래그를 추가하여 A/B/C 실험을 자동 실행하고 상대 드롭을 출력한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, LightGBM, numpy, sklearn, pytest
|
||||||
|
|
||||||
|
**판단 기준 (합의됨):**
|
||||||
|
- A→C 드롭 ≤ 0.05: ML 필터 가치 있음
|
||||||
|
- A→C 드롭 0.05~0.10: 조건부 투입
|
||||||
|
- A→C 드롭 ≥ 0.10: 재설계 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| 파일 | 변경 유형 | 역할 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `scripts/train_model.py` | Modify | purged gap + ablation CLI |
|
||||||
|
| `scripts/train_mlx_model.py` | Modify | purged gap |
|
||||||
|
| `scripts/tune_hyperparams.py` | Modify | purged gap |
|
||||||
|
| `tests/test_ml_pipeline_fixes.py` | Modify | purged gap 테스트 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: walk-forward에 purged gap(embargo) 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/train_model.py:389-396`
|
||||||
|
- Modify: `scripts/train_mlx_model.py:194-204`
|
||||||
|
- Modify: `scripts/tune_hyperparams.py:153-160`
|
||||||
|
- Test: `tests/test_ml_pipeline_fixes.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: purged gap 테스트 작성**
|
||||||
|
|
||||||
|
`tests/test_ml_pipeline_fixes.py`에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_walk_forward_purged_gap():
|
||||||
|
"""Walk-Forward 검증에서 학습/검증 사이에 LOOKAHEAD 만큼의 gap이 존재해야 한다."""
|
||||||
|
from src.dataset_builder import LOOKAHEAD
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# 시뮬레이션: n=1000, train_ratio=0.6, n_splits=5
|
||||||
|
n = 1000
|
||||||
|
train_ratio = 0.6
|
||||||
|
n_splits = 5
|
||||||
|
embargo = LOOKAHEAD # 24
|
||||||
|
|
||||||
|
step = max(1, int(n * (1 - train_ratio) / n_splits))
|
||||||
|
train_end_start = int(n * train_ratio)
|
||||||
|
|
||||||
|
for fold_idx in range(n_splits):
|
||||||
|
tr_end = train_end_start + fold_idx * step
|
||||||
|
val_start = tr_end + embargo # purged gap
|
||||||
|
val_end = val_start + step
|
||||||
|
if val_end > n:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 학습 마지막 인덱스와 검증 첫 인덱스 사이에 최소 embargo 캔들 gap
|
||||||
|
assert val_start - tr_end >= embargo, \
|
||||||
|
f"폴드 {fold_idx}: gap={val_start - tr_end} < embargo={embargo}"
|
||||||
|
# 검증 구간이 학습 구간과 겹치지 않아야 한다
|
||||||
|
assert val_start > tr_end, \
|
||||||
|
f"폴드 {fold_idx}: val_start={val_start} <= tr_end={tr_end}"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 통과 확인 (로직 테스트이므로 바로 PASS)**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py::test_walk_forward_purged_gap -v`
|
||||||
|
Expected: PASS (이 테스트는 로직만 검증하므로 코드 변경 없이도 통과)
|
||||||
|
|
||||||
|
- [ ] **Step 3: train_model.py walk_forward_auc() 수정**
|
||||||
|
|
||||||
|
`scripts/train_model.py`의 `walk_forward_auc()` 함수 내 폴드 루프(~line 389-396):
|
||||||
|
|
||||||
|
변경 전:
|
||||||
|
```python
|
||||||
|
for i in range(n_splits):
|
||||||
|
tr_end = train_end_start + i * step
|
||||||
|
val_end = tr_end + step
|
||||||
|
if val_end > n:
|
||||||
|
break
|
||||||
|
|
||||||
|
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
|
||||||
|
X_val, y_val = X[tr_end:val_end], y[tr_end:val_end]
|
||||||
|
```
|
||||||
|
|
||||||
|
변경 후:
|
||||||
|
```python
|
||||||
|
from src.dataset_builder import LOOKAHEAD
|
||||||
|
|
||||||
|
for i in range(n_splits):
|
||||||
|
tr_end = train_end_start + i * step
|
||||||
|
val_start = tr_end + LOOKAHEAD # purged gap: 레이블 누수 방지
|
||||||
|
val_end = val_start + step
|
||||||
|
if val_end > n:
|
||||||
|
break
|
||||||
|
|
||||||
|
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
|
||||||
|
X_val, y_val = X[val_start:val_end], y[val_start:val_end]
|
||||||
|
```
|
||||||
|
|
||||||
|
`source_tr`는 기존과 동일하게 `source[:tr_end]`.
|
||||||
|
|
||||||
|
출력 문자열도 업데이트:
|
||||||
|
```python
|
||||||
|
print(
|
||||||
|
f" 폴드 {i+1}/{n_splits}: 학습={tr_end}개, "
|
||||||
|
f"검증={val_start}~{val_end} ({step}개, embargo={LOOKAHEAD}), AUC={auc:.4f} | "
|
||||||
|
f"Thr={f_thr:.4f} Prec={f_prec:.3f} Rec={f_rec:.3f}"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: train_mlx_model.py walk_forward_auc() 동일 수정**
|
||||||
|
|
||||||
|
`scripts/train_mlx_model.py`의 `walk_forward_auc()` 폴드 루프(~line 194-204):
|
||||||
|
|
||||||
|
변경 전:
|
||||||
|
```python
|
||||||
|
X_val_raw = X_all[tr_end:val_end]
|
||||||
|
y_val = y_all[tr_end:val_end]
|
||||||
|
```
|
||||||
|
|
||||||
|
변경 후:
|
||||||
|
```python
|
||||||
|
from src.dataset_builder import LOOKAHEAD
|
||||||
|
|
||||||
|
val_start = tr_end + LOOKAHEAD
|
||||||
|
val_end = val_start + step
|
||||||
|
if val_end > n:
|
||||||
|
break
|
||||||
|
|
||||||
|
X_val_raw = X_all[val_start:val_end]
|
||||||
|
y_val = y_all[val_start:val_end]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: tune_hyperparams.py _walk_forward_cv() 동일 수정**
|
||||||
|
|
||||||
|
`scripts/tune_hyperparams.py`의 `_walk_forward_cv()` 폴드 루프(~line 153-160):
|
||||||
|
|
||||||
|
변경 전:
|
||||||
|
```python
|
||||||
|
X_val, y_val = X[tr_end:val_end], y[tr_end:val_end]
|
||||||
|
```
|
||||||
|
|
||||||
|
변경 후:
|
||||||
|
```python
|
||||||
|
from src.dataset_builder import LOOKAHEAD
|
||||||
|
|
||||||
|
val_start = tr_end + LOOKAHEAD
|
||||||
|
val_end = val_start + step
|
||||||
|
if val_end > n:
|
||||||
|
break
|
||||||
|
|
||||||
|
X_val, y_val = X[val_start:val_end], y[val_start:val_end]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: 전체 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 7: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/train_model.py scripts/train_mlx_model.py scripts/tune_hyperparams.py tests/test_ml_pipeline_fixes.py
|
||||||
|
git commit -m "fix(ml): add purged gap (embargo=LOOKAHEAD) to walk-forward validation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Feature ablation 실험 CLI 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/train_model.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: ablation 함수 추가**
|
||||||
|
|
||||||
|
`scripts/train_model.py`에 `ablation()` 함수를 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def ablation(
|
||||||
|
data_path: str,
|
||||||
|
time_weight_decay: float = 2.0,
|
||||||
|
n_splits: int = 5,
|
||||||
|
train_ratio: float = 0.6,
|
||||||
|
tuned_params_path: str | None = None,
|
||||||
|
atr_sl_mult: float = 2.0,
|
||||||
|
atr_tp_mult: float = 2.0,
|
||||||
|
) -> None:
|
||||||
|
"""Feature ablation 실험: signal_strength/side 의존도 진단.
|
||||||
|
|
||||||
|
실험 A: 전체 피처 (baseline)
|
||||||
|
실험 B: signal_strength 제거
|
||||||
|
실험 C: signal_strength + side 제거
|
||||||
|
|
||||||
|
판단 기준 (절대 AUC 차이):
|
||||||
|
A→C ≤ 0.05: ML 필터 가치 있음 (다른 피처가 충분히 기여)
|
||||||
|
A→C 0.05~0.10: 조건부 투입 (signal_strength 의존도 높지만 다른 피처도 기여)
|
||||||
|
A→C ≥ 0.10: 재설계 필요 (사실상 점수 재확인기)
|
||||||
|
"""
|
||||||
|
import warnings
|
||||||
|
from src.dataset_builder import LOOKAHEAD
|
||||||
|
|
||||||
|
print(f"\n{'='*64}")
|
||||||
|
print(f" Feature Ablation 실험 ({n_splits}폴드 Walk-Forward, embargo={LOOKAHEAD})")
|
||||||
|
print(f"{'='*64}")
|
||||||
|
|
||||||
|
df_raw = pd.read_parquet(data_path)
|
||||||
|
base_cols = ["open", "high", "low", "close", "volume"]
|
||||||
|
btc_df = eth_df = None
|
||||||
|
if "close_btc" in df_raw.columns:
|
||||||
|
btc_df = df_raw[[c + "_btc" for c in base_cols]].copy()
|
||||||
|
btc_df.columns = base_cols
|
||||||
|
if "close_eth" in df_raw.columns:
|
||||||
|
eth_df = df_raw[[c + "_eth" for c in base_cols]].copy()
|
||||||
|
eth_df.columns = base_cols
|
||||||
|
df = df_raw[base_cols].copy()
|
||||||
|
|
||||||
|
dataset = generate_dataset_vectorized(
|
||||||
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
|
time_weight_decay=time_weight_decay,
|
||||||
|
atr_sl_mult=atr_sl_mult,
|
||||||
|
atr_tp_mult=atr_tp_mult,
|
||||||
|
)
|
||||||
|
actual_feature_cols = [c for c in FEATURE_COLS if c in dataset.columns]
|
||||||
|
y = dataset["label"].values
|
||||||
|
w = dataset["sample_weight"].values
|
||||||
|
n = len(dataset)
|
||||||
|
source = dataset["source"].values if "source" in dataset.columns else np.full(n, "signal")
|
||||||
|
|
||||||
|
lgbm_params, weight_scale = _load_lgbm_params(tuned_params_path)
|
||||||
|
w = (w * weight_scale).astype(np.float32)
|
||||||
|
|
||||||
|
# 실험 정의
|
||||||
|
experiments = {
|
||||||
|
"A (전체 피처)": actual_feature_cols,
|
||||||
|
"B (-signal_strength)": [c for c in actual_feature_cols if c != "signal_strength"],
|
||||||
|
"C (-signal_strength, -side)": [c for c in actual_feature_cols if c not in ("signal_strength", "side")],
|
||||||
|
}
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for exp_name, cols in experiments.items():
|
||||||
|
X = dataset[cols].values
|
||||||
|
step = max(1, int(n * (1 - train_ratio) / n_splits))
|
||||||
|
train_end_start = int(n * train_ratio)
|
||||||
|
|
||||||
|
fold_aucs = []
|
||||||
|
fold_importances = []
|
||||||
|
for fold_idx in range(n_splits):
|
||||||
|
tr_end = train_end_start + fold_idx * step
|
||||||
|
val_start = tr_end + LOOKAHEAD
|
||||||
|
val_end = val_start + step
|
||||||
|
if val_end > n:
|
||||||
|
break
|
||||||
|
|
||||||
|
X_tr, y_tr, w_tr = X[:tr_end], y[:tr_end], w[:tr_end]
|
||||||
|
X_val, y_val = X[val_start:val_end], y[val_start:val_end]
|
||||||
|
|
||||||
|
source_tr = source[:tr_end]
|
||||||
|
idx = stratified_undersample(y_tr, source_tr, seed=42)
|
||||||
|
|
||||||
|
model = lgb.LGBMClassifier(**lgbm_params, random_state=42, verbose=-1)
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore")
|
||||||
|
model.fit(X_tr[idx], y_tr[idx], sample_weight=w_tr[idx])
|
||||||
|
|
||||||
|
proba = model.predict_proba(X_val)[:, 1]
|
||||||
|
auc = roc_auc_score(y_val, proba) if len(np.unique(y_val)) > 1 else 0.5
|
||||||
|
fold_aucs.append(auc)
|
||||||
|
fold_importances.append(dict(zip(cols, model.feature_importances_)))
|
||||||
|
|
||||||
|
mean_auc = float(np.mean(fold_aucs))
|
||||||
|
std_auc = float(np.std(fold_aucs))
|
||||||
|
results[exp_name] = {
|
||||||
|
"mean_auc": mean_auc,
|
||||||
|
"std_auc": std_auc,
|
||||||
|
"fold_aucs": fold_aucs,
|
||||||
|
"importances": fold_importances,
|
||||||
|
}
|
||||||
|
print(f"\n {exp_name}: AUC={mean_auc:.4f} ± {std_auc:.4f}")
|
||||||
|
print(f" 폴드별: {[round(a, 4) for a in fold_aucs]}")
|
||||||
|
|
||||||
|
# 실험 A에서만 feature importance top 10 출력
|
||||||
|
if exp_name.startswith("A"):
|
||||||
|
avg_imp = {}
|
||||||
|
for imp in fold_importances:
|
||||||
|
for k, v in imp.items():
|
||||||
|
avg_imp[k] = avg_imp.get(k, 0) + v / len(fold_importances)
|
||||||
|
top10 = sorted(avg_imp.items(), key=lambda x: x[1], reverse=True)[:10]
|
||||||
|
print(f" Feature Importance Top 10:")
|
||||||
|
for feat_name, imp_val in top10:
|
||||||
|
marker = " ← 주의" if feat_name in ("signal_strength", "side") else ""
|
||||||
|
print(f" {feat_name:<25} {imp_val:>8.1f}{marker}")
|
||||||
|
|
||||||
|
# 드롭 분석
|
||||||
|
auc_a = results["A (전체 피처)"]["mean_auc"]
|
||||||
|
auc_b = results["B (-signal_strength)"]["mean_auc"]
|
||||||
|
auc_c = results["C (-signal_strength, -side)"]["mean_auc"]
|
||||||
|
drop_ab = auc_a - auc_b
|
||||||
|
drop_ac = auc_a - auc_c
|
||||||
|
|
||||||
|
print(f"\n{'='*64}")
|
||||||
|
print(f" 드롭 분석")
|
||||||
|
print(f"{'='*64}")
|
||||||
|
print(f" A → B (signal_strength 제거): {drop_ab:+.4f}")
|
||||||
|
print(f" A → C (signal_strength + side 제거): {drop_ac:+.4f}")
|
||||||
|
print(f"{'─'*64}")
|
||||||
|
|
||||||
|
if drop_ac <= 0.05:
|
||||||
|
verdict = "✅ ML 필터 가치 있음 (다른 피처가 충분히 기여)"
|
||||||
|
elif drop_ac <= 0.10:
|
||||||
|
verdict = "⚠️ 조건부 투입 (signal_strength 의존도 높지만 다른 피처도 기여)"
|
||||||
|
else:
|
||||||
|
verdict = "❌ 재설계 필요 (사실상 점수 재확인기)"
|
||||||
|
print(f" 판정: {verdict}")
|
||||||
|
print(f"{'='*64}\n")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: CLI에 --ablation 플래그 추가**
|
||||||
|
|
||||||
|
`scripts/train_model.py`의 `main()` 내 argparse에:
|
||||||
|
|
||||||
|
```python
|
||||||
|
parser.add_argument("--ablation", action="store_true",
|
||||||
|
help="Feature ablation 실험 (signal_strength/side 의존도 진단)")
|
||||||
|
```
|
||||||
|
|
||||||
|
main() 분기에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if args.ablation:
|
||||||
|
ablation(
|
||||||
|
args.data, time_weight_decay=args.decay,
|
||||||
|
tuned_params_path=args.tuned_params,
|
||||||
|
atr_sl_mult=args.sl_mult, atr_tp_mult=args.tp_mult,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 `elif args.compare:` 앞에 배치.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 전체 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/train_model.py
|
||||||
|
git commit -m "feat(ml): add --ablation CLI for signal_strength/side dependency diagnosis"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: CLAUDE.md 업데이트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: plan history 업데이트**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| 2026-03-21 | `purged-gap-and-ablation` (plan) | Completed |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs: update plan history with purged-gap-and-ablation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 후 실행 가이드
|
||||||
|
|
||||||
|
구현 완료 후 다음 순서로 실행:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Purged gap 적용된 Walk-Forward (심볼별)
|
||||||
|
python scripts/train_model.py --symbol XRPUSDT --wf
|
||||||
|
python scripts/train_model.py --symbol SOLUSDT --wf
|
||||||
|
python scripts/train_model.py --symbol DOGEUSDT --wf
|
||||||
|
|
||||||
|
# 2. Ablation 실험 (심볼별)
|
||||||
|
python scripts/train_model.py --symbol XRPUSDT --ablation
|
||||||
|
python scripts/train_model.py --symbol SOLUSDT --ablation
|
||||||
|
python scripts/train_model.py --symbol DOGEUSDT --ablation
|
||||||
|
```
|
||||||
|
|
||||||
|
결과를 보고 판단:
|
||||||
|
- Purged AUC가 0.85+ 유지되면 모델 유효
|
||||||
|
- A→C 드롭이 0.05 이내면 ML 필터 실전 투입 가치 있음
|
||||||
|
- 두 조건 모두 충족 시 PF 계산(Task 미포함, 별도 판단 후 추가)으로 진행
|
||||||
254
docs/plans/2026-03-21-training-threshold-relaxation.md
Normal file
254
docs/plans/2026-03-21-training-threshold-relaxation.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# Training Threshold Relaxation Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** ML 학습용 신호 임계값을 완화하여 학습 샘플을 5~10배 증가시키고, 모델이 의미 있는 패턴을 학습할 수 있도록 한다.
|
||||||
|
|
||||||
|
**Architecture:** `dataset_builder.py`에 학습 전용 상수 블록(`TRAIN_*`)을 추가하고, `generate_dataset_vectorized()`의 기본값을 이 상수로 변경한다. 모든 호출부(train_model, train_mlx_model, tune_hyperparams)는 기본값을 따르므로 호출부 코드 변경 없이 적용된다. 실전 봇(`bot.py`)과 백테스터 시뮬레이션(`Backtester.run`)은 `config.py`의 엄격한 임계값을 별도로 사용하므로 영향 없다.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, pandas, numpy, LightGBM, pytest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| 파일 | 변경 유형 | 역할 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `src/dataset_builder.py` | Modify | 학습 전용 상수 추가 + 기본값 변경 |
|
||||||
|
| `scripts/train_model.py` | Modify | 하드코딩된 `negative_ratio=5` → 기본값 사용으로 전환 |
|
||||||
|
| `scripts/train_mlx_model.py` | Modify | 동일 |
|
||||||
|
| `scripts/tune_hyperparams.py` | Modify | 동일 |
|
||||||
|
| `src/backtester.py` | Modify | `WalkForwardConfig.negative_ratio` 기본값 변경 |
|
||||||
|
| `tests/test_dataset_builder.py` | Modify | 완화된 기본값 반영 |
|
||||||
|
| `tests/test_ml_pipeline_fixes.py` | Modify | 새 기본값 검증 테스트 추가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: dataset_builder.py에 학습 전용 상수 추가 + 기본값 변경
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/dataset_builder.py:14-17, 387-397`
|
||||||
|
- Test: `tests/test_ml_pipeline_fixes.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 테스트 작성**
|
||||||
|
|
||||||
|
`tests/test_ml_pipeline_fixes.py`에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_training_defaults_are_relaxed(signal_df):
|
||||||
|
"""generate_dataset_vectorized의 기본 임계값이 학습용 완화 값이어야 한다."""
|
||||||
|
from src.dataset_builder import (
|
||||||
|
TRAIN_SIGNAL_THRESHOLD, TRAIN_ADX_THRESHOLD,
|
||||||
|
TRAIN_VOLUME_MULTIPLIER, TRAIN_NEGATIVE_RATIO,
|
||||||
|
)
|
||||||
|
assert TRAIN_SIGNAL_THRESHOLD == 2
|
||||||
|
assert TRAIN_ADX_THRESHOLD == 15.0
|
||||||
|
assert TRAIN_VOLUME_MULTIPLIER == 1.5
|
||||||
|
assert TRAIN_NEGATIVE_RATIO == 3
|
||||||
|
|
||||||
|
# 완화된 기본값으로 샘플이 더 많이 생성되는지 검증
|
||||||
|
r_relaxed = generate_dataset_vectorized(signal_df)
|
||||||
|
r_strict = generate_dataset_vectorized(
|
||||||
|
signal_df, signal_threshold=3, adx_threshold=25, volume_multiplier=2.5,
|
||||||
|
)
|
||||||
|
assert len(r_relaxed) >= len(r_strict), \
|
||||||
|
f"완화된 임계값이 더 많은 샘플을 생성해야 한다: relaxed={len(r_relaxed)}, strict={len(r_strict)}"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py::test_training_defaults_are_relaxed -v`
|
||||||
|
Expected: FAIL — `ImportError: cannot import name 'TRAIN_SIGNAL_THRESHOLD'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: dataset_builder.py 수정**
|
||||||
|
|
||||||
|
`src/dataset_builder.py` 상단 상수 블록(line 14-17)을 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 뷰
|
||||||
|
ATR_SL_MULT = 2.0 # config.py 기본값과 동일 (서빙 환경 일치)
|
||||||
|
ATR_TP_MULT = 2.0
|
||||||
|
WARMUP = 60 # 15분봉 기준 60캔들 = 15시간 (지표 안정화 충분)
|
||||||
|
|
||||||
|
# ── 학습 전용 기본값 ──────────────────────────────────────────────
|
||||||
|
# 실전 봇(config.py)보다 완화된 임계값으로 더 많은 신호를 수집한다.
|
||||||
|
# ML 모델이 약한 신호 중에서 좋은 기회를 구분하는 법을 학습한다.
|
||||||
|
# 실전 진입은 bot.py의 엄격한 5단 게이트 + ML 필터가 최종 판단.
|
||||||
|
TRAIN_SIGNAL_THRESHOLD = 2 # 실전: 3 (config.py)
|
||||||
|
TRAIN_ADX_THRESHOLD = 15.0 # 실전: 25.0
|
||||||
|
TRAIN_VOLUME_MULTIPLIER = 1.5 # 실전: 2.5
|
||||||
|
TRAIN_NEGATIVE_RATIO = 3 # HOLD 네거티브 비율 (기존: 5)
|
||||||
|
```
|
||||||
|
|
||||||
|
`generate_dataset_vectorized()` 시그니처(line 387-397)의 기본값을 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def generate_dataset_vectorized(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
btc_df: pd.DataFrame | None = None,
|
||||||
|
eth_df: pd.DataFrame | None = None,
|
||||||
|
time_weight_decay: float = 0.0,
|
||||||
|
negative_ratio: int = TRAIN_NEGATIVE_RATIO, # 변경: 0 → 3
|
||||||
|
signal_threshold: int = TRAIN_SIGNAL_THRESHOLD, # 변경: 3 → 2
|
||||||
|
adx_threshold: float = TRAIN_ADX_THRESHOLD, # 변경: 25 → 15
|
||||||
|
volume_multiplier: float = TRAIN_VOLUME_MULTIPLIER, # 변경: 2.5 → 1.5
|
||||||
|
atr_sl_mult: float = ATR_SL_MULT,
|
||||||
|
atr_tp_mult: float = ATR_TP_MULT,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
```
|
||||||
|
|
||||||
|
또한 `_calc_signals()`(line 57-61)의 기본값도 학습 상수로 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _calc_signals(
|
||||||
|
d: pd.DataFrame,
|
||||||
|
signal_threshold: int = TRAIN_SIGNAL_THRESHOLD, # 변경: 3 → 2
|
||||||
|
adx_threshold: float = TRAIN_ADX_THRESHOLD, # 변경: 25 → 15
|
||||||
|
volume_multiplier: float = TRAIN_VOLUME_MULTIPLIER, # 변경: 2.5 → 1.5
|
||||||
|
) -> np.ndarray:
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_ml_pipeline_fixes.py tests/test_dataset_builder.py -v`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/dataset_builder.py tests/test_ml_pipeline_fixes.py
|
||||||
|
git commit -m "feat(ml): add TRAIN_* constants with relaxed thresholds for more training samples"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 호출부에서 하드코딩된 값 제거
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/train_model.py`
|
||||||
|
- Modify: `scripts/train_mlx_model.py`
|
||||||
|
- Modify: `scripts/tune_hyperparams.py`
|
||||||
|
- Modify: `src/backtester.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: train_model.py — 하드코딩 negative_ratio=5 제거**
|
||||||
|
|
||||||
|
`train()`, `walk_forward_auc()`, `compare()` 내 `generate_dataset_vectorized()` 호출에서 `negative_ratio=5`를 삭제하여 기본값(`TRAIN_NEGATIVE_RATIO=3`)을 사용하도록 변경.
|
||||||
|
|
||||||
|
변경 전 (3곳):
|
||||||
|
```python
|
||||||
|
dataset = generate_dataset_vectorized(
|
||||||
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
|
time_weight_decay=time_weight_decay,
|
||||||
|
negative_ratio=5,
|
||||||
|
atr_sl_mult=atr_sl_mult,
|
||||||
|
atr_tp_mult=atr_tp_mult,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
변경 후:
|
||||||
|
```python
|
||||||
|
dataset = generate_dataset_vectorized(
|
||||||
|
df, btc_df=btc_df, eth_df=eth_df,
|
||||||
|
time_weight_decay=time_weight_decay,
|
||||||
|
atr_sl_mult=atr_sl_mult,
|
||||||
|
atr_tp_mult=atr_tp_mult,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: train_mlx_model.py — 동일 변경**
|
||||||
|
|
||||||
|
`train_mlx()`와 `walk_forward_auc()` 내 `negative_ratio=5` 삭제 (2곳).
|
||||||
|
|
||||||
|
- [ ] **Step 3: tune_hyperparams.py — 동일 변경**
|
||||||
|
|
||||||
|
`load_dataset()` 내 `negative_ratio=5` 삭제 (1곳).
|
||||||
|
|
||||||
|
- [ ] **Step 4: backtester.py — WalkForwardConfig 기본값 변경**
|
||||||
|
|
||||||
|
`WalkForwardConfig` 데이터클래스(~line 601)에서:
|
||||||
|
|
||||||
|
변경 전:
|
||||||
|
```python
|
||||||
|
negative_ratio: int = 5
|
||||||
|
```
|
||||||
|
|
||||||
|
변경 후:
|
||||||
|
```python
|
||||||
|
negative_ratio: int = 3
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 전체 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/train_model.py scripts/train_mlx_model.py scripts/tune_hyperparams.py src/backtester.py
|
||||||
|
git commit -m "refactor(ml): remove hardcoded negative_ratio=5, use dataset_builder defaults"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: 기존 테스트 기본값 정합성 확인 + 수정
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/test_dataset_builder.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 기존 테스트가 기본값 변경에 영향받는지 확인**
|
||||||
|
|
||||||
|
`tests/test_dataset_builder.py`의 기존 테스트 중 `generate_dataset_vectorized(sample_df)` 처럼 기본값에 의존하는 호출이 있음. 기본값이 완화되었으므로:
|
||||||
|
- `signal_threshold=2`에서 더 많은 신호가 발생 → 기존 테스트의 assertion이 깨질 수 있음
|
||||||
|
- `negative_ratio=3`이 기본값이 되므로, 기본 호출 시 HOLD 네거티브가 포함됨
|
||||||
|
|
||||||
|
기존 테스트가 실패하면, **원래 의도를 유지하면서** 명시적 파라미터를 추가:
|
||||||
|
|
||||||
|
예: `test_returns_dataframe`이 기본 호출로 충분한 결과를 기대한다면 그대로 동작할 가능성이 높음. 하지만 `test_has_required_columns`에서 "source" 컬럼 유무가 달라질 수 있음 (negative_ratio=3 → source 컬럼 존재).
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실행 및 실패 확인**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_dataset_builder.py -v`
|
||||||
|
|
||||||
|
실패하는 테스트를 파악하고, 각각 수정:
|
||||||
|
- 기본값에 의존하는 테스트에 명시적 파라미터 추가 (기존 동작 테스트 시 `signal_threshold=3, adx_threshold=25, volume_multiplier=2.5, negative_ratio=0` 명시)
|
||||||
|
- 또는 새 기본값에서도 assertion이 유효하면 그대로 둠
|
||||||
|
|
||||||
|
- [ ] **Step 3: 전체 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/test_dataset_builder.py
|
||||||
|
git commit -m "test: update dataset_builder tests for relaxed training defaults"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: CLAUDE.md 업데이트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: CLAUDE.md plan history 업데이트**
|
||||||
|
|
||||||
|
plan history 테이블에 추가:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| 2026-03-21 | `training-threshold-relaxation` (plan) | Completed |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 최종 전체 테스트**
|
||||||
|
|
||||||
|
Run: `bash scripts/run_tests.sh`
|
||||||
|
Expected: ALL PASS
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs: update plan history with training-threshold-relaxation"
|
||||||
|
```
|
||||||
192
docs/plans/2026-03-22-backtest-market-context-design.md
Normal file
192
docs/plans/2026-03-22-backtest-market-context-design.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# 백테스트 시장 컨텍스트 리포트 설계
|
||||||
|
|
||||||
|
**일자**: 2026-03-22
|
||||||
|
**상태**: 설계 완료, 구현 대기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
Walk-Forward 백테스트 결과를 해석할 때, 각 폴드 기간의 시장 상황(BTC/ETH 추세, L/S ratio)을 함께 보여준다. **"왜 이 폴드에서 졌는가"**를 구조적으로 이해하기 위한 참조 데이터이며, 트레이딩 시그널이나 ML 피처로는 사용하지 않는다.
|
||||||
|
|
||||||
|
## 접근 방식
|
||||||
|
|
||||||
|
Walk-Forward 폴드 테이블 출력 직후에 시장 컨텍스트 테이블 2개(Market Regime + L/S Ratio)를 추가한다. 기존 `scripts/run_backtest.py`만 수정하며, 별도 CLI 명령어는 만들지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 소스
|
||||||
|
|
||||||
|
### 1. BTC/ETH 가격 데이터 (Market Regime)
|
||||||
|
|
||||||
|
- **소스**: XRP의 `data/xrpusdt/combined_15m.parquet`에 임베딩된 `close_btc`, `high_btc`, `low_btc`, `close_eth`, `high_eth`, `low_eth` 컬럼
|
||||||
|
- 별도 `data/btcusdt/combined_15m.parquet` 파일은 로컬/프로덕션 모두 **존재하지 않음**
|
||||||
|
- 백테스터가 이미 이 임베딩 컬럼을 로딩하므로 추가 데이터 fetch 불필요
|
||||||
|
- 폴드 기간별로 슬라이싱하여 수익률, ADX 계산
|
||||||
|
|
||||||
|
### 2. L/S Ratio 데이터
|
||||||
|
|
||||||
|
- **소스**: `data/{symbol}/ls_ratio_15m.parquet` (로컬 파일)
|
||||||
|
- **심볼**: XRPUSDT, BTCUSDT, ETHUSDT
|
||||||
|
- **주기**: 15m
|
||||||
|
- **컬럼**: `timestamp` (datetime64[ms, UTC]), `top_acct_ls_ratio` (float64), `global_ls_ratio` (float64)
|
||||||
|
|
||||||
|
#### 현재 데이터 상태
|
||||||
|
|
||||||
|
- L/S ratio collector는 운영 LXC(`10.1.10.24`)에서 가동 중 (commit `e2b0454`, 2026-03-22~)
|
||||||
|
- **프로덕션**: XRP/BTC/ETH 각 3건 (2026-03-22 13:15 ~ 13:45 UTC), 계속 축적 중
|
||||||
|
- **로컬**: XRP 2건, BTC 2건, ETH 2건 (로컬 collector 테스트 시 생성된 데이터)
|
||||||
|
- 과거 폴드(2025-06, 2025-09, 2025-12)에 대한 L/S ratio 데이터는 **존재하지 않음**
|
||||||
|
- Binance API는 최근 30일만 historical 제공 → 과거 데이터 복구 불가능
|
||||||
|
|
||||||
|
#### 데이터 동기화
|
||||||
|
|
||||||
|
구현 전 프로덕션 LXC에서 L/S ratio parquet 파일을 로컬로 복사해야 한다:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp root@10.1.10.24:/root/cointrader/data/xrpusdt/ls_ratio_15m.parquet data/xrpusdt/
|
||||||
|
scp root@10.1.10.24:/root/cointrader/data/btcusdt/ls_ratio_15m.parquet data/btcusdt/
|
||||||
|
scp root@10.1.10.24:/root/cointrader/data/ethusdt/ls_ratio_15m.parquet data/ethusdt/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Fallback 전략
|
||||||
|
|
||||||
|
1. **로컬 parquet 우선**: `data/{symbol}/ls_ratio_15m.parquet`에서 폴드 기간 데이터 조회
|
||||||
|
2. **파일 없거나 해당 기간 데이터 없으면 `N/A`**: 폴드의 L/S ratio 셀을 `N/A`로 표시
|
||||||
|
3. **전체 폴드가 N/A이면 L/S ratio 테이블 자체를 생략**: 불필요한 N/A 테이블을 출력하지 않음
|
||||||
|
4. **Binance API에서 실시간 fetch하지 않음**: 백테스트는 오프라인 재현 가능해야 함
|
||||||
|
5. **시간이 지나면 해결됨**: collector가 계속 수집하므로, 데이터 축적 후 백테스트에 자연스럽게 반영
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Market Regime 분류 기준
|
||||||
|
|
||||||
|
BTC ADX와 수익률 기반으로 **코드에 명확히 정의**하여 주관적 해석을 방지한다:
|
||||||
|
|
||||||
|
| 조건 | 라벨 |
|
||||||
|
|------|------|
|
||||||
|
| ADX ≥ 25 and return > 0 | 상승 추세 |
|
||||||
|
| ADX ≥ 25 and return < 0 | 하락 추세 |
|
||||||
|
| ADX < 25 | 횡보 |
|
||||||
|
|
||||||
|
- ADX는 폴드 기간 내 BTC 15m 캔들(`high_btc`, `low_btc`, `close_btc`)로 계산한 **기간 평균 ADX** (`pandas_ta.adx(length=14)` 사용)
|
||||||
|
- return은 폴드 시작가 대비 종료가의 **단순 수익률** (`close_btc`)
|
||||||
|
- 라벨 뒤에 `(BTC ADX {값:.0f})` 형태로 실제 수치 병기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 출력 형식
|
||||||
|
|
||||||
|
기존 폴드 테이블 바로 아래에 출력:
|
||||||
|
|
||||||
|
```
|
||||||
|
📊 Market Context per Fold
|
||||||
|
┌──────┬──────────────┬──────────────┬─────────────────────────────────┐
|
||||||
|
│ Fold │ BTC Return │ ETH Return │ Market Regime │
|
||||||
|
├──────┼──────────────┼──────────────┼─────────────────────────────────┤
|
||||||
|
│ 1 │ +12.3% │ +8.7% │ 상승 추세 (BTC ADX 32) │
|
||||||
|
│ 2 │ -2.1% │ -5.4% │ 횡보 (BTC ADX 18) │
|
||||||
|
│ 3 │ +25.6% │ +18.2% │ 상승 추세 (BTC ADX 41) │
|
||||||
|
└──────┴──────────────┴──────────────┴─────────────────────────────────┘
|
||||||
|
|
||||||
|
📊 L/S Ratio Context per Fold (period avg)
|
||||||
|
┌──────┬──────────────────┬──────────────────┬──────────────────┐
|
||||||
|
│ Fold │ XRP Top/Global │ BTC Top/Global │ ETH Top/Global │
|
||||||
|
├──────┼──────────────────┼──────────────────┼──────────────────┤
|
||||||
|
│ 1 │ N/A │ N/A │ N/A │
|
||||||
|
│ 2 │ N/A │ N/A │ N/A │
|
||||||
|
│ 3 │ 1.15 / 0.98 │ 0.95 / 1.02 │ 1.08 / 1.05 │
|
||||||
|
└──────┴──────────────────┴──────────────────┴──────────────────┘
|
||||||
|
→ Fold 1~2: L/S ratio 데이터 없음 (collector 가동 전)
|
||||||
|
→ Fold 3: 데이터 가용
|
||||||
|
```
|
||||||
|
|
||||||
|
**전체 폴드가 N/A인 경우** (현재 상태에서 과거 데이터만으로 백테스트하면):
|
||||||
|
|
||||||
|
```
|
||||||
|
📊 Market Context per Fold
|
||||||
|
┌──────┬──────────────┬──────────────┬─────────────────────────────────┐
|
||||||
|
│ Fold │ BTC Return │ ETH Return │ Market Regime │
|
||||||
|
├──────┼──────────────┼──────────────┼─────────────────────────────────┤
|
||||||
|
│ 1 │ +12.3% │ +8.7% │ 상승 추세 (BTC ADX 32) │
|
||||||
|
│ 2 │ -2.1% │ -5.4% │ 횡보 (BTC ADX 18) │
|
||||||
|
│ 3 │ +25.6% │ +18.2% │ 상승 추세 (BTC ADX 41) │
|
||||||
|
└──────┴──────────────┴──────────────┴─────────────────────────────────┘
|
||||||
|
ℹ️ L/S ratio 데이터 없음 — collector 데이터 축적 후 표시됩니다
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON 출력
|
||||||
|
|
||||||
|
walk-forward 결과 JSON에도 `market_context` 필드 추가:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"folds": [
|
||||||
|
{
|
||||||
|
"fold": 1,
|
||||||
|
"test_period": "2025-06-07 ~ 2025-07-06",
|
||||||
|
"test_start": "2025-06-07T00:00:00",
|
||||||
|
"test_end": "2025-07-06T00:00:00",
|
||||||
|
"summary": { "..." : "..." },
|
||||||
|
"market_context": {
|
||||||
|
"btc_return_pct": 12.3,
|
||||||
|
"eth_return_pct": 8.7,
|
||||||
|
"btc_avg_adx": 32.1,
|
||||||
|
"market_regime": "상승 추세",
|
||||||
|
"ls_ratio": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 3,
|
||||||
|
"test_period": "2026-03-01 ~ 2026-04-01",
|
||||||
|
"test_start": "2026-03-01T00:00:00",
|
||||||
|
"test_end": "2026-04-01T00:00:00",
|
||||||
|
"summary": { "..." : "..." },
|
||||||
|
"market_context": {
|
||||||
|
"btc_return_pct": 5.2,
|
||||||
|
"eth_return_pct": 3.1,
|
||||||
|
"btc_avg_adx": 28.5,
|
||||||
|
"market_regime": "상승 추세",
|
||||||
|
"ls_ratio": {
|
||||||
|
"xrp": { "top_acct_avg": 1.15, "global_avg": 0.98 },
|
||||||
|
"btc": { "top_acct_avg": 0.95, "global_avg": 1.02 },
|
||||||
|
"eth": { "top_acct_avg": 1.08, "global_avg": 1.05 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 수정 대상 파일
|
||||||
|
|
||||||
|
| 파일 | 변경 유형 | 역할 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `scripts/run_backtest.py` | Modify | 시장 컨텍스트 계산 + 출력 함수 추가 |
|
||||||
|
| `src/backtester.py` | Modify (최소) | 폴드 결과에 `test_start`/`test_end`를 timestamp로 노출 (현재는 문자열 `test_period`만 있음) |
|
||||||
|
|
||||||
|
### 변경하지 않는 것
|
||||||
|
|
||||||
|
- `src/indicators.py` — ADX 계산은 `run_backtest.py` 내에서 `pandas_ta.adx()` 직접 사용
|
||||||
|
- `scripts/collect_ls_ratio.py` — 기존 collector 로직 변경 없음
|
||||||
|
- `src/ml_filter.py`, `src/ml_features.py` — ML 피처와 무관
|
||||||
|
- `scripts/fetch_history.py` — BTC/ETH 별도 fetch 불필요 (XRP parquet에 임베딩됨)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 전 선행 작업
|
||||||
|
|
||||||
|
1. ~~BTC/ETH 히스토리 데이터 fetch~~ → **불필요** (XRP parquet에 `close_btc`, `close_eth` 등 임베딩됨)
|
||||||
|
2. `backtester.py`에서 `test_start`/`test_end`를 timestamp로 노출하도록 수정
|
||||||
|
3. 프로덕션 LXC에서 L/S ratio parquet 파일 로컬 동기화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 범위 제한
|
||||||
|
|
||||||
|
- **참조 전용**: 시장 컨텍스트는 출력/리포트에만 사용. 트레이딩 로직에 영향 없음
|
||||||
|
- **오프라인 우선**: Binance API 호출 없음. 로컬 데이터만 사용
|
||||||
|
- **기존 테스트 영향 없음**: 출력 함수 추가이므로 기존 백테스트 로직 불변
|
||||||
|
- **L/S ratio 테이블 조건부 출력**: 전체 N/A이면 테이블 생략, 한 줄 안내 메시지만 출력
|
||||||
242
docs/plans/2026-03-22-testnet-uds-verification-design.md
Normal file
242
docs/plans/2026-03-22-testnet-uds-verification-design.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
# Testnet UDS 검증 설계
|
||||||
|
|
||||||
|
**일자**: 2026-03-22
|
||||||
|
**상태**: 설계 완료, 구현 대기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
Binance Futures Testnet에서 User Data Stream(UDS)의 reconnect 동작을 검증한다. 현재 프로덕션 15분봉 설정 그대로 testnet에 연결하여, UDS 연결 → ~30분 후 reconnect → ORDER_TRADE_UPDATE 수신까지 전체 경로가 정상 작동하는지 확인한다.
|
||||||
|
|
||||||
|
**이것은 UDS 검증 전용이다.** 1분봉 전환, 125x 레버리지, ML 파이프라인 변경은 포함하지 않는다. 기존 설계(`2026-03-03-testnet-1m-125x`)는 ML OFF 확정 후 전제가 바뀌었으므로 별도 취급한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 접근 방식
|
||||||
|
|
||||||
|
python-binance 1.0.35에서 `testnet=True` 파라미터가 REST API와 WebSocket(kline + User Data Stream) 모두 자동 라우팅한다. 별도 URL 오버라이드 불필요.
|
||||||
|
|
||||||
|
**검증된 라우팅 경로 (python-binance 소스 확인):**
|
||||||
|
- REST API: `https://testnet.binancefuture.com`
|
||||||
|
- Kline WebSocket: `wss://stream.binancefuture.com/` (`BinanceSocketManager._get_futures_socket()`에서 `self.testnet` 체크)
|
||||||
|
- User Data Stream WebSocket: `wss://stream.binancefuture.com/` (`futures_user_socket()`에서 `self.testnet` 체크)
|
||||||
|
|
||||||
|
`AsyncClient.create(testnet=True)` → `BinanceSocketManager(client)` → `client.testnet` 플래그가 자동 전파.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 수정 대상 파일
|
||||||
|
|
||||||
|
| 파일 | 변경 내용 |
|
||||||
|
|------|----------|
|
||||||
|
| `src/config.py` | `testnet: bool` 필드 추가, `BINANCE_TESTNET` env var 파싱, testnet이면 testnet API key 사용 |
|
||||||
|
| `src/exchange.py` | `Client(..., testnet=config.testnet)` 전달 |
|
||||||
|
| `src/user_data_stream.py` | `AsyncClient.create(..., testnet=testnet)` 전달 |
|
||||||
|
| `src/data_stream.py` | `AsyncClient.create(..., testnet=testnet)` 전달 (KlineStream + MultiSymbolStream) |
|
||||||
|
| `src/notifier.py` | testnet일 때 Discord 메시지에 `[TESTNET]` 접두사 추가 |
|
||||||
|
| `src/bot.py` | testnet 플래그를 각 스트림/notifier에 전달 + trade_history 경로 분리 + 시작 시 TESTNET 경고 로그 |
|
||||||
|
|
||||||
|
### 변경하지 않는 것
|
||||||
|
|
||||||
|
- 지표 계산 (`src/indicators.py`) — 그대로
|
||||||
|
- ML 필터 (`src/ml_filter.py`) — NO_ML_FILTER=true 상태 그대로
|
||||||
|
- 학습 파이프라인 — 변경 없음
|
||||||
|
- 리스크 매니저 — 그대로
|
||||||
|
- Discord 알림 — testnet일 때 메시지에 `[TESTNET]` 접두사 추가 (아래 상세 변경 참조)
|
||||||
|
- `.env` 프로덕션 설정 — 변경 없음 (BINANCE_TESTNET 추가만)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 상세 변경
|
||||||
|
|
||||||
|
### 1. Config (`src/config.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 필드 추가
|
||||||
|
testnet: bool = False
|
||||||
|
|
||||||
|
# __post_init__에서:
|
||||||
|
self.testnet = os.getenv("BINANCE_TESTNET", "").lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
if self.testnet:
|
||||||
|
self.api_key = os.getenv("BINANCE_TESTNET_API_KEY", "")
|
||||||
|
self.api_secret = os.getenv("BINANCE_TESTNET_API_SECRET", "")
|
||||||
|
else:
|
||||||
|
self.api_key = os.getenv("BINANCE_API_KEY", "")
|
||||||
|
self.api_secret = os.getenv("BINANCE_API_SECRET", "")
|
||||||
|
```
|
||||||
|
|
||||||
|
- testnet이면 `BINANCE_TESTNET_API_KEY/SECRET` 사용
|
||||||
|
- 나머지 설정(SYMBOLS, LEVERAGE 등)은 동일하게 적용
|
||||||
|
|
||||||
|
### 2. Exchange (`src/exchange.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 현재:
|
||||||
|
self.client = Client(
|
||||||
|
api_key=config.api_key,
|
||||||
|
api_secret=config.api_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 변경:
|
||||||
|
self.client = Client(
|
||||||
|
api_key=config.api_key,
|
||||||
|
api_secret=config.api_secret,
|
||||||
|
testnet=config.testnet,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. UserDataStream (`src/user_data_stream.py`)
|
||||||
|
|
||||||
|
`_run_loop()` 시그니처에 `testnet` 파라미터 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# start()에 testnet 파라미터 추가
|
||||||
|
async def start(self, api_key: str, api_secret: str, testnet: bool = False) -> None:
|
||||||
|
...
|
||||||
|
await self._run_loop(api_key, api_secret, testnet)
|
||||||
|
|
||||||
|
# _run_loop()에서 AsyncClient.create에 전달
|
||||||
|
async def _run_loop(self, api_key: str, api_secret: str, testnet: bool = False) -> None:
|
||||||
|
while True:
|
||||||
|
client = await AsyncClient.create(
|
||||||
|
api_key=api_key,
|
||||||
|
api_secret=api_secret,
|
||||||
|
testnet=testnet,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. DataStream (`src/data_stream.py`)
|
||||||
|
|
||||||
|
KlineStream.start()과 MultiSymbolStream.start() 모두 동일 패턴:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def start(self, api_key: str, api_secret: str, testnet: bool = False):
|
||||||
|
client = await AsyncClient.create(
|
||||||
|
api_key=api_key,
|
||||||
|
api_secret=api_secret,
|
||||||
|
testnet=testnet,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
MultiSymbolStream._run_loop()에서도 reconnect 시 AsyncClient.create에 testnet 전달.
|
||||||
|
|
||||||
|
### 5. Notifier (`src/notifier.py`)
|
||||||
|
|
||||||
|
testnet일 때 Discord 메시지에 `[TESTNET]` 접두사를 추가하여 프로덕션 알림과 구분:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Notifier.__init__()에 testnet 파라미터 추가
|
||||||
|
def __init__(self, webhook_url: str, testnet: bool = False):
|
||||||
|
self.webhook_url = webhook_url
|
||||||
|
self.testnet = testnet
|
||||||
|
|
||||||
|
# 메시지 전송 시 접두사 추가
|
||||||
|
async def _send(self, content: str):
|
||||||
|
if self.testnet:
|
||||||
|
content = f"[TESTNET] {content}"
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Bot에서 Notifier 생성 시 `testnet=self.config.testnet` 전달.
|
||||||
|
|
||||||
|
### 6. Bot (`src/bot.py`)
|
||||||
|
|
||||||
|
**시작 로그에 TESTNET 명시 (warning 레벨):**
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def run(self):
|
||||||
|
if self.config.testnet:
|
||||||
|
logger.warning("⚠️ TESTNET MODE ENABLED — 실제 자금이 아닌 테스트넷에서 실행 중")
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**stream.start()와 user_stream.start()에 testnet 전달:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
await asyncio.gather(
|
||||||
|
self.stream.start(
|
||||||
|
api_key=self.config.api_key,
|
||||||
|
api_secret=self.config.api_secret,
|
||||||
|
testnet=self.config.testnet,
|
||||||
|
),
|
||||||
|
user_stream.start(
|
||||||
|
api_key=self.config.api_key,
|
||||||
|
api_secret=self.config.api_secret,
|
||||||
|
testnet=self.config.testnet,
|
||||||
|
),
|
||||||
|
self._position_monitor(),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**trade_history 경로 분리:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 현재 (line 24):
|
||||||
|
_TRADE_HISTORY_DIR = Path("data/trade_history")
|
||||||
|
|
||||||
|
# 변경 — _trade_history_path() 메서드에서 분기:
|
||||||
|
def _trade_history_path(self) -> Path:
|
||||||
|
base = Path("data/trade_history")
|
||||||
|
if self.config.testnet:
|
||||||
|
base = base / "testnet"
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
return base / f"{self.symbol.lower()}.jsonl"
|
||||||
|
```
|
||||||
|
|
||||||
|
- testnet: `data/trade_history/testnet/xrpusdt.jsonl`
|
||||||
|
- production: `data/trade_history/xrpusdt.jsonl` (기존과 동일)
|
||||||
|
- Kill Switch 판정이 testnet 트레이드로 오염되지 않음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## .env 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 기존 프로덕션 설정 유지 + 아래 추가
|
||||||
|
BINANCE_TESTNET=true # testnet 모드 활성화
|
||||||
|
BINANCE_TESTNET_API_KEY=xxx # testnet.binancefuture.com에서 발급
|
||||||
|
BINANCE_TESTNET_API_SECRET=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
- `BINANCE_TESTNET=true`를 설정하면 testnet 모드로 전환
|
||||||
|
- 프로덕션 복귀 시 `BINANCE_TESTNET=false` 또는 줄 삭제
|
||||||
|
|
||||||
|
**주의**: .env에 이미 `BINANCE_TESTNET_API_KEY`/`BINANCE_TESTNET_API_SECRET` 자리가 마련되어 있음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 절차
|
||||||
|
|
||||||
|
### 1단계: Testnet API 키 발급
|
||||||
|
|
||||||
|
- `testnet.binancefuture.com` 접속 → API 키 발급
|
||||||
|
- `.env`에 설정
|
||||||
|
|
||||||
|
### 2단계: 봇 실행 + UDS 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env에 BINANCE_TESTNET=true 설정 후
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
확인 사항:
|
||||||
|
1. 시작 로그에 testnet 표시 확인
|
||||||
|
2. User Data Stream 연결 로그 확인
|
||||||
|
3. ~30분 대기 → reconnect 발생하는지 확인
|
||||||
|
4. reconnect 후 ORDER_TRADE_UPDATE 수신되는지 확인
|
||||||
|
5. trade_history가 `data/trade_history/testnet/` 에 기록되는지 확인
|
||||||
|
|
||||||
|
### 3단계: Kill Switch 경로 확인
|
||||||
|
|
||||||
|
- testnet 트레이드가 `data/trade_history/testnet/xrpusdt.jsonl`에만 기록되는지 확인
|
||||||
|
- 프로덕션 `data/trade_history/xrpusdt.jsonl`이 변경되지 않았는지 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 주의사항
|
||||||
|
|
||||||
|
- **테스트넷 가격은 실제 시장과 다름**: 전략 성과 판단 불가, UDS 동작 검증만 목적
|
||||||
|
- **trade_history 분리 필수**: testnet 트레이드가 프로덕션 Kill Switch를 오염시키면 안 됨
|
||||||
|
- **프로덕션 배포 시 BINANCE_TESTNET 제거 확인**: `.env`에 `BINANCE_TESTNET=true`가 남아있으면 프로덕션이 testnet으로 연결됨
|
||||||
132
docs/plans/2026-03-23-algo-order-fix-design.md
Normal file
132
docs/plans/2026-03-23-algo-order-fix-design.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Algo Order 호환성 수정 설계
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
실전 바이낸스 API 검증 결과, 조건부 주문(STOP_MARKET, TAKE_PROFIT_MARKET)이 Algo Order로 처리되며 테스트넷과 동작이 다름이 확인됨.
|
||||||
|
|
||||||
|
### 검증 결과 요약
|
||||||
|
|
||||||
|
| 항목 | 테스트넷 | 실전 |
|
||||||
|
|------|---------|------|
|
||||||
|
| SL/TP 응답 | `orderId` 반환 | `algoId`만 반환, orderId=None |
|
||||||
|
| SL 트리거 UDS | `ot=STOP_MARKET` | `ot=MARKET` |
|
||||||
|
| SL 후 TP 자동만료 | EXPIRED 이벤트 수신 | 만료 안 됨 → 고아주문 |
|
||||||
|
| `get_open_orders()` | algo 주문 조회됨 | algo 주문 조회 안 됨 |
|
||||||
|
| `cancel_all_orders()` | algo 주문 취소됨 | algo 주문 취소 안 됨 |
|
||||||
|
| UDS `i` 필드 vs 배치 ID | 동일 | `i` ≠ `algoId` (서로 다른 값) |
|
||||||
|
|
||||||
|
## 수정 대상 파일
|
||||||
|
|
||||||
|
1. `src/exchange.py` — algo API 병행 호출
|
||||||
|
2. `src/bot.py` — SL/TP 가격 저장, close_reason 판별, 복구 로직
|
||||||
|
3. `src/user_data_stream.py` — 가격 기반 close_reason 판별
|
||||||
|
4. `tests/` — 변경사항 반영
|
||||||
|
|
||||||
|
## 설계
|
||||||
|
|
||||||
|
### 1. exchange.py: Algo API 병행
|
||||||
|
|
||||||
|
**`cancel_all_orders()`**: 일반 주문 취소 + algo 주문 전체 취소를 모두 호출.
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def cancel_all_orders(self):
|
||||||
|
await self._run_api(
|
||||||
|
lambda: self.client.futures_cancel_all_open_orders(symbol=self.symbol)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self._run_api(
|
||||||
|
lambda: self.client.futures_cancel_all_algo_open_orders(symbol=self.symbol)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # algo 주문 없으면 실패 가능 — 무시
|
||||||
|
```
|
||||||
|
|
||||||
|
**`cancel_order()`**: ID 크기나 타입으로 분기하지 않고, 일반 취소 시도 → 실패 시 algo 취소 (현재와 동일, 이미 올바른 구조).
|
||||||
|
|
||||||
|
**`get_open_orders()`**: 일반 주문 + algo 주문을 병합 반환. algo 주문 응답의 필드명이 다르므로 정규화 필요.
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_open_orders(self) -> list[dict]:
|
||||||
|
orders = await self._run_api(
|
||||||
|
lambda: self.client.futures_get_open_orders(symbol=self.symbol)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
algo_orders = await self._run_api(
|
||||||
|
lambda: self.client.futures_get_algo_open_orders(symbol=self.symbol)
|
||||||
|
)
|
||||||
|
for ao in algo_orders.get("orders", []):
|
||||||
|
orders.append({
|
||||||
|
"orderId": ao.get("algoId"),
|
||||||
|
"type": ao.get("orderType"), # STOP_MARKET / TAKE_PROFIT_MARKET
|
||||||
|
"stopPrice": ao.get("triggerPrice"),
|
||||||
|
"side": ao.get("side"),
|
||||||
|
"status": ao.get("algoStatus"),
|
||||||
|
"_is_algo": True,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return orders
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. bot.py: SL/TP 가격 저장 + close_reason 판별
|
||||||
|
|
||||||
|
**새 필드 추가** (`__init__`):
|
||||||
|
```python
|
||||||
|
self._sl_price: float | None = None
|
||||||
|
self._tp_price: float | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
**`_open_position()`**: SL/TP 배치 후 가격 저장.
|
||||||
|
```python
|
||||||
|
# _place_sl_tp_with_retry 호출 전에 이미 stop_loss, take_profit 계산됨
|
||||||
|
self._sl_price = stop_loss
|
||||||
|
self._tp_price = take_profit
|
||||||
|
```
|
||||||
|
|
||||||
|
**`_ensure_sl_tp_orders()` (복구)**: 오픈 주문에서 SL/TP 가격 복원.
|
||||||
|
```python
|
||||||
|
for o in open_orders:
|
||||||
|
otype = o.get("type", "")
|
||||||
|
if otype == "STOP_MARKET":
|
||||||
|
self._sl_price = float(o.get("stopPrice", 0))
|
||||||
|
elif otype == "TAKE_PROFIT_MARKET":
|
||||||
|
self._tp_price = float(o.get("stopPrice", 0))
|
||||||
|
```
|
||||||
|
|
||||||
|
**`_on_position_closed()`**: close_reason이 "MANUAL"일 때 가격 비교로 재판별.
|
||||||
|
```python
|
||||||
|
if close_reason == "MANUAL" and self._sl_price and self._tp_price:
|
||||||
|
sl_dist = abs(exit_price - self._sl_price)
|
||||||
|
tp_dist = abs(exit_price - self._tp_price)
|
||||||
|
if sl_dist < tp_dist:
|
||||||
|
close_reason = "SL"
|
||||||
|
else:
|
||||||
|
close_reason = "TP"
|
||||||
|
```
|
||||||
|
|
||||||
|
**상태 초기화**: `_on_position_closed()` 및 `_close_and_reenter()`에서 포지션 Flat 전환 시:
|
||||||
|
```python
|
||||||
|
self._sl_price = None
|
||||||
|
self._tp_price = None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. user_data_stream.py: close_reason을 콜백에 위임
|
||||||
|
|
||||||
|
UDS의 close_reason 판별 로직은 유지하되, 콜백 시그니처에 `exit_price`가 이미 전달되므로 bot.py에서 재판별 가능. UDS 자체는 변경 최소화.
|
||||||
|
|
||||||
|
현재 UDS에서 `ot`로 판별 → 실전에서 `ot=MARKET` → `close_reason="MANUAL"` → bot.py에서 가격 비교로 SL/TP 재판별. 이 흐름이 테스트넷에서도 안전 (테스트넷은 `ot=STOP_MARKET`이 오므로 재판별 자체가 불필요).
|
||||||
|
|
||||||
|
### 4. 포지션 모니터 SYNC 경로 — 이미 구현됨
|
||||||
|
|
||||||
|
`_position_monitor()`의 SYNC 폴백에서 잔여주문 취소는 **이미 구현되어 있음**. 추가 수정 불필요.
|
||||||
|
|
||||||
|
> **참고**: `_place_sl_tp_with_retry()`의 algoId 저장도 이미 구현됨 (bot.py line 539, 550).
|
||||||
|
|
||||||
|
### 5. 테스트 계획
|
||||||
|
|
||||||
|
- 테스트넷에서 SL 트리거 → TP 고아주문 자동 취소 확인
|
||||||
|
- 테스트넷에서 TP 트리거 → SL 고아주문 자동 취소 확인
|
||||||
|
- 테스트넷에서 역방향 재진입 → 기존 SL/TP 취소 확인
|
||||||
|
- 봇 재시작 → SL/TP 가격 복원 확인
|
||||||
|
- close_reason이 SL/TP로 정확히 분류되는지 확인
|
||||||
|
- 위 모든 항목 통과 후 실전 배포
|
||||||
507
docs/plans/CoinTrader_종합검토보고서.md
Normal file
507
docs/plans/CoinTrader_종합검토보고서.md
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
# CoinTrader 프로젝트 종합 검토 보고서
|
||||||
|
|
||||||
|
**검토 일자**: 2026년 3월 7일
|
||||||
|
**검토자**: Claude AI
|
||||||
|
**대상**: CoinTrader — Binance Futures 자동매매 봇 (47개 설계/계획 문서 + README + ARCHITECTURE)
|
||||||
|
**기준**: 객관성, 내용의 정확성, 아키텍처 일관성, 코드 품질
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 요약
|
||||||
|
|
||||||
|
**프로젝트 상태**: 초기 단계 + 활발한 개발 중
|
||||||
|
|
||||||
|
CoinTrader는 Binance Futures에서 15분 봉의 기술 지표와 ML 필터를 결합하여 XRP, TRX, DOGE 등 다중 심볼을 동시 거래하는 자동매매 봇입니다. **5-레이어 아키텍처**(Data → Signal → ML Filter → Execution & Risk → Event/Alert)로 구성되어 있으며, 136개의 단위 테스트와 완전한 MLOps 파이프라인을 갖추고 있습니다.
|
||||||
|
|
||||||
|
그러나 **즉시 수정이 필요한 버그 2개**(OI division by zero, 누적 트레이드 계산 오류)와 **이번 주 중 해결해야 할 문제 4~5개**(API retry, Parquet 중복, async Lock, exit_price 방어)가 존재합니다. 또한 ML 필터가 현재 비활성화되어 있으며, 그 이유(학습 데이터 부족)가 타당해 보입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 아키텍처 분석
|
||||||
|
|
||||||
|
### 1.1 전체 구조의 강점
|
||||||
|
|
||||||
|
**✓ 명확한 5-레이어 분리**
|
||||||
|
- Layer 1 (Data): WebSocket 캔들 수신, Parquet 버퍼
|
||||||
|
- Layer 2 (Signal): 기술 지표 + 가중치 신호 생성
|
||||||
|
- Layer 3 (ML Filter): ONNX/LightGBM 선택적 활성화
|
||||||
|
- Layer 4 (Execution & Risk): 주문 실행 + 공유 RiskManager
|
||||||
|
- Layer 5 (Event/Alert): User Data Stream TP/SL 감지 + Discord
|
||||||
|
|
||||||
|
각 레이어가 단일 책임을 가지고 있으며, 의존성 방향이 명확함.
|
||||||
|
|
||||||
|
**✓ 멀티심볼 동시 거래의 실제 구현**
|
||||||
|
- 심볼별 **독립 TradingBot 인스턴스** → 각자 `Exchange`, `MLFilter`, `DataStream` 소유
|
||||||
|
- **공유 RiskManager (싱글턴)** → asyncio.Lock으로 일일 손실 한도, 동일 방향 제한 관리
|
||||||
|
- `asyncio.gather()`로 병렬 실행 → 심볼 간 간섭 없음
|
||||||
|
|
||||||
|
이는 멀티심볼 거래에서 흔한 함정(단일 데이터 경로의 병목, 공유 상태의 경쟁 조건)을 잘 피함.
|
||||||
|
|
||||||
|
**✓ 완전한 MLOps 파이프라인**
|
||||||
|
- 과거 데이터 수집 → 벡터화 데이터셋 생성 → LightGBM/MLX 학습
|
||||||
|
- Walk-Forward 5폴드 검증 → Optuna 하이퍼파라미터 튜닝
|
||||||
|
- 모델 핫리로드 (변경 감지 후 자동 로드)
|
||||||
|
- 주간 백테스트 리포트 + Discord 자동 알림
|
||||||
|
|
||||||
|
### 1.2 설계 결정의 타당성
|
||||||
|
|
||||||
|
**기술 지표 선택 (RSI, MACD, 볼린저, EMA, StochRSI, ADX)**
|
||||||
|
- 각 지표의 역할이 명확함 (과매수/과매도, 추세 전환, 가격 이탈, 추세 강도)
|
||||||
|
- 가중치 합산 시스템 (ADX ≥ 25 필터가 가장 효과적이라고 문서에서 언급)
|
||||||
|
- 전략 파라미터 스윕 결과: ADX=25 + Vol=2.5 조합에서 PF 1.57~2.39 달성
|
||||||
|
|
||||||
|
**ML 필터 현재 비활성화 (NO_ML_FILTER=true)**
|
||||||
|
- 이유: Walk-Forward 검증에서 각 폴드 학습 세트에 유효 신호가 ~27건으로 부족
|
||||||
|
- ADX + 거래량 배수만으로도 PF 1.5 이상 → ML 없이 운영하겠다는 판단은 보수적이고 합리적
|
||||||
|
- "충분한 거래 데이터(150건 이상) 축적 후 재활성화" 기준도 명확함
|
||||||
|
|
||||||
|
**동적 증증금 비율**
|
||||||
|
- 잔고가 늘어날수록 비율 감소 (과노출 방지)
|
||||||
|
- `MARGIN_MIN_RATIO=0.20`, `MARGIN_MAX_RATIO=0.50`, `DECAY_RATE=0.0006`
|
||||||
|
- 수식: `margin_ratio = MAX - (balance_growth) × DECAY_RATE` (선형 감소)
|
||||||
|
|
||||||
|
### 1.3 아키텍처의 약점
|
||||||
|
|
||||||
|
**△ User Data Stream TP/SL 감지 미테스트**
|
||||||
|
- 코드는 구현되어 있으나, WebSocket 의존성 때문에 테스트가 없음 (COVERAGE 6.3 표참조)
|
||||||
|
- 실제 운영 중 `ORDER_TRADE_UPDATE` 이벤트 처리에 버그가 있을 수 있음
|
||||||
|
- 특히 `exit_price = 0.0` 기본값 문제 (#8 이슈)가 이를 증명
|
||||||
|
|
||||||
|
**△ 반대 시그널 재진입의 경쟁 조건 가능성**
|
||||||
|
- `_is_reentering` 플래그로 보호하고 있으나, 극단적인 타이밍에서는 여전히 버그 가능
|
||||||
|
- 멀티심볼에서 각 심볼의 캔들 마감 시점이 다르면, 한 심볼의 청산 콜백이 다른 심볼의 신호 처리와 겹칠 수 있음
|
||||||
|
|
||||||
|
**△ Parquet Upsert 시 타임존 처리**
|
||||||
|
- `tz_localize("UTC")` 호출이 기존 데이터가 실제 UTC인지 검증하지 않음 (#13 이슈)
|
||||||
|
- OI/펀딩비 데이터가 다른 타임존이면 시계열 병합이 어긋남
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 코드 품질 분석
|
||||||
|
|
||||||
|
### 2.1 즉시 수정이 필요한 버그 (Critical)
|
||||||
|
|
||||||
|
#### 버그 #1: OI 변화율 계산 시 Division by Zero
|
||||||
|
**파일**: `src/bot.py:120`
|
||||||
|
**심각도**: 높음 (봇 크래시 가능)
|
||||||
|
**원인**: `_prev_oi == 0.0`일 때 `(current_oi - self._prev_oi) / self._prev_oi` 계산
|
||||||
|
**영향**: `get_open_interest()` API 실패 시 0.0 반환 → ZeroDivisionError 발생
|
||||||
|
**수정**: `if self._prev_oi == 0.0: oi_change = 0.0 else: oi_change = ...`
|
||||||
|
**예상 수정 시간**: 5분
|
||||||
|
|
||||||
|
#### 버그 #2: 누적 트레이드 수 계산 로직 오류
|
||||||
|
**파일**: `scripts/weekly_report.py:415-423`
|
||||||
|
**심각도**: 높음 (ML 재학습 트리거 오작동)
|
||||||
|
**원인**: `max(cumulative, prev_count)`로 최대값만 취함 → 누적이 아님
|
||||||
|
**영향**: ML 재학습 조건 "≥ 150 누적 거래" 판단 오류
|
||||||
|
**수정**: `cumulative += prev.get("live_trades", {}).get("count", 0)` (합산)
|
||||||
|
**예상 수정 시간**: 5분
|
||||||
|
|
||||||
|
### 2.2 이번 주 중 수정 권장 (Important)
|
||||||
|
|
||||||
|
#### 이슈 #3: Training-Serving Skew (OI/펀딩비 피처)
|
||||||
|
**파일**: `src/dataset_builder.py` vs `src/ml_features.py`
|
||||||
|
**심각도**: 중간 (ML 재활성화 시에만 영향)
|
||||||
|
**문제**:
|
||||||
|
- 학습: OI=0 구간 → NaN으로 마스킹 후 z-score 정규화
|
||||||
|
- 서빙: OI 값 → NaN으로 직접 설정
|
||||||
|
- 결과: 피처 분포 불일치 (학습/서빙 간 스큐)
|
||||||
|
|
||||||
|
**현재 상태**: ML OFF이므로 당장은 무영향
|
||||||
|
**필요 시점**: ML 재활성화 전 반드시 해결
|
||||||
|
**예상 수정 시간**: 30분
|
||||||
|
|
||||||
|
#### 이슈 #4: fetch_history.py — API 실패/Rate Limit 미처리
|
||||||
|
**파일**: `scripts/fetch_history.py:46-61`
|
||||||
|
**심각도**: 중간 (데이터 수집 중단, 주간 리포트 행)
|
||||||
|
**문제**: `futures_klines()` 호출에 retry 로직 없음
|
||||||
|
**영향**: Rate limit(429) 발생 시 크래시 → subprocess 무한 대기
|
||||||
|
**수정**: `tenacity` 라이브러리 또는 수동 retry (최대 3회, exponential backoff)
|
||||||
|
**예상 수정 시간**: 30분
|
||||||
|
|
||||||
|
#### 이슈 #5: Parquet Upsert 시 중복 타임스탬프 미제거
|
||||||
|
**파일**: `scripts/fetch_history.py:314`
|
||||||
|
**심각도**: 중간 (지표 이중 계산)
|
||||||
|
**문제**: `sort_index()`만 하고 `drop_duplicates()` 미수행
|
||||||
|
**영향**: API 응답에 중복 타임스탬프 있으면 RSI/MACD 등이 이중 계산됨
|
||||||
|
**수정**: `df[~df.index.duplicated(keep='last')]` 추가
|
||||||
|
**예상 수정 시간**: 5분
|
||||||
|
|
||||||
|
#### 이슈 #6: record_pnl()에 asyncio.Lock 미사용
|
||||||
|
**파일**: `src/risk_manager.py:55`
|
||||||
|
**심각도**: 중간 (멀티심볼에서 일일 손실 한도 부정확)
|
||||||
|
**문제**: `record_pnl()`이 `self.daily_pnl` 수정하지만 Lock 미사용
|
||||||
|
**영향**: 멀티심볼 동시 호출 시 경쟁 조건 → 일일 손실 한도 체크 오류
|
||||||
|
**수정**: `async def record_pnl()` + `async with self._lock:` 추가
|
||||||
|
**예상 수정 시간**: 5분
|
||||||
|
|
||||||
|
#### 이슈 #8: User Data Stream — exit_price 기본값 0.0
|
||||||
|
**파일**: `src/user_data_stream.py:95`
|
||||||
|
**심각도**: 중간 (PnL 오계산)
|
||||||
|
**문제**: `order.get("ap", "0")` → exit_price=0.0 (필드 누락 시)
|
||||||
|
**영향**: 청산가가 0이면 PnL 계산 완전 오류
|
||||||
|
**수정**: `if exit_price == 0.0: return; logger.warning(...)`
|
||||||
|
**예상 수정 시간**: 10분
|
||||||
|
|
||||||
|
### 2.3 다음 스프린트 (Minor)
|
||||||
|
|
||||||
|
#### 이슈 #7: 백테스터 Equity Curve 미구현
|
||||||
|
**파일**: `src/backtester.py:509-510`
|
||||||
|
**문제**: `_record_equity()`가 `pass`로 비어 있음
|
||||||
|
**영향**: MDD 계산이 실현 PnL만 기준 → 미실현 PnL 무시 → MDD 과소평가
|
||||||
|
**수정**: 포트폴리오 가치(equity) = 초기 자본 + 누적 PnL 계산
|
||||||
|
**예상 수정 시간**: 1시간
|
||||||
|
|
||||||
|
#### 이슈 #9: 거래량 급증 진입 조건 의도 불일치
|
||||||
|
**파일**: `src/indicators.py:115-118`
|
||||||
|
**문제**: `(vol_surge or long_signals >= threshold + 1)` — OR 조건
|
||||||
|
**의도 추측**: "강한 신호(threshold+1) + 거래량 급증" = AND
|
||||||
|
**현재**: 거래량 급증만으로도 진입 허용 = OR
|
||||||
|
**현재 상태**: 전략 스윕(ADX=25, Vol=2.5)에서는 큰 문제 없음
|
||||||
|
**필요**: 의도 확인 후 조건 정리
|
||||||
|
**예상 수정 시간**: 10분 (확인 후)
|
||||||
|
|
||||||
|
#### 이슈 #10: ML 모델 피처 불일치 시 Silent Failure
|
||||||
|
**파일**: `src/ml_filter.py:152`
|
||||||
|
**문제**: ONNX와 FEATURE_COLS 불일치 → 예외 잡고 `False` 반환 (모든 신호 차단)
|
||||||
|
**영향**: 사용자가 원인을 알 수 없음 (디버깅 어려움)
|
||||||
|
**수정**: ERROR 로깅 + Discord 초회 알림
|
||||||
|
**예상 수정 시간**: 15분
|
||||||
|
|
||||||
|
#### 이슈 #11-13: 기타 (데이터셋 검증, AsyncClient retry, 타임존 처리)
|
||||||
|
**총 예상 시간**: 각 10-30분
|
||||||
|
|
||||||
|
### 2.4 테스트 커버리지
|
||||||
|
|
||||||
|
**전체 테스트**: 15개 파일, 136개 케이스
|
||||||
|
|
||||||
|
**커버되는 항목**:
|
||||||
|
- ✅ 기술 지표 계산 (RSI 범위, MACD 컬럼, 볼린저 부등식)
|
||||||
|
- ✅ ADX 횡보장 필터 (ADX < 25 시 신호 차단)
|
||||||
|
- ✅ ML 피처 추출 (26개 피처, RS 분모 0 처리, NaN 없음)
|
||||||
|
- ✅ 동적 증거금 비율 계산
|
||||||
|
- ✅ 동일 방향 포지션 제한
|
||||||
|
- ✅ 일일 손실 한도 (5%)
|
||||||
|
- ✅ 반대 시그널 재진입
|
||||||
|
- ✅ Parquet Upsert + OI=0 처리
|
||||||
|
- ✅ 주간 리포트 (백테스트, 대시보드 API, 추이 분석)
|
||||||
|
|
||||||
|
**커버되지 않는 항목**:
|
||||||
|
- ❌ User Data Stream TP/SL (WebSocket 의존)
|
||||||
|
- ❌ Discord 알림 전송 (외부 서비스)
|
||||||
|
|
||||||
|
**평가**: 핵심 로직은 잘 테스트되어 있으나, WebSocket 기반 실시간 이벤트 처리는 미테스트. 이는 실제 운영에서 버그의 원천이 될 수 있음 (#8 이슈 예시).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 설계 문서 분석 (47개 파일)
|
||||||
|
|
||||||
|
### 3.1 문서 조직과 진행 상황
|
||||||
|
|
||||||
|
**초기 단계 (2026-03-01~02)**
|
||||||
|
- `2026-03-01-xrp-futures-autotrader.md` (1325줄): 프로젝트 전체 초기 계획
|
||||||
|
- `2026-03-01-ml-filter-design.md`: ML 필터 설계 (최소한)
|
||||||
|
- `2026-03-01-*-design/plan`: 15개 주요 기능별 설계+계획 쌍
|
||||||
|
|
||||||
|
**중기 개발 (2026-03-03~04)**
|
||||||
|
- ADX ML 피처 마이그레이션
|
||||||
|
- Optuna 하이퍼파라미터 튜닝
|
||||||
|
- OI 파생 피처 설계
|
||||||
|
|
||||||
|
**최근 (2026-03-05~07)**
|
||||||
|
- 멀티심볼 거래 설계 + 구현 (정상 작동 중)
|
||||||
|
- 다중심볼 대시보드 설계 + 계획
|
||||||
|
- 전략 파라미터 스윕 계획 (실행됨, PF 1.57~2.39 달성)
|
||||||
|
- **코드 리뷰 개선사항** (2026-03-07): 13개 이슈 정리
|
||||||
|
|
||||||
|
### 3.2 문서 품질
|
||||||
|
|
||||||
|
**강점**:
|
||||||
|
- **명확한 설계 의도**: 각 문서가 "목적 → 선택이유 → 기각된 대안 → 구현"의 구조
|
||||||
|
- **예시 코드 포함**: 설계를 검증할 구체적 코드 샘플 제시
|
||||||
|
- **트레이드오프 분석**: 멀티심볼 거래 시 "단일 Bot + 라우팅 vs 독립 Bot 인스턴스" 비교
|
||||||
|
|
||||||
|
**약점**:
|
||||||
|
- **기술 부채 시각화 미흡**: 47개 문서가 있지만, "전체 진행률/리스크/미해결 항목"을 한눈에 보는 대시보드 없음
|
||||||
|
- **의사결정 추적성 부족**: "왜 ADX=25 필터를 선택했는가?" 같은 근거가 전략 스윕 이후에 추가됨 (역순 설계)
|
||||||
|
- **문서 간 중복**: ML 필터 설계, 피처 설계, 데이터셋 빌더 등이 서로 겹침
|
||||||
|
|
||||||
|
### 3.3 설계의 실제 반영도
|
||||||
|
|
||||||
|
**잘 반영된 항목**:
|
||||||
|
- ✅ 5-레이어 아키텍처 (README + ARCHITECTURE 명시, 코드 구조 일치)
|
||||||
|
- ✅ 멀티심볼 독립 Bot + 공유 RiskManager (설계 문서 + 실제 코드 일치)
|
||||||
|
- ✅ ML 필터 선택적 활성화 (`NO_ML_FILTER=true` 기본값, 근거 문서 확보)
|
||||||
|
- ✅ Walk-Forward 검증 (백테스트 엔진 구현, 주간 리포트에 적용)
|
||||||
|
- ✅ Discord 알림 (설계 문서 + 구현 + 테스트)
|
||||||
|
|
||||||
|
**부분적으로 반영된 항목**:
|
||||||
|
- △ 전략 파라미터 자동 스윕 (설계 문서는 있으나, "파라미터 자동 적용"은 수동 검토 단계)
|
||||||
|
- △ 하이퍼파라미터 튜닝 (Optuna 설계 문서 있으나, 실제 사용 현황 불명)
|
||||||
|
|
||||||
|
**미반영 항목**:
|
||||||
|
- ❌ Equity curve (문서는 설계되었으나, 코드는 `pass`)
|
||||||
|
- ❌ Testnet 자동 검증 (2026-03-03 문서는 1m/125x 테스트넷 계획, 현재 상태 불명)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 운영 안정성 평가
|
||||||
|
|
||||||
|
### 4.1 리스크 관리 메커니즘
|
||||||
|
|
||||||
|
| 기능 | 구현 | 테스트 | 평가 |
|
||||||
|
|------|:----:|:-----:|------|
|
||||||
|
| 일일 손실 한도 (5%) | ✅ | ✅ | 명확함 |
|
||||||
|
| 동적 증거금 비율 | ✅ | ✅ | 선형 감소 로직 검증됨 |
|
||||||
|
| 동일 방향 제한 (2개) | ✅ | ✅ | asyncio.Lock 필요 (#6) |
|
||||||
|
| 포지션 복구 (봇 재시작) | ✅ | △ | 코드는 있으나 테스트 미흡 |
|
||||||
|
| TP/SL 자동 청산 | ✅ | ❌ | WebSocket 미테스트 (#8 버그 증명) |
|
||||||
|
| 반대 시그널 재진입 | ✅ | △ | 경쟁 조건 가능성 |
|
||||||
|
|
||||||
|
### 4.2 외부 의존성
|
||||||
|
|
||||||
|
| 서비스 | 용도 | Retry 로직 | 평가 |
|
||||||
|
|--------|------|:----------:|------|
|
||||||
|
| Binance Futures REST API | 주문, 잔고, OI, 펀딩비 | △ (부분) | #4 이슈: fetch_history retry 없음 |
|
||||||
|
| Binance WebSocket | 캔들, User Data | △ | #12 이슈: AsyncClient 생성 실패 시 전체 크래시 |
|
||||||
|
| Discord Webhook | 알림 | ❌ | 실패 시 봇 중단될 수 있음 (현황 불명) |
|
||||||
|
|
||||||
|
### 4.3 운영 자동화
|
||||||
|
|
||||||
|
**진행 중인 자동화**:
|
||||||
|
- ✅ 매주 일요일 3시 KST: `weekly_report.py` (크론탭)
|
||||||
|
- 데이터 수집 → Walk-Forward 백테스트 → 실전 통계 조회 → 추이 분석 → Discord 알림
|
||||||
|
- ✅ 모델 핫리로드: mtime 변경 감지 후 자동 리로드 (15분마다)
|
||||||
|
- ✅ CI/CD (Jenkins + Gitea Registry): `main` 푸시 → 빌드 → 레지스트리 푸시 → 운영 배포
|
||||||
|
|
||||||
|
**자동화 부족**:
|
||||||
|
- ❌ 에러 자동 복구 (AsyncClient 생성 실패 시 5회 retry 필요)
|
||||||
|
- ❌ API Rate Limit 자동 처리 (exponential backoff 필요)
|
||||||
|
- ❌ Parquet 데이터 검증 (타임존, 중복 타임스탬프)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 성능 및 검증 기준
|
||||||
|
|
||||||
|
### 5.1 전략 파라미터 스윕 결과
|
||||||
|
|
||||||
|
**테스트 기간**: 과거 데이터 (Walk-Forward 방식)
|
||||||
|
**테스트 심볼**: XRPUSDT
|
||||||
|
**조합 수**: 324개 (5 파라미터 × 3~4 값 각각)
|
||||||
|
|
||||||
|
| 파라미터 | 범위 | 최적값 | 영향도 |
|
||||||
|
|---------|------|--------|--------|
|
||||||
|
| ADX_THRESHOLD | 0, 20, 25, 30 | 25 | ⭐⭐⭐ (가장 중요) |
|
||||||
|
| ATR_SL_MULT | 1.0, 1.5, 2.0 | 2.0 | ⭐⭐ |
|
||||||
|
| ATR_TP_MULT | 2.0, 3.0, 4.0 | 2.0 | ⭐⭐ |
|
||||||
|
| SIGNAL_THRESHOLD | 3, 4, 5 | 3 | ⭐ |
|
||||||
|
| VOL_MULTIPLIER | 1.5, 2.0, 2.5 | 2.5 | ⭐⭐ |
|
||||||
|
|
||||||
|
**결과**: **PF 1.57 ~ 2.39** (심볼·조합에 따라 변동)
|
||||||
|
|
||||||
|
**평가**:
|
||||||
|
- ADX ≥ 25 필터가 가장 효과적 (횡보장 노이즈 신호 제거)
|
||||||
|
- 전략 파라미터가 타당한 범위에서 탐색됨
|
||||||
|
- 그러나 **과거 데이터 기반** → 현재 시장에서도 동일 성능 보장 불가
|
||||||
|
- **실전 거래 통계**는 README에 없음 (운영 대시보드 API 조회만 가능)
|
||||||
|
|
||||||
|
### 5.2 ML 모델 평가
|
||||||
|
|
||||||
|
**현재 상태**: 비활성화 (`NO_ML_FILTER=true`)
|
||||||
|
|
||||||
|
**근거**:
|
||||||
|
- Walk-Forward 5폴드 검증에서 각 폴드 학습 세트 ~27건 유효 신호
|
||||||
|
- LightGBM이 의미 있는 패턴을 학습하기에는 표본 부족
|
||||||
|
- ADX + 거래량만으로 PF 1.5 이상 달성 → ML 추가 필요성 낮음
|
||||||
|
|
||||||
|
**재활성화 조건**:
|
||||||
|
- 누적 거래 ≥ 150건 (현재: 불명, 버그 #2로 인해 계산 오류)
|
||||||
|
- PF < 1.0 또는 PF 3주 연속 하락
|
||||||
|
|
||||||
|
**평가**: 보수적이고 합리적 판단. 다만 150건 기준이 실제 달성되는지 확인 필요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 개발 프로세스 평가
|
||||||
|
|
||||||
|
### 6.1 설계-구현 프로세스
|
||||||
|
|
||||||
|
**강점**:
|
||||||
|
- 기능별로 `*-design.md` + `*-plan.md` 쌍 작성 (설계 의도 기록)
|
||||||
|
- ARCHITECTURE.md에 5-레이어 구조와 동작 시나리오 상세 기술
|
||||||
|
- 코드 리뷰 문서(2026-03-07)로 이슈 우선순위 정리
|
||||||
|
|
||||||
|
**약점**:
|
||||||
|
- 47개 문서 중 많은 부분이 "과거 설계 기록" (실제 구현과 시차)
|
||||||
|
- "설계 → 검증(테스트) → 문서화"의 역순 진행 보임 (특히 전략 파라미터 스윕은 후행 검증)
|
||||||
|
- 마이그레이션/리팩토링 문서가 많음 (ADX 마이그레이션, OI 피처 마이그레이션) → 초기 설계에 미흡했음을 시사
|
||||||
|
|
||||||
|
### 6.2 코드 리뷰 프로세스
|
||||||
|
|
||||||
|
**현황**:
|
||||||
|
- 2026-03-07 코드 리뷰에서 **13개 이슈 발견 및 우선순위 정리**
|
||||||
|
- Critical 2개: 즉시 수정 필요
|
||||||
|
- Important 6개: 이번 주 수정 권장
|
||||||
|
- Minor 5개: 다음 스프린트
|
||||||
|
|
||||||
|
**평가**:
|
||||||
|
- ✅ 이슈를 체계적으로 정리하고 우선순위 명시
|
||||||
|
- ✅ 각 이슈에 대해 파일명, 라인 수, 영향도, 수정 시간 제시
|
||||||
|
- ❌ 어느 이슈가 실제로 수정되었는지 추적이 없음 (상태: "부분 완료")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 문제점 및 개선 제안
|
||||||
|
|
||||||
|
### 7.1 즉시 조치 (오늘~내일)
|
||||||
|
|
||||||
|
| 번호 | 이슈 | 영향 | 수정 시간 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| #1 | OI division by zero | 봇 크래시 | 5분 |
|
||||||
|
| #2 | 누적 트레이드 계산 오류 | ML 재학습 트리거 오작동 | 5분 |
|
||||||
|
|
||||||
|
**조치 없을 시 리스크**:
|
||||||
|
- #1: 당일 운영 중 봇 크래시 가능
|
||||||
|
- #2: ML 재활성화 시점 오판
|
||||||
|
|
||||||
|
### 7.2 이번 주 조치
|
||||||
|
|
||||||
|
| 번호 | 이슈 | 우선도 | 수정 시간 |
|
||||||
|
|------|------|--------|---------|
|
||||||
|
| #4 | fetch_history retry | 높음 | 30분 |
|
||||||
|
| #5 | Parquet 중복 제거 | 중간 | 5분 |
|
||||||
|
| #6 | record_pnl Lock | 높음 | 5분 |
|
||||||
|
| #8 | exit_price=0 방어 | 높음 | 10분 |
|
||||||
|
|
||||||
|
**조치 없을 시 리스크**:
|
||||||
|
- #4: 주간 데이터 수집 실패 → 주간 리포트 미생성
|
||||||
|
- #6: 멀티심볼 운영 시 일일 손실 한도 부정확 (위험)
|
||||||
|
- #8: TP/SL 체결 시 PnL 오계산 (통계 왜곡)
|
||||||
|
|
||||||
|
### 7.3 ML 재활성화 전 (필수)
|
||||||
|
|
||||||
|
| 번호 | 이슈 | 수정 시간 |
|
||||||
|
|------|------|---------|
|
||||||
|
| #3 | Training-Serving Skew (OI/펀딩비 피처) | 30분 |
|
||||||
|
|
||||||
|
### 7.4 구조적 개선 제안
|
||||||
|
|
||||||
|
#### 제안 1: 설계 의도 문서화
|
||||||
|
**현황**: 47개 문서가 분산되어 있어 "현재 상태 파악"이 어려움
|
||||||
|
**개선**:
|
||||||
|
- `IMPLEMENTATION_STATUS.md` 추가
|
||||||
|
- 각 기능별 설계 → 구현 → 테스트 → 배포 상태 추적
|
||||||
|
- 마지막 수정 날짜 + 담당자 명시
|
||||||
|
|
||||||
|
#### 제안 2: WebSocket 기반 이벤트 테스트
|
||||||
|
**현황**: User Data Stream TP/SL 감지가 미테스트
|
||||||
|
**개선**:
|
||||||
|
- `test_user_data_stream_integration.py` 추가
|
||||||
|
- 모의 WebSocket 메시지 시뮬레이션 (pytest-asyncio)
|
||||||
|
- 특히 `exit_price=0.0` 엣지 케이스 테스트
|
||||||
|
|
||||||
|
#### 제안 3: 멀티심볼 동시성 테스트
|
||||||
|
**현황**: 단위 테스트는 있으나, "N개 심볼 동시 거래 시 경쟁 조건" 미테스트
|
||||||
|
**개선**:
|
||||||
|
- `test_multisymbol_concurrent.py` 추가
|
||||||
|
- 각 심볼이 동시에 포지션 진입/청산 시뮬레이션
|
||||||
|
- asyncio.Lock이 제대로 작동하는지 검증
|
||||||
|
|
||||||
|
#### 제안 4: API Retry 정책 통일
|
||||||
|
**현황**: fetch_history.py에만 retry 없음 → 다른 모듈도 검토 필요
|
||||||
|
**개선**:
|
||||||
|
- `src/binance_client.py` (또는 exchange.py)에 retry decorator 추가
|
||||||
|
- `tenacity` 라이브러리 사용 (exponential backoff + jitter)
|
||||||
|
- Rate limit(429) 감지 → 최대 5회 재시도
|
||||||
|
|
||||||
|
#### 제안 5: 실전 성능 대시보드 추가
|
||||||
|
**현황**: 백테스트 성능(PF 1.57~2.39)은 있으나, 실전 거래 성능 미기록
|
||||||
|
**개선**:
|
||||||
|
- `scripts/extract_live_stats.py` 추가
|
||||||
|
- 운영 대시보드 API(`GET /api/trades`, `GET /api/stats`) 조회 후 JSON 저장
|
||||||
|
- README에 "실전 거래 성능" 섹션 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 결론
|
||||||
|
|
||||||
|
### 8.1 종합 평가
|
||||||
|
|
||||||
|
| 항목 | 평가 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| 아키텍처 설계 | ⭐⭐⭐⭐ (90/100) | 5-레이어 분리 명확, 멀티심볼 구현 양호 |
|
||||||
|
| 코드 품질 | ⭐⭐⭐ (75/100) | 핵심 로직은 건실하나, 엣지 케이스 미흡 |
|
||||||
|
| 테스트 커버리지 | ⭐⭐⭐ (75/100) | 136개 케이스, 단위 테스트 양호 / WebSocket 미테스트 |
|
||||||
|
| 설계 문서 | ⭐⭐⭐ (80/100) | 47개 파일로 상세하나, 진행 상황 추적 미흡 |
|
||||||
|
| 운영 자동화 | ⭐⭐⭐ (80/100) | 주간 리포트 + CI/CD 갖춤 / 에러 자동 복구 부족 |
|
||||||
|
| **종합** | **⭐⭐⭐⭐ (80/100)** | **초기 단계 프로젝트로는 양호, 즉시 수정 필요 항목 2개** |
|
||||||
|
|
||||||
|
### 8.2 가동 여부 판단
|
||||||
|
|
||||||
|
**현재 가동 가능**: 예, 그러나 위험 요소 있음
|
||||||
|
|
||||||
|
**조건**:
|
||||||
|
1. **즉시**: 버그 #1, #2 수정 (합계 10분)
|
||||||
|
2. **당일**: 이슈 #4, #6, #8 수정 (합계 45분)
|
||||||
|
3. **이번 주**: 이슈 #5, #3(ML 활성화 계획 있으면) 수정
|
||||||
|
|
||||||
|
**위험 요소**:
|
||||||
|
- ❌ User Data Stream TP/SL이 미테스트 → 실제 청산이 작동하지 않을 가능성
|
||||||
|
- ❌ 멀티심볼 동시성: `record_pnl()` Lock 미사용 → 리스크 한도 부정확 가능
|
||||||
|
- ❌ 데이터 품질: Parquet 중복/타임존 미처리 → 지표 계산 오류 가능
|
||||||
|
|
||||||
|
### 8.3 다음 단계
|
||||||
|
|
||||||
|
**즉시 (오늘 중)**:
|
||||||
|
- [x] 버그 #1 수정: OI division by zero _(commit 60510c0)_
|
||||||
|
- [x] 버그 #2 수정: 누적 트레이드 계산 _(commit 60510c0)_
|
||||||
|
|
||||||
|
**당일 야간**:
|
||||||
|
- [x] 이슈 #4 수정: fetch_history retry 로직
|
||||||
|
- [x] 이슈 #6 수정: record_pnl asyncio.Lock _(commit 60510c0)_
|
||||||
|
- [x] 이슈 #8 수정: exit_price=0.0 방어 _(commit 60510c0)_
|
||||||
|
|
||||||
|
**이번 주**:
|
||||||
|
- [x] 이슈 #5 수정: Parquet 중복 제거 _(commit 60510c0)_
|
||||||
|
- [ ] 이슈 #13 수정: 타임존 처리
|
||||||
|
- [ ] 이슈 #3 분석: Training-Serving Skew (ML 재활성화 계획이면)
|
||||||
|
|
||||||
|
**다음 2주**:
|
||||||
|
- [ ] IMPLEMENTATION_STATUS.md 작성 (설계→구현→테스트→배포 추적)
|
||||||
|
- [ ] WebSocket 통합 테스트 작성
|
||||||
|
- [ ] 멀티심볼 동시성 테스트 작성
|
||||||
|
|
||||||
|
### 8.4 최종 의견
|
||||||
|
|
||||||
|
CoinTrader는 **아키텍처가 건실하고 설계 의도가 명확한 프로젝트**입니다. 5-레이어 분리, 멀티심볼 동시 거래, 완전한 MLOps 파이프라인 등은 초기 자동매매 봇 프로젝트 치고는 수준이 높습니다.
|
||||||
|
|
||||||
|
그러나 **즉시 수정이 필요한 버그 2개**(Division by Zero, 누적 계산 오류)와 **엣지 케이스 미흡**(WebSocket 미테스트, asyncio 경쟁 조건, API retry 부족)이 있어서, 실제 운영 환경에 투입하기 전에 최소 1주일의 안정화 기간이 필요합니다.
|
||||||
|
|
||||||
|
특히 **User Data Stream TP/SL 감지**가 미테스트되어 있다는 점이 가장 우려스럽습니다. 이 부분이 작동하지 않으면 포지션이 영구히 열려 있을 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: 검토 범위
|
||||||
|
|
||||||
|
**검토 대상**:
|
||||||
|
- `README.md` — 사용자 가이드
|
||||||
|
- `ARCHITECTURE.md` — 기술 아키텍처
|
||||||
|
- 47개 설계/계획 문서 (2026-03-01 ~ 2026-03-07)
|
||||||
|
- 코드 리뷰 개선사항 (2026-03-07-code-review-improvements.md)
|
||||||
|
|
||||||
|
**검토 제외**:
|
||||||
|
- 실제 소스 코드 (src/, scripts/, tests/)
|
||||||
|
- 운영 로그 및 실전 거래 데이터
|
||||||
|
- Docker 설정 및 CI/CD 파이프라인 상세
|
||||||
|
|
||||||
|
**검토 방식**:
|
||||||
|
- 문서 정합성 검증
|
||||||
|
- 설계 결정 타당성 분석
|
||||||
|
- 버그 및 이슈 우선순위 검토
|
||||||
|
- 아키텍처 강점/약점 평가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**보고서 작성**: 2026-03-07
|
||||||
|
**담당자**: Claude AI
|
||||||
|
**버전**: 1.0
|
||||||
253
docs/plans/code-review-2026-03-16.md
Normal file
253
docs/plans/code-review-2026-03-16.md
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
# CoinTrader 코드 점검 보고서
|
||||||
|
|
||||||
|
> 작성일: 2026-03-16
|
||||||
|
> 대상: CoinTrader 전체 소스 코드 (bot.py, exchange.py, risk_manager.py, data_stream.py, user_data_stream.py, ml_filter.py, ml_features.py, config.py)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 요약
|
||||||
|
|
||||||
|
| 심각도 | 건수 |
|
||||||
|
|--------|------|
|
||||||
|
| 🔴 심각 (버그 / 실제 자금 손실 위험) | 4 (✅ 전부 수정 완료) |
|
||||||
|
| 🟡 경고 (논리 오류 / 운영 리스크) | 6 (✅ 전부 수정 완료) |
|
||||||
|
| 🔵 개선 (코드 품질 / 유지보수) | 5 |
|
||||||
|
|
||||||
|
아키텍처 설계 자체(멀티심볼 독립 인스턴스, 공유 RiskManager)는 합리적이다. 문제는 멀티심볼 확장 과정에서 공유 상태(`RiskManager`)에 대한 동시성 처리가 불완전하고, 자금 관련 계산 로직(마진 비율, PnL 폴백)에 실제 버그가 존재한다는 점이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 심각 — 버그 / 실제 자금 손실 위험
|
||||||
|
|
||||||
|
### 1. 마진 비율 계산 불일치 (`bot.py` L190-196)
|
||||||
|
|
||||||
|
**문제:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
per_symbol_balance = balance / num_symbols # 심볼별로 나눔
|
||||||
|
margin_ratio = self.risk.get_dynamic_margin_ratio(balance) # 전체 잔고 기준
|
||||||
|
quantity = self.exchange.calculate_quantity(
|
||||||
|
balance=per_symbol_balance, # 나눈 값
|
||||||
|
margin_ratio=margin_ratio # 전체 기준 비율 → 불일치
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`margin_ratio`는 전체 잔고 기준으로 계산되었는데, `per_symbol_balance`(나눈 값)에 곱해진다. 결과적으로 마진 비율 감소 효과가 의도한 것의 `num_symbols`배로 증폭된다.
|
||||||
|
|
||||||
|
**수정 방향:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
per_symbol_balance = balance / num_symbols
|
||||||
|
margin_ratio = self.risk.get_dynamic_margin_ratio(per_symbol_balance) # 나눈 값 기준
|
||||||
|
```
|
||||||
|
|
||||||
|
또는 전체 잔고로 수량을 계산하고 나중에 심볼 수로 나누는 방식으로 통일해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `_place_algo_order`의 `algoType="CONDITIONAL"` 하드코딩 (`exchange.py` L149)
|
||||||
|
|
||||||
|
**문제:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
params = dict(
|
||||||
|
symbol=self.symbol,
|
||||||
|
side=side,
|
||||||
|
algoType="CONDITIONAL", # 하드코딩
|
||||||
|
type=order_type,
|
||||||
|
...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Binance FAPI `/fapi/v1/algoOrder`의 `algoType`은 `VP`, `TWAP` 등 실행 알고리즘용이다. `STOP_MARKET` / `TAKE_PROFIT_MARKET` 같은 조건부 주문은 `/fapi/v1/order`에 `reduceOnly=true`로 전송해야 한다. 이 경로가 실제로 동작하지 않으면 SL/TP 주문이 아예 등록되지 않아 무한 손실 가능.
|
||||||
|
|
||||||
|
**수정 방향:** 테스트넷에서 즉시 검증. 실패 시 일반 `place_order` 경로로 대체하고 `_place_algo_order` 삭제.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 폴백 PnL 계산 오류 (`bot.py` L328-334)
|
||||||
|
|
||||||
|
**문제:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
pnl_rows, comm_rows = await self.exchange.get_recent_income(limit=5)
|
||||||
|
if pnl_rows:
|
||||||
|
realized_pnl = float(pnl_rows[-1].get("income", "0")) # 마지막 1건만 사용
|
||||||
|
```
|
||||||
|
|
||||||
|
멀티심볼 환경에서 `limit=5` 조회 시 다른 심볼의 PnL이 섞일 수 있다. 마지막 항목 하나만 쓰는 것은 다중 체결 건이 있을 때 틀린 값을 기록한다. SYNC 청산에서 잘못된 PnL이 기록되면 `daily_pnl`이 오염되어 손실 한도 체크 자체가 무의미해진다.
|
||||||
|
|
||||||
|
**수정 방향:** 조회 시 `symbol` 파라미터로 필터링하고, 해당 포지션의 거래 ID 범위를 기준으로 합산해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `_is_reentering` 타이밍 레이스 컨디션 (`bot.py` L401, L421)
|
||||||
|
|
||||||
|
**문제:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._is_reentering = True
|
||||||
|
try:
|
||||||
|
await self._close_position(position) # 청산 주문 전송
|
||||||
|
# ← 이 시점에 User Data Stream 콜백 도착 가능
|
||||||
|
await self._open_position(signal, df) # 신규 진입
|
||||||
|
finally:
|
||||||
|
self._is_reentering = False
|
||||||
|
```
|
||||||
|
|
||||||
|
청산 주문 전송 직후 User Data Stream 콜백이 도착하면, `_is_reentering = True`인 상태에서 `risk.close_position`이 호출된다. 그 직후 `_open_position`이 `risk.register_position`을 호출하며 상태가 겹친다. `asyncio`의 단일 스레드 특성 덕분에 `await` 사이에는 안전하지만, 콜백 순서와 타이밍에 따라 포지션 카운트가 틀어질 수 있다.
|
||||||
|
|
||||||
|
**수정 방향:** `_close_and_reenter` 내에서 포지션 상태 전환을 명시적으로 관리하고, `_on_position_closed`에서 `_is_reentering` 플래그를 확인하는 것 외에도 명시적인 상태 머신 전환을 추가한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 경고 — 논리 오류 / 운영 리스크
|
||||||
|
|
||||||
|
### 5. `reset_daily()` 자동 호출 없음 (`risk_manager.py`)
|
||||||
|
|
||||||
|
메서드는 정의되어 있으나 어디서도 호출되지 않는다. 봇이 며칠 연속 실행되면 `daily_pnl`이 계속 누적되어 일일 손실 한도 체크가 무의미해진다.
|
||||||
|
|
||||||
|
**수정 방향:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# main.py 또는 bot.run() 내에서
|
||||||
|
async def _daily_reset_loop(risk: RiskManager):
|
||||||
|
while True:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
next_midnight = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0)
|
||||||
|
await asyncio.sleep((next_midnight - now).total_seconds())
|
||||||
|
risk.reset_daily()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. 공유 `RiskManager`에서 `set_base_balance` 경쟁 조건 (`bot.py` L429)
|
||||||
|
|
||||||
|
`asyncio.gather`로 3개 봇이 거의 동시에 `run()`을 실행하면 각자 `set_base_balance(balance)`를 호출한다. 마지막으로 호출한 봇의 잔고로 덮어씌워지며, Lock이 없어 순서도 보장되지 않는다.
|
||||||
|
|
||||||
|
**수정 방향:** `initial_balance` 설정을 `main.py`에서 한 번만 수행하고 공유 RiskManager에 주입하거나, 설정 시 Lock으로 보호한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. 진입 주문이 청산으로 잘못 판별 가능 (`user_data_stream.py` L89)
|
||||||
|
|
||||||
|
```python
|
||||||
|
is_close = is_reduce or order_type in _CLOSE_ORDER_TYPES or realized_pnl != 0
|
||||||
|
```
|
||||||
|
|
||||||
|
일부 상황에서 진입 주문 체결 시 소액의 `rp`(실현 손익)가 붙는 경우가 있다. `realized_pnl != 0` 단독 조건이 너무 넓어 진입 주문이 청산으로 잘못 처리될 수 있다.
|
||||||
|
|
||||||
|
**수정 방향:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
is_close = is_reduce or order_type in _CLOSE_ORDER_TYPES
|
||||||
|
# realized_pnl != 0 조건 제거
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. 피처 컬럼명이 XRP에 하드코딩 (`ml_features.py` L10-11)
|
||||||
|
|
||||||
|
```python
|
||||||
|
FEATURE_COLS = [
|
||||||
|
...
|
||||||
|
"xrp_btc_rs", "xrp_eth_rs", # XRP 하드코딩
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
TRX/DOGE 봇도 동일한 피처명을 사용한다. 학습과 추론 간 컬럼명 불일치는 없지만, 의미가 잘못되어 있고 심볼별 모델 학습 시 혼란을 유발한다.
|
||||||
|
|
||||||
|
**수정 방향:** `build_features_aligned` 함수에서 심볼명을 동적으로 포함하거나, 컬럼명을 `primary_btc_rs`, `primary_eth_rs`로 범용화한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. `asyncio.get_event_loop()` deprecated 패턴 (`exchange.py` 전반)
|
||||||
|
|
||||||
|
Python 3.10+에서 실행 중인 루프가 없을 때 `get_event_loop()`은 `DeprecationWarning`을 발생시킨다.
|
||||||
|
|
||||||
|
**수정 방향:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Before
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, lambda: ...)
|
||||||
|
|
||||||
|
# After
|
||||||
|
await asyncio.to_thread(lambda: ...)
|
||||||
|
# 또는
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
await loop.run_in_executor(None, lambda: ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. 프리로드가 순차적으로 처리됨 (`data_stream.py` L164-183)
|
||||||
|
|
||||||
|
```python
|
||||||
|
for symbol in self.symbols: # 순차 처리
|
||||||
|
klines = await client.futures_klines(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
심볼 3개를 순차 REST 조회하면 시작 시간이 약 3배 길어진다.
|
||||||
|
|
||||||
|
**수정 방향:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _preload_one(client, symbol):
|
||||||
|
...
|
||||||
|
|
||||||
|
await asyncio.gather(*[_preload_one(client, s) for s in self.symbols])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔵 개선 — 코드 품질 / 유지보수
|
||||||
|
|
||||||
|
### 11. `config.py` 데드 필드
|
||||||
|
|
||||||
|
`stop_loss_pct`, `take_profit_pct`, `trailing_stop_pct`가 dataclass 기본값으로만 존재하고 `__post_init__`에서 환경변수로 로드되지 않는다. `atr_sl_mult`/`atr_tp_mult`로 대체되었으나 정리되지 않았다. 혼란을 줄이기 위해 삭제하거나 환경변수 로드를 추가해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. 매 캔들마다 불필요한 REST API 조회 (`bot.py` L158)
|
||||||
|
|
||||||
|
```python
|
||||||
|
position = await self.exchange.get_position() # 15분마다 호출
|
||||||
|
```
|
||||||
|
|
||||||
|
`current_trade_side`로 로컬 상태를 이미 관리하고 있다. User Data Stream 콜백과 `_position_monitor` 폴백이 있으므로, `process_candle`에서는 로컬 상태만 확인하면 충분하다. 불필요한 API rate limit을 소비하고 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 13. `main.py` 파일 없음
|
||||||
|
|
||||||
|
README와 ARCHITECTURE.md에 진입점으로 언급되지만 실제 파일이 없다. 배포 시 어떻게 봇을 실행하는지 코드로 확인할 수 없다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 14. `MIN_NOTIONAL = 5.0` 하드코딩 (`exchange.py` L20)
|
||||||
|
|
||||||
|
Binance의 최소 명목금액은 심볼마다 다르고 정책 변경이 가능하다. `exchange_info`의 `filters`에서 `MIN_NOTIONAL` 또는 `NOTIONAL` 필터를 읽어야 정확하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 15. ML 필터 예측 오류 시 무조건 진입 차단 (`ml_filter.py` L153)
|
||||||
|
|
||||||
|
```python
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"ML 필터 예측 오류 (진입 차단): {e}")
|
||||||
|
return False # 모든 거래 차단
|
||||||
|
```
|
||||||
|
|
||||||
|
모델에 버그가 생기면 거래가 전면 중단된다. 오류 유형에 따라 `True`(폴백 허용)를 반환할지 `False`(차단)를 반환할지 구분하고, 오류 횟수를 카운팅하여 Discord 알림을 보내는 것이 바람직하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 우선 처리 권장 순서
|
||||||
|
|
||||||
|
1. **즉시**: `_place_algo_order` API 경로 테스트넷 검증 (#2)
|
||||||
|
2. **즉시**: 마진 비율 계산 불일치 수정 (#1)
|
||||||
|
3. **이번 주**: `reset_daily()` 자동 호출 추가 (#5)
|
||||||
|
4. **이번 주**: `set_base_balance` 경쟁 조건 수정 (#6)
|
||||||
|
5. **이번 주**: 폴백 PnL 조회 로직 개선 (#3)
|
||||||
|
6. **다음 배포 전**: `is_close` 판별 조건 수정 (#7), `asyncio.get_event_loop` 교체 (#9), 프리로드 병렬화 (#10)
|
||||||
68
main.py
68
main.py
@@ -1,4 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import signal
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from src.config import Config
|
from src.config import Config
|
||||||
@@ -9,18 +11,82 @@ from src.logger_setup import setup_logger
|
|||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
async def _daily_reset_loop(risk: RiskManager):
|
||||||
|
"""매일 UTC 자정에 daily_pnl을 초기화한다."""
|
||||||
|
while True:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
next_midnight = (now + timedelta(days=1)).replace(
|
||||||
|
hour=0, minute=0, second=0, microsecond=0,
|
||||||
|
)
|
||||||
|
await asyncio.sleep((next_midnight - now).total_seconds())
|
||||||
|
await risk.reset_daily()
|
||||||
|
|
||||||
|
|
||||||
|
async def _graceful_shutdown(bots: list[TradingBot], tasks: list[asyncio.Task]):
|
||||||
|
"""모든 봇의 오픈 주문 취소 후 태스크를 정리한다."""
|
||||||
|
logger.info("Graceful shutdown 시작 — 오픈 주문 취소 중...")
|
||||||
|
for bot in bots:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(bot.exchange.cancel_all_orders(), timeout=5)
|
||||||
|
logger.info(f"[{bot.symbol}] 오픈 주문 취소 완료")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[{bot.symbol}] 오픈 주문 취소 실패 (무시): {e}")
|
||||||
|
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
for r in results:
|
||||||
|
if isinstance(r, Exception) and not isinstance(r, asyncio.CancelledError):
|
||||||
|
logger.warning(f"태스크 종료 중 예외: {r}")
|
||||||
|
logger.info("Graceful shutdown 완료")
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
setup_logger(log_level="INFO")
|
setup_logger(log_level="INFO")
|
||||||
config = Config()
|
config = Config()
|
||||||
risk = RiskManager(config)
|
risk = RiskManager(config)
|
||||||
|
|
||||||
|
# 기준 잔고를 main에서 한 번만 설정 (경쟁 조건 방지)
|
||||||
|
from src.exchange import BinanceFuturesClient
|
||||||
|
exchange = BinanceFuturesClient(config, symbol=config.symbols[0])
|
||||||
|
balance = await exchange.get_balance()
|
||||||
|
risk.set_base_balance(balance)
|
||||||
|
logger.info(f"기준 잔고 설정: {balance:.2f} USDT")
|
||||||
|
|
||||||
bots = []
|
bots = []
|
||||||
for symbol in config.symbols:
|
for symbol in config.symbols:
|
||||||
bot = TradingBot(config, symbol=symbol, risk=risk)
|
bot = TradingBot(config, symbol=symbol, risk=risk)
|
||||||
bots.append(bot)
|
bots.append(bot)
|
||||||
|
|
||||||
logger.info(f"멀티심볼 봇 시작: {config.symbols} ({len(bots)}개 인스턴스)")
|
logger.info(f"멀티심볼 봇 시작: {config.symbols} ({len(bots)}개 인스턴스)")
|
||||||
await asyncio.gather(*[bot.run() for bot in bots])
|
|
||||||
|
# 시그널 핸들러 등록
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
shutdown_event = asyncio.Event()
|
||||||
|
|
||||||
|
def _signal_handler():
|
||||||
|
logger.warning("종료 시그널 수신 (SIGTERM/SIGINT)")
|
||||||
|
shutdown_event.set()
|
||||||
|
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
loop.add_signal_handler(sig, _signal_handler)
|
||||||
|
|
||||||
|
tasks = [
|
||||||
|
asyncio.create_task(bot.run(), name=f"bot-{bot.symbol}")
|
||||||
|
for bot in bots
|
||||||
|
]
|
||||||
|
tasks.append(asyncio.create_task(_daily_reset_loop(risk), name="daily-reset"))
|
||||||
|
|
||||||
|
# 종료 시그널 대기 vs 태스크 완료 (먼저 발생하는 쪽)
|
||||||
|
shutdown_task = asyncio.create_task(shutdown_event.wait(), name="shutdown-wait")
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
tasks + [shutdown_task],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 시그널이든 태스크 종료든 graceful shutdown 수행
|
||||||
|
shutdown_task.cancel()
|
||||||
|
await _graceful_shutdown(bots, tasks)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
44
main_mtf.py
Normal file
44
main_mtf.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""MTF Pullback Bot — OOS Dry-run Entry Point."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import signal as sig
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from loguru import logger
|
||||||
|
from src.mtf_bot import MTFPullbackBot
|
||||||
|
from src.logger_setup import setup_logger
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
setup_logger(log_level="INFO")
|
||||||
|
logger.info("MTF Pullback Bot 시작 (Dry-run OOS 모드)")
|
||||||
|
|
||||||
|
bot = MTFPullbackBot(symbol="XRP/USDT:USDT")
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
shutdown = asyncio.Event()
|
||||||
|
|
||||||
|
def _on_signal():
|
||||||
|
logger.warning("종료 시그널 수신 (SIGTERM/SIGINT)")
|
||||||
|
shutdown.set()
|
||||||
|
|
||||||
|
for s in (sig.SIGTERM, sig.SIGINT):
|
||||||
|
loop.add_signal_handler(s, _on_signal)
|
||||||
|
|
||||||
|
bot_task = asyncio.create_task(bot.run(), name="mtf-bot")
|
||||||
|
shutdown_task = asyncio.create_task(shutdown.wait(), name="shutdown-wait")
|
||||||
|
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
[bot_task, shutdown_task],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
bot_task.cancel()
|
||||||
|
shutdown_task.cancel()
|
||||||
|
await asyncio.gather(bot_task, shutdown_task, return_exceptions=True)
|
||||||
|
logger.info("MTF Pullback Bot 종료")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
BIN
models/dogeusdt/lgbm_filter_prev.pkl
Normal file
BIN
models/dogeusdt/lgbm_filter_prev.pkl
Normal file
Binary file not shown.
BIN
models/trxusdt/lgbm_filter_prev.pkl
Normal file
BIN
models/trxusdt/lgbm_filter_prev.pkl
Normal file
Binary file not shown.
BIN
models/xrpusdt/lgbm_filter_prev.pkl
Normal file
BIN
models/xrpusdt/lgbm_filter_prev.pkl
Normal file
Binary file not shown.
@@ -5,7 +5,7 @@ python-dotenv==1.0.0
|
|||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
pytest>=8.1.0
|
pytest>=8.1.0
|
||||||
pytest-asyncio>=0.24.0
|
pytest-asyncio>=0.24.0
|
||||||
aiohttp==3.9.3
|
aiohttp>=3.10.11
|
||||||
websockets==12.0
|
websockets==12.0
|
||||||
loguru==0.7.2
|
loguru==0.7.2
|
||||||
lightgbm>=4.3.0
|
lightgbm>=4.3.0
|
||||||
@@ -14,3 +14,5 @@ joblib>=1.3.0
|
|||||||
pyarrow>=15.0.0
|
pyarrow>=15.0.0
|
||||||
onnxruntime>=1.18.0
|
onnxruntime>=1.18.0
|
||||||
optuna>=3.6.0
|
optuna>=3.6.0
|
||||||
|
quantstats>=0.0.81
|
||||||
|
ccxt>=4.5.0
|
||||||
|
|||||||
31436
results/combined/backtest_20260306_222250.json
Normal file
31436
results/combined/backtest_20260306_222250.json
Normal file
File diff suppressed because it is too large
Load Diff
455
results/combined/backtest_20260306_222611.json
Normal file
455
results/combined/backtest_20260306_222611.json
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"symbols": [
|
||||||
|
"XRPUSDT",
|
||||||
|
"TRXUSDT",
|
||||||
|
"DOGEUSDT"
|
||||||
|
],
|
||||||
|
"start": null,
|
||||||
|
"end": null,
|
||||||
|
"initial_balance": 1000.0,
|
||||||
|
"leverage": 10,
|
||||||
|
"fee_pct": 0.04,
|
||||||
|
"slippage_pct": 0.01,
|
||||||
|
"use_ml": true,
|
||||||
|
"ml_threshold": 0.55,
|
||||||
|
"max_daily_loss_pct": 0.05,
|
||||||
|
"max_positions": 3,
|
||||||
|
"max_same_direction": 2,
|
||||||
|
"margin_max_ratio": 0.5,
|
||||||
|
"margin_min_ratio": 0.2,
|
||||||
|
"margin_decay_rate": 0.0006,
|
||||||
|
"atr_sl_mult": 1.5,
|
||||||
|
"atr_tp_mult": 3.0,
|
||||||
|
"min_notional": 5.0
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 15,
|
||||||
|
"total_pnl": 198.4051,
|
||||||
|
"return_pct": 19.84,
|
||||||
|
"win_rate": 53.33,
|
||||||
|
"avg_win": 32.5332,
|
||||||
|
"avg_loss": -8.8372,
|
||||||
|
"profit_factor": 4.21,
|
||||||
|
"max_drawdown_pct": 2.24,
|
||||||
|
"sharpe_ratio": 79.77,
|
||||||
|
"total_fees": 18.8564,
|
||||||
|
"close_reasons": {
|
||||||
|
"TAKE_PROFIT": 7,
|
||||||
|
"REVERSE_SIGNAL": 3,
|
||||||
|
"STOP_LOSS": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trades": [
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-05-23 02:15:00+00:00",
|
||||||
|
"exit_time": "2025-05-23 04:30:00+00:00",
|
||||||
|
"entry_price": 2.470853,
|
||||||
|
"exit_price": 2.438268,
|
||||||
|
"quantity": 674.5,
|
||||||
|
"sl": 2.487145,
|
||||||
|
"tp": 2.438268,
|
||||||
|
"gross_pnl": 21.978348,
|
||||||
|
"entry_fee": 0.666636,
|
||||||
|
"exit_fee": 0.657845,
|
||||||
|
"net_pnl": 20.653867,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.5847,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 75.08406565689027,
|
||||||
|
"macd_hist": 0.004905452274126379,
|
||||||
|
"atr": 0.010861550575088958,
|
||||||
|
"adx": 21.704459542796908
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-05-30 00:45:00+00:00",
|
||||||
|
"exit_time": "2025-05-30 02:15:00+00:00",
|
||||||
|
"entry_price": 2.155015,
|
||||||
|
"exit_price": 2.207692,
|
||||||
|
"quantity": 770.0,
|
||||||
|
"sl": 2.128677,
|
||||||
|
"tp": 2.207692,
|
||||||
|
"gross_pnl": 40.56076,
|
||||||
|
"entry_fee": 0.663745,
|
||||||
|
"exit_fee": 0.679969,
|
||||||
|
"net_pnl": 39.217046,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.7602,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 13.158390769693794,
|
||||||
|
"macd_hist": -0.00797002840932291,
|
||||||
|
"atr": 0.01755877038478085,
|
||||||
|
"adx": 31.17699185815243
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-06-14 04:15:00+00:00",
|
||||||
|
"exit_time": "2025-06-14 08:15:00+00:00",
|
||||||
|
"entry_price": 2.164584,
|
||||||
|
"exit_price": 2.169417,
|
||||||
|
"quantity": 757.7,
|
||||||
|
"sl": 2.175701,
|
||||||
|
"tp": 2.142349,
|
||||||
|
"gross_pnl": -3.662267,
|
||||||
|
"entry_fee": 0.656042,
|
||||||
|
"exit_fee": 0.657507,
|
||||||
|
"net_pnl": -4.975816,
|
||||||
|
"close_reason": "REVERSE_SIGNAL",
|
||||||
|
"ml_proba": 0.6115,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 69.92512937431012,
|
||||||
|
"macd_hist": 0.0026939087409630215,
|
||||||
|
"atr": 0.007411409293121909,
|
||||||
|
"adx": 20.278562659091943
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-08-14 12:30:00+00:00",
|
||||||
|
"exit_time": "2025-08-14 21:30:00+00:00",
|
||||||
|
"entry_price": 3.132487,
|
||||||
|
"exit_price": 3.035326,
|
||||||
|
"quantity": 524.6,
|
||||||
|
"sl": 3.181067,
|
||||||
|
"tp": 3.035326,
|
||||||
|
"gross_pnl": 50.970515,
|
||||||
|
"entry_fee": 0.657321,
|
||||||
|
"exit_fee": 0.636933,
|
||||||
|
"net_pnl": 49.676261,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.8857,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 20.976757311144258,
|
||||||
|
"macd_hist": -0.0032317207367513617,
|
||||||
|
"atr": 0.032386907216145205,
|
||||||
|
"adx": 38.70665879423988
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-09-01 00:15:00+00:00",
|
||||||
|
"exit_time": "2025-09-01 02:45:00+00:00",
|
||||||
|
"entry_price": 2.750075,
|
||||||
|
"exit_price": 2.73304,
|
||||||
|
"quantity": 586.2,
|
||||||
|
"sl": 2.73304,
|
||||||
|
"tp": 2.784144,
|
||||||
|
"gross_pnl": -9.985662,
|
||||||
|
"entry_fee": 0.644838,
|
||||||
|
"exit_fee": 0.640843,
|
||||||
|
"net_pnl": -11.271343,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.5754,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 20.77769666120342,
|
||||||
|
"macd_hist": -0.0054454742314916215,
|
||||||
|
"atr": 0.01135637668410908,
|
||||||
|
"adx": 32.97685850211662
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-10-05 19:00:00+00:00",
|
||||||
|
"exit_time": "2025-10-06 02:45:00+00:00",
|
||||||
|
"entry_price": 2.953995,
|
||||||
|
"exit_price": 2.990353,
|
||||||
|
"quantity": 548.6,
|
||||||
|
"sl": 2.935817,
|
||||||
|
"tp": 2.990353,
|
||||||
|
"gross_pnl": 19.945579,
|
||||||
|
"entry_fee": 0.648225,
|
||||||
|
"exit_fee": 0.656203,
|
||||||
|
"net_pnl": 18.641151,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.6037,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 22.68978567945751,
|
||||||
|
"macd_hist": -0.003579814992577557,
|
||||||
|
"atr": 0.012119078271027253,
|
||||||
|
"adx": 40.35268005132035
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-10-10 20:45:00+00:00",
|
||||||
|
"exit_time": "2025-10-10 21:00:00+00:00",
|
||||||
|
"entry_price": 2.49595,
|
||||||
|
"exit_price": 2.373231,
|
||||||
|
"quantity": 643.9,
|
||||||
|
"sl": 2.55731,
|
||||||
|
"tp": 2.373231,
|
||||||
|
"gross_pnl": 79.019141,
|
||||||
|
"entry_fee": 0.642857,
|
||||||
|
"exit_fee": 0.611249,
|
||||||
|
"net_pnl": 77.765034,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.8864,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 17.950089434981262,
|
||||||
|
"macd_hist": -0.010381022790605134,
|
||||||
|
"atr": 0.0409065283069771,
|
||||||
|
"adx": 56.13982003832872
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-10-14 16:15:00+00:00",
|
||||||
|
"exit_time": "2025-10-15 02:15:00+00:00",
|
||||||
|
"entry_price": 2.508449,
|
||||||
|
"exit_price": 2.523552,
|
||||||
|
"quantity": 612.4,
|
||||||
|
"sl": 2.544104,
|
||||||
|
"tp": 2.437139,
|
||||||
|
"gross_pnl": -9.2492,
|
||||||
|
"entry_fee": 0.61447,
|
||||||
|
"exit_fee": 0.618169,
|
||||||
|
"net_pnl": -10.481839,
|
||||||
|
"close_reason": "REVERSE_SIGNAL",
|
||||||
|
"ml_proba": 0.5683,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 68.85343626442496,
|
||||||
|
"macd_hist": 0.010657476860447013,
|
||||||
|
"atr": 0.023769893751947626,
|
||||||
|
"adx": 39.4255156509299
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-10-27 16:00:00+00:00",
|
||||||
|
"exit_time": "2025-10-27 21:00:00+00:00",
|
||||||
|
"entry_price": 2.674233,
|
||||||
|
"exit_price": 2.623904,
|
||||||
|
"quantity": 578.8,
|
||||||
|
"sl": 2.699397,
|
||||||
|
"tp": 2.623904,
|
||||||
|
"gross_pnl": 29.129941,
|
||||||
|
"entry_fee": 0.619138,
|
||||||
|
"exit_fee": 0.607486,
|
||||||
|
"net_pnl": 27.903316,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.5625,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 66.34709912155408,
|
||||||
|
"macd_hist": 0.005634259928464551,
|
||||||
|
"atr": 0.01677605457389115,
|
||||||
|
"adx": 23.34205636197947
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-11-28 01:45:00+00:00",
|
||||||
|
"exit_time": "2025-11-28 05:30:00+00:00",
|
||||||
|
"entry_price": 2.171517,
|
||||||
|
"exit_price": 2.200054,
|
||||||
|
"quantity": 699.4,
|
||||||
|
"sl": 2.157249,
|
||||||
|
"tp": 2.200054,
|
||||||
|
"gross_pnl": 19.958706,
|
||||||
|
"entry_fee": 0.607504,
|
||||||
|
"exit_fee": 0.615487,
|
||||||
|
"net_pnl": 18.735716,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.6381,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 22.942287299402874,
|
||||||
|
"macd_hist": -0.003478384036068617,
|
||||||
|
"atr": 0.009512299239799599,
|
||||||
|
"adx": 35.89384138114383
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-12-19 13:15:00+00:00",
|
||||||
|
"exit_time": "2025-12-19 15:15:00+00:00",
|
||||||
|
"entry_price": 1.878712,
|
||||||
|
"exit_price": 1.889911,
|
||||||
|
"quantity": 796.9,
|
||||||
|
"sl": 1.889911,
|
||||||
|
"tp": 1.856315,
|
||||||
|
"gross_pnl": -8.924064,
|
||||||
|
"entry_fee": 0.598858,
|
||||||
|
"exit_fee": 0.602428,
|
||||||
|
"net_pnl": -10.12535,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.5945,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 68.16547032772114,
|
||||||
|
"macd_hist": -4.5929936914913816e-05,
|
||||||
|
"atr": 0.007465649526915487,
|
||||||
|
"adx": 40.69667585881617
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-12-25 22:45:00+00:00",
|
||||||
|
"exit_time": "2025-12-25 23:30:00+00:00",
|
||||||
|
"entry_price": 1.844884,
|
||||||
|
"exit_price": 1.836907,
|
||||||
|
"quantity": 818.5,
|
||||||
|
"sl": 1.836907,
|
||||||
|
"tp": 1.86084,
|
||||||
|
"gross_pnl": -6.529692,
|
||||||
|
"entry_fee": 0.604015,
|
||||||
|
"exit_fee": 0.601403,
|
||||||
|
"net_pnl": -7.735111,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.6099,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 22.431779710524914,
|
||||||
|
"macd_hist": -0.0022220637884117073,
|
||||||
|
"atr": 0.005318421811566107,
|
||||||
|
"adx": 20.682478174103885
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2026-01-17 15:00:00+00:00",
|
||||||
|
"exit_time": "2026-01-17 15:15:00+00:00",
|
||||||
|
"entry_price": 2.074093,
|
||||||
|
"exit_price": 2.080899,
|
||||||
|
"quantity": 732.6,
|
||||||
|
"sl": 2.080899,
|
||||||
|
"tp": 2.06048,
|
||||||
|
"gross_pnl": -4.986389,
|
||||||
|
"entry_fee": 0.607792,
|
||||||
|
"exit_fee": 0.609787,
|
||||||
|
"net_pnl": -6.203967,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.594,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 69.16433708427633,
|
||||||
|
"macd_hist": 0.0015812464458042678,
|
||||||
|
"atr": 0.004537618137465387,
|
||||||
|
"adx": 16.189151941493567
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2026-01-30 01:30:00+00:00",
|
||||||
|
"exit_time": "2026-01-30 02:15:00+00:00",
|
||||||
|
"entry_price": 1.743626,
|
||||||
|
"exit_price": 1.733473,
|
||||||
|
"quantity": 875.8,
|
||||||
|
"sl": 1.766432,
|
||||||
|
"tp": 1.698012,
|
||||||
|
"gross_pnl": 8.891376,
|
||||||
|
"entry_fee": 0.610827,
|
||||||
|
"exit_fee": 0.60727,
|
||||||
|
"net_pnl": 7.673278,
|
||||||
|
"close_reason": "REVERSE_SIGNAL",
|
||||||
|
"ml_proba": 0.6829,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 19.89605260729724,
|
||||||
|
"macd_hist": -0.003114826868995284,
|
||||||
|
"atr": 0.015204543588406344,
|
||||||
|
"adx": 20.39618087837
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2026-02-28 06:15:00+00:00",
|
||||||
|
"exit_time": "2026-02-28 06:30:00+00:00",
|
||||||
|
"entry_price": 1.331433,
|
||||||
|
"exit_price": 1.322797,
|
||||||
|
"quantity": 1141.2,
|
||||||
|
"sl": 1.322797,
|
||||||
|
"tp": 1.348705,
|
||||||
|
"gross_pnl": -9.855576,
|
||||||
|
"entry_fee": 0.607773,
|
||||||
|
"exit_fee": 0.60383,
|
||||||
|
"net_pnl": -11.067179,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.7416,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 22.07257216583472,
|
||||||
|
"macd_hist": -0.0019878129960472814,
|
||||||
|
"atr": 0.005757434514952236,
|
||||||
|
"adx": 36.23941502849302
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"validation": {
|
||||||
|
"overall": "FAIL",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "exit_after_entry",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "모든 트레이드에서 청산 > 진입"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sl_tp_direction",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "SL/TP 방향 정합"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no_overlap",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "포지션 비중첩 확인"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "positive_fees",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "수수료 양수 확인"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no_negative_balance",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "잔고 양수 유지"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "win_rate_high",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "승률 정상 (53.3%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "win_rate_low",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "승률 정상 (53.3%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mdd_nonzero",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "MDD 정상 (2.2%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trade_frequency",
|
||||||
|
"passed": false,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "월 평균 1.6건 < 5건 — 신호 생성 부족"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "profit_factor_high",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "PF 정상 (4.21)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
3744
results/combined/strategy_sweep_20260306_232337.json
Normal file
3744
results/combined/strategy_sweep_20260306_232337.json
Normal file
File diff suppressed because it is too large
Load Diff
3405
results/combined/wf_backtest_20260306_224143.json
Normal file
3405
results/combined/wf_backtest_20260306_224143.json
Normal file
File diff suppressed because it is too large
Load Diff
86
results/compare/compare_2026-03-18.json
Normal file
86
results/compare/compare_2026-03-18.json
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"symbol": "SOLUSDT",
|
||||||
|
"best_params": {
|
||||||
|
"atr_sl_mult": 1.0,
|
||||||
|
"atr_tp_mult": 4.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 20,
|
||||||
|
"volume_multiplier": 2.5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 31,
|
||||||
|
"total_pnl": 909.294,
|
||||||
|
"return_pct": 90.93,
|
||||||
|
"win_rate": 38.71,
|
||||||
|
"avg_win": 117.2904,
|
||||||
|
"avg_loss": -26.2206,
|
||||||
|
"payoff_ratio": 4.47,
|
||||||
|
"max_consecutive_losses": 6,
|
||||||
|
"profit_factor": 2.83,
|
||||||
|
"max_drawdown_pct": 10.87,
|
||||||
|
"sharpe_ratio": 57.43,
|
||||||
|
"total_fees": 117.2484,
|
||||||
|
"close_reasons": {
|
||||||
|
"TAKE_PROFIT": 12,
|
||||||
|
"STOP_LOSS": 19
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "LINKUSDT",
|
||||||
|
"best_params": {
|
||||||
|
"atr_sl_mult": 2.0,
|
||||||
|
"atr_tp_mult": 3.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 0,
|
||||||
|
"volume_multiplier": 2.5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 38,
|
||||||
|
"total_pnl": 12.3248,
|
||||||
|
"return_pct": 1.23,
|
||||||
|
"win_rate": 39.47,
|
||||||
|
"avg_win": 88.1543,
|
||||||
|
"avg_loss": -56.9561,
|
||||||
|
"payoff_ratio": 1.55,
|
||||||
|
"max_consecutive_losses": 5,
|
||||||
|
"profit_factor": 1.01,
|
||||||
|
"max_drawdown_pct": 24.28,
|
||||||
|
"sharpe_ratio": 0.67,
|
||||||
|
"total_fees": 142.4705,
|
||||||
|
"close_reasons": {
|
||||||
|
"TAKE_PROFIT": 15,
|
||||||
|
"STOP_LOSS": 23
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "AVAXUSDT",
|
||||||
|
"best_params": {
|
||||||
|
"atr_sl_mult": 1.5,
|
||||||
|
"atr_tp_mult": 3.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 25,
|
||||||
|
"volume_multiplier": 2.5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 20,
|
||||||
|
"total_pnl": 497.5511,
|
||||||
|
"return_pct": 49.76,
|
||||||
|
"win_rate": 55.0,
|
||||||
|
"avg_win": 90.6485,
|
||||||
|
"avg_loss": -55.5092,
|
||||||
|
"payoff_ratio": 1.63,
|
||||||
|
"max_consecutive_losses": 3,
|
||||||
|
"profit_factor": 2.0,
|
||||||
|
"max_drawdown_pct": 8.89,
|
||||||
|
"sharpe_ratio": 47.39,
|
||||||
|
"total_fees": 76.184,
|
||||||
|
"close_reasons": {
|
||||||
|
"STOP_LOSS": 9,
|
||||||
|
"TAKE_PROFIT": 11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
7511
results/dogeusdt/strategy_sweep_20260317_172011.json
Normal file
7511
results/dogeusdt/strategy_sweep_20260317_172011.json
Normal file
File diff suppressed because it is too large
Load Diff
1241
results/dogeusdt/wf_backtest_20260306_224128.json
Normal file
1241
results/dogeusdt/wf_backtest_20260306_224128.json
Normal file
File diff suppressed because it is too large
Load Diff
1236
results/dogeusdt/wf_backtest_20260306_231221.json
Normal file
1236
results/dogeusdt/wf_backtest_20260306_231221.json
Normal file
File diff suppressed because it is too large
Load Diff
7511
results/trxusdt/strategy_sweep_20260317_171133.json
Normal file
7511
results/trxusdt/strategy_sweep_20260317_171133.json
Normal file
File diff suppressed because it is too large
Load Diff
1167
results/trxusdt/wf_backtest_20260306_224117.json
Normal file
1167
results/trxusdt/wf_backtest_20260306_224117.json
Normal file
File diff suppressed because it is too large
Load Diff
900
results/trxusdt/wf_backtest_20260306_231211.json
Normal file
900
results/trxusdt/wf_backtest_20260306_231211.json
Normal file
@@ -0,0 +1,900 @@
|
|||||||
|
{
|
||||||
|
"mode": "walk_forward",
|
||||||
|
"config": {
|
||||||
|
"symbols": [
|
||||||
|
"TRXUSDT"
|
||||||
|
],
|
||||||
|
"start": null,
|
||||||
|
"end": null,
|
||||||
|
"initial_balance": 1000.0,
|
||||||
|
"leverage": 10,
|
||||||
|
"fee_pct": 0.04,
|
||||||
|
"slippage_pct": 0.01,
|
||||||
|
"use_ml": false,
|
||||||
|
"ml_threshold": 0.55,
|
||||||
|
"max_daily_loss_pct": 0.05,
|
||||||
|
"max_positions": 3,
|
||||||
|
"max_same_direction": 2,
|
||||||
|
"margin_max_ratio": 0.5,
|
||||||
|
"margin_min_ratio": 0.2,
|
||||||
|
"margin_decay_rate": 0.0006,
|
||||||
|
"atr_sl_mult": 1.0,
|
||||||
|
"atr_tp_mult": 2.0,
|
||||||
|
"min_notional": 5.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 20.0,
|
||||||
|
"volume_multiplier": 2.5,
|
||||||
|
"train_months": 3,
|
||||||
|
"test_months": 1,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"negative_ratio": 5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 30,
|
||||||
|
"total_pnl": 82.5579,
|
||||||
|
"return_pct": 8.26,
|
||||||
|
"win_rate": 56.67,
|
||||||
|
"avg_win": 18.6156,
|
||||||
|
"avg_loss": -17.9929,
|
||||||
|
"profit_factor": 1.35,
|
||||||
|
"max_drawdown_pct": 9.39,
|
||||||
|
"sharpe_ratio": 17.82,
|
||||||
|
"total_fees": 115.4404,
|
||||||
|
"close_reasons": {
|
||||||
|
"STOP_LOSS": 13,
|
||||||
|
"TAKE_PROFIT": 17
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"folds": [
|
||||||
|
{
|
||||||
|
"fold": 1,
|
||||||
|
"train_period": "2025-03-05 ~ 2025-06-05",
|
||||||
|
"test_period": "2025-06-05 ~ 2025-07-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 7,
|
||||||
|
"total_pnl": -93.5562,
|
||||||
|
"return_pct": -9.36,
|
||||||
|
"win_rate": 28.57,
|
||||||
|
"avg_win": 21.9794,
|
||||||
|
"avg_loss": -27.503,
|
||||||
|
"profit_factor": 0.32,
|
||||||
|
"max_drawdown_pct": 9.39,
|
||||||
|
"sharpe_ratio": -83.68,
|
||||||
|
"total_fees": 25.9916,
|
||||||
|
"close_reasons": {
|
||||||
|
"STOP_LOSS": 5,
|
||||||
|
"TAKE_PROFIT": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 2,
|
||||||
|
"train_period": "2025-06-05 ~ 2025-09-05",
|
||||||
|
"test_period": "2025-09-05 ~ 2025-10-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 15,
|
||||||
|
"total_pnl": 155.2593,
|
||||||
|
"return_pct": 15.53,
|
||||||
|
"win_rate": 66.67,
|
||||||
|
"avg_win": 22.197,
|
||||||
|
"avg_loss": -13.3422,
|
||||||
|
"profit_factor": 3.33,
|
||||||
|
"max_drawdown_pct": 2.95,
|
||||||
|
"sharpe_ratio": 63.82,
|
||||||
|
"total_fees": 57.5397,
|
||||||
|
"close_reasons": {
|
||||||
|
"TAKE_PROFIT": 10,
|
||||||
|
"STOP_LOSS": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 3,
|
||||||
|
"train_period": "2025-09-05 ~ 2025-12-05",
|
||||||
|
"test_period": "2025-12-05 ~ 2026-01-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 8,
|
||||||
|
"total_pnl": 20.8549,
|
||||||
|
"return_pct": 2.09,
|
||||||
|
"win_rate": 62.5,
|
||||||
|
"avg_win": 10.1073,
|
||||||
|
"avg_loss": -9.8938,
|
||||||
|
"profit_factor": 1.7,
|
||||||
|
"max_drawdown_pct": 0.99,
|
||||||
|
"sharpe_ratio": 41.49,
|
||||||
|
"total_fees": 31.9091,
|
||||||
|
"close_reasons": {
|
||||||
|
"TAKE_PROFIT": 5,
|
||||||
|
"STOP_LOSS": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"trades": [
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-06-11 17:00:00",
|
||||||
|
"exit_time": "2025-06-11 17:45:00",
|
||||||
|
"entry_price": 0.282968,
|
||||||
|
"exit_price": 0.281514,
|
||||||
|
"quantity": 17671.6,
|
||||||
|
"sl": 0.281514,
|
||||||
|
"tp": 0.285877,
|
||||||
|
"gross_pnl": -25.701813,
|
||||||
|
"entry_fee": 2.000201,
|
||||||
|
"exit_fee": 1.98992,
|
||||||
|
"net_pnl": -29.691935,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 21.310011221387928,
|
||||||
|
"macd_hist": -0.0009208923407909242,
|
||||||
|
"atr": 0.001454413485759152,
|
||||||
|
"adx": 32.474695496414746
|
||||||
|
},
|
||||||
|
"fold": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-06-13 00:00:00",
|
||||||
|
"exit_time": "2025-06-13 00:15:00",
|
||||||
|
"entry_price": 0.268723,
|
||||||
|
"exit_price": 0.269681,
|
||||||
|
"quantity": 18015.0,
|
||||||
|
"sl": 0.269681,
|
||||||
|
"tp": 0.266808,
|
||||||
|
"gross_pnl": -17.253496,
|
||||||
|
"entry_fee": 1.936419,
|
||||||
|
"exit_fee": 1.94332,
|
||||||
|
"net_pnl": -21.133235,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 26.966927261980565,
|
||||||
|
"macd_hist": -1.9569743207310678e-05,
|
||||||
|
"atr": 0.0009577294352106148,
|
||||||
|
"adx": 26.838036717103265
|
||||||
|
},
|
||||||
|
"fold": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-06-16 12:30:00",
|
||||||
|
"exit_time": "2025-06-16 12:45:00",
|
||||||
|
"entry_price": 0.281208,
|
||||||
|
"exit_price": 0.278854,
|
||||||
|
"quantity": 16808.4,
|
||||||
|
"sl": 0.278854,
|
||||||
|
"tp": 0.285916,
|
||||||
|
"gross_pnl": -39.565619,
|
||||||
|
"entry_fee": 1.890663,
|
||||||
|
"exit_fee": 1.874837,
|
||||||
|
"net_pnl": -43.33112,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 84.3200899004785,
|
||||||
|
"macd_hist": 0.0005755897383287943,
|
||||||
|
"atr": 0.0023539194130354013,
|
||||||
|
"adx": 24.98726333785949
|
||||||
|
},
|
||||||
|
"fold": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-06-16 23:45:00",
|
||||||
|
"exit_time": "2025-06-17 00:00:00",
|
||||||
|
"entry_price": 0.273887,
|
||||||
|
"exit_price": 0.272484,
|
||||||
|
"quantity": 16432.1,
|
||||||
|
"sl": 0.272484,
|
||||||
|
"tp": 0.276694,
|
||||||
|
"gross_pnl": -23.061898,
|
||||||
|
"entry_fee": 1.800218,
|
||||||
|
"exit_fee": 1.790993,
|
||||||
|
"net_pnl": -26.653109,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 28.74454185909087,
|
||||||
|
"macd_hist": -0.0006231433167976357,
|
||||||
|
"atr": 0.0014034662689071044,
|
||||||
|
"adx": 28.215673425255815
|
||||||
|
},
|
||||||
|
"fold": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-06-22 14:15:00",
|
||||||
|
"exit_time": "2025-06-22 14:45:00",
|
||||||
|
"entry_price": 0.264836,
|
||||||
|
"exit_price": 0.267254,
|
||||||
|
"quantity": 16456.4,
|
||||||
|
"sl": 0.263628,
|
||||||
|
"tp": 0.267254,
|
||||||
|
"gross_pnl": 39.776618,
|
||||||
|
"entry_fee": 1.743302,
|
||||||
|
"exit_fee": 1.759213,
|
||||||
|
"net_pnl": 36.274103,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 23.816317652581944,
|
||||||
|
"macd_hist": -0.0007482968367435475,
|
||||||
|
"atr": 0.0012085455367911535,
|
||||||
|
"adx": 28.05756581397983
|
||||||
|
},
|
||||||
|
"fold": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-06-28 07:00:00",
|
||||||
|
"exit_time": "2025-06-28 07:45:00",
|
||||||
|
"entry_price": 0.274403,
|
||||||
|
"exit_price": 0.273718,
|
||||||
|
"quantity": 16508.7,
|
||||||
|
"sl": 0.274745,
|
||||||
|
"tp": 0.273718,
|
||||||
|
"gross_pnl": 11.304155,
|
||||||
|
"entry_fee": 1.812012,
|
||||||
|
"exit_fee": 1.80749,
|
||||||
|
"net_pnl": 7.684653,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 69.9178346428632,
|
||||||
|
"macd_hist": 0.00010551255130301432,
|
||||||
|
"atr": 0.0003423696394189119,
|
||||||
|
"adx": 24.542335118437286
|
||||||
|
},
|
||||||
|
"fold": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-07-04 14:15:00",
|
||||||
|
"exit_time": "2025-07-04 15:15:00",
|
||||||
|
"entry_price": 0.283728,
|
||||||
|
"exit_price": 0.282916,
|
||||||
|
"quantity": 16072.7,
|
||||||
|
"sl": 0.282916,
|
||||||
|
"tp": 0.285354,
|
||||||
|
"gross_pnl": -13.062601,
|
||||||
|
"entry_fee": 1.824112,
|
||||||
|
"exit_fee": 1.818887,
|
||||||
|
"net_pnl": -16.705601,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 27.74105415081286,
|
||||||
|
"macd_hist": -0.0003117196058060476,
|
||||||
|
"atr": 0.0008127197629142321,
|
||||||
|
"adx": 32.363795146724236
|
||||||
|
},
|
||||||
|
"fold": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-09-06 20:45:00",
|
||||||
|
"exit_time": "2025-09-06 21:00:00",
|
||||||
|
"entry_price": 0.321678,
|
||||||
|
"exit_price": 0.319715,
|
||||||
|
"quantity": 15541.9,
|
||||||
|
"sl": 0.322659,
|
||||||
|
"tp": 0.319715,
|
||||||
|
"gross_pnl": 30.509805,
|
||||||
|
"entry_fee": 1.999794,
|
||||||
|
"exit_fee": 1.98759,
|
||||||
|
"net_pnl": 26.522421,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 14.50979679434796,
|
||||||
|
"macd_hist": -0.0001481211181555309,
|
||||||
|
"atr": 0.0009815339390825979,
|
||||||
|
"adx": 64.7935479437538
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-09-06 22:00:00",
|
||||||
|
"exit_time": "2025-09-06 22:15:00",
|
||||||
|
"entry_price": 0.305541,
|
||||||
|
"exit_price": 0.31122,
|
||||||
|
"quantity": 16274.0,
|
||||||
|
"sl": 0.302701,
|
||||||
|
"tp": 0.31122,
|
||||||
|
"gross_pnl": 92.431123,
|
||||||
|
"entry_fee": 1.988947,
|
||||||
|
"exit_fee": 2.025919,
|
||||||
|
"net_pnl": 88.416257,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 7.92971732591285,
|
||||||
|
"macd_hist": -0.0020195476384628087,
|
||||||
|
"atr": 0.0028398403210786296,
|
||||||
|
"adx": 72.55182590993492
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-09-08 09:15:00",
|
||||||
|
"exit_time": "2025-09-08 10:00:00",
|
||||||
|
"entry_price": 0.332173,
|
||||||
|
"exit_price": 0.333369,
|
||||||
|
"quantity": 14497.5,
|
||||||
|
"sl": 0.331575,
|
||||||
|
"tp": 0.333369,
|
||||||
|
"gross_pnl": 17.330829,
|
||||||
|
"entry_fee": 1.926272,
|
||||||
|
"exit_fee": 1.933205,
|
||||||
|
"net_pnl": 13.471352,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 65.07783460048826,
|
||||||
|
"macd_hist": 1.5209076637119471e-05,
|
||||||
|
"atr": 0.0005977178625434464,
|
||||||
|
"adx": 29.94633147610989
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-09-10 12:30:00",
|
||||||
|
"exit_time": "2025-09-10 12:45:00",
|
||||||
|
"entry_price": 0.338144,
|
||||||
|
"exit_price": 0.337604,
|
||||||
|
"quantity": 14159.5,
|
||||||
|
"sl": 0.337604,
|
||||||
|
"tp": 0.339223,
|
||||||
|
"gross_pnl": -7.639274,
|
||||||
|
"entry_fee": 1.915179,
|
||||||
|
"exit_fee": 1.912123,
|
||||||
|
"net_pnl": -11.466576,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 69.27306940076188,
|
||||||
|
"macd_hist": 1.761110324105758e-05,
|
||||||
|
"atr": 0.0005395157903468375,
|
||||||
|
"adx": 28.042143316321827
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-09-13 05:30:00",
|
||||||
|
"exit_time": "2025-09-13 06:45:00",
|
||||||
|
"entry_price": 0.354365,
|
||||||
|
"exit_price": 0.353225,
|
||||||
|
"quantity": 13598.9,
|
||||||
|
"sl": 0.354934,
|
||||||
|
"tp": 0.353225,
|
||||||
|
"gross_pnl": 15.499603,
|
||||||
|
"entry_fee": 1.927587,
|
||||||
|
"exit_fee": 1.921387,
|
||||||
|
"net_pnl": 11.650628,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 75.09668428355302,
|
||||||
|
"macd_hist": 0.00010590971216388305,
|
||||||
|
"atr": 0.0005698844413077655,
|
||||||
|
"adx": 40.031791993546236
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-09-18 16:15:00",
|
||||||
|
"exit_time": "2025-09-18 17:00:00",
|
||||||
|
"entry_price": 0.349035,
|
||||||
|
"exit_price": 0.350709,
|
||||||
|
"quantity": 13743.4,
|
||||||
|
"sl": 0.348198,
|
||||||
|
"tp": 0.350709,
|
||||||
|
"gross_pnl": 23.014546,
|
||||||
|
"entry_fee": 1.91877,
|
||||||
|
"exit_fee": 1.927976,
|
||||||
|
"net_pnl": 19.167799,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 71.40338684709819,
|
||||||
|
"macd_hist": 2.7945430776989415e-05,
|
||||||
|
"atr": 0.0008372944810174205,
|
||||||
|
"adx": 41.403707409730266
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-09-19 15:00:00",
|
||||||
|
"exit_time": "2025-09-20 02:00:00",
|
||||||
|
"entry_price": 0.344696,
|
||||||
|
"exit_price": 0.342745,
|
||||||
|
"quantity": 13787.0,
|
||||||
|
"sl": 0.345671,
|
||||||
|
"tp": 0.342745,
|
||||||
|
"gross_pnl": 26.891239,
|
||||||
|
"entry_fee": 1.900927,
|
||||||
|
"exit_fee": 1.89017,
|
||||||
|
"net_pnl": 23.100142,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 36.3929743759645,
|
||||||
|
"macd_hist": -4.9173165000631076e-05,
|
||||||
|
"atr": 0.0009752389588793218,
|
||||||
|
"adx": 20.73583082347764
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-09-21 08:15:00",
|
||||||
|
"exit_time": "2025-09-21 09:15:00",
|
||||||
|
"entry_price": 0.344206,
|
||||||
|
"exit_price": 0.343239,
|
||||||
|
"quantity": 13636.7,
|
||||||
|
"sl": 0.344689,
|
||||||
|
"tp": 0.343239,
|
||||||
|
"gross_pnl": 13.180653,
|
||||||
|
"entry_fee": 1.877531,
|
||||||
|
"exit_fee": 1.872259,
|
||||||
|
"net_pnl": 9.430863,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 30.782692658162308,
|
||||||
|
"macd_hist": -7.421870424645718e-05,
|
||||||
|
"atr": 0.0004832786824330342,
|
||||||
|
"adx": 31.221203425283278
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-09-21 23:00:00",
|
||||||
|
"exit_time": "2025-09-22 00:15:00",
|
||||||
|
"entry_price": 0.342464,
|
||||||
|
"exit_price": 0.342008,
|
||||||
|
"quantity": 13644.1,
|
||||||
|
"sl": 0.342008,
|
||||||
|
"tp": 0.343378,
|
||||||
|
"gross_pnl": -6.231495,
|
||||||
|
"entry_fee": 1.869047,
|
||||||
|
"exit_fee": 1.866554,
|
||||||
|
"net_pnl": -9.967096,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 32.492156959618264,
|
||||||
|
"macd_hist": -0.00011844966177527712,
|
||||||
|
"atr": 0.0004567172002937059,
|
||||||
|
"adx": 30.86524355608222
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-09-22 06:00:00",
|
||||||
|
"exit_time": "2025-09-22 06:30:00",
|
||||||
|
"entry_price": 0.334297,
|
||||||
|
"exit_price": 0.335684,
|
||||||
|
"quantity": 14077.5,
|
||||||
|
"sl": 0.335684,
|
||||||
|
"tp": 0.331521,
|
||||||
|
"gross_pnl": -19.537013,
|
||||||
|
"entry_fee": 1.882424,
|
||||||
|
"exit_fee": 1.890239,
|
||||||
|
"net_pnl": -23.309675,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 19.554287882129668,
|
||||||
|
"macd_hist": -0.00022792604366828832,
|
||||||
|
"atr": 0.0013878183290049766,
|
||||||
|
"adx": 30.760886738521233
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-09-23 09:00:00",
|
||||||
|
"exit_time": "2025-09-23 09:15:00",
|
||||||
|
"entry_price": 0.341044,
|
||||||
|
"exit_price": 0.342135,
|
||||||
|
"quantity": 14000.1,
|
||||||
|
"sl": 0.340499,
|
||||||
|
"tp": 0.342135,
|
||||||
|
"gross_pnl": 15.274224,
|
||||||
|
"entry_fee": 1.909861,
|
||||||
|
"exit_fee": 1.91597,
|
||||||
|
"net_pnl": 11.448393,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 60.568797010213174,
|
||||||
|
"macd_hist": 4.5460317085828014e-05,
|
||||||
|
"atr": 0.0005455040859896886,
|
||||||
|
"adx": 20.641276471653338
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-09-27 08:00:00",
|
||||||
|
"exit_time": "2025-09-27 08:30:00",
|
||||||
|
"entry_price": 0.336534,
|
||||||
|
"exit_price": 0.33605,
|
||||||
|
"quantity": 14114.3,
|
||||||
|
"sl": 0.33605,
|
||||||
|
"tp": 0.337501,
|
||||||
|
"gross_pnl": -6.829669,
|
||||||
|
"entry_fee": 1.899975,
|
||||||
|
"exit_fee": 1.897243,
|
||||||
|
"net_pnl": -10.626887,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 28.90053605935814,
|
||||||
|
"macd_hist": -0.00025988593478820746,
|
||||||
|
"atr": 0.00048388294790651314,
|
||||||
|
"adx": 26.380156011508365
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-09-29 10:00:00",
|
||||||
|
"exit_time": "2025-09-29 10:45:00",
|
||||||
|
"entry_price": 0.332323,
|
||||||
|
"exit_price": 0.333211,
|
||||||
|
"quantity": 14390.1,
|
||||||
|
"sl": 0.331879,
|
||||||
|
"tp": 0.333211,
|
||||||
|
"gross_pnl": 12.782308,
|
||||||
|
"entry_fee": 1.912866,
|
||||||
|
"exit_fee": 1.917979,
|
||||||
|
"net_pnl": 8.951463,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 19.362378267287347,
|
||||||
|
"macd_hist": -0.0002291318308186226,
|
||||||
|
"atr": 0.00044413546950098455,
|
||||||
|
"adx": 29.645494050123773
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-09-30 03:30:00",
|
||||||
|
"exit_time": "2025-09-30 04:00:00",
|
||||||
|
"entry_price": 0.337134,
|
||||||
|
"exit_price": 0.336601,
|
||||||
|
"quantity": 14131.8,
|
||||||
|
"sl": 0.336601,
|
||||||
|
"tp": 0.3382,
|
||||||
|
"gross_pnl": -7.532294,
|
||||||
|
"entry_fee": 1.905722,
|
||||||
|
"exit_fee": 1.90271,
|
||||||
|
"net_pnl": -11.340726,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 58.81934618554273,
|
||||||
|
"macd_hist": 9.633358724452359e-06,
|
||||||
|
"atr": 0.0005330031631932522,
|
||||||
|
"adx": 34.07177915564808
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-10-01 08:15:00",
|
||||||
|
"exit_time": "2025-10-01 08:45:00",
|
||||||
|
"entry_price": 0.335354,
|
||||||
|
"exit_price": 0.336308,
|
||||||
|
"quantity": 14305.7,
|
||||||
|
"sl": 0.334876,
|
||||||
|
"tp": 0.336308,
|
||||||
|
"gross_pnl": 13.654332,
|
||||||
|
"entry_fee": 1.918987,
|
||||||
|
"exit_fee": 1.924449,
|
||||||
|
"net_pnl": 9.810896,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 70.24789531394917,
|
||||||
|
"macd_hist": 4.729285330987371e-05,
|
||||||
|
"atr": 0.0004772339591884286,
|
||||||
|
"adx": 31.542285567700812
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-12-08 23:15:00",
|
||||||
|
"exit_time": "2025-12-09 00:00:00",
|
||||||
|
"entry_price": 0.281402,
|
||||||
|
"exit_price": 0.28061,
|
||||||
|
"quantity": 17766.4,
|
||||||
|
"sl": 0.281798,
|
||||||
|
"tp": 0.28061,
|
||||||
|
"gross_pnl": 14.063894,
|
||||||
|
"entry_fee": 1.999799,
|
||||||
|
"exit_fee": 1.994174,
|
||||||
|
"net_pnl": 10.069921,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 22.36971195563758,
|
||||||
|
"macd_hist": -5.3411406416355763e-05,
|
||||||
|
"atr": 0.0003958003322855375,
|
||||||
|
"adx": 36.500835949606106
|
||||||
|
},
|
||||||
|
"fold": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-12-12 07:45:00",
|
||||||
|
"exit_time": "2025-12-12 08:15:00",
|
||||||
|
"entry_price": 0.278582,
|
||||||
|
"exit_price": 0.277953,
|
||||||
|
"quantity": 17915.9,
|
||||||
|
"sl": 0.278897,
|
||||||
|
"tp": 0.277953,
|
||||||
|
"gross_pnl": 11.265055,
|
||||||
|
"entry_fee": 1.99642,
|
||||||
|
"exit_fee": 1.991914,
|
||||||
|
"net_pnl": 7.276721,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 29.438038967517357,
|
||||||
|
"macd_hist": -2.5520992386730775e-05,
|
||||||
|
"atr": 0.0003143870866373587,
|
||||||
|
"adx": 26.711440675930646
|
||||||
|
},
|
||||||
|
"fold": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-12-12 15:30:00",
|
||||||
|
"exit_time": "2025-12-12 15:45:00",
|
||||||
|
"entry_price": 0.276062,
|
||||||
|
"exit_price": 0.275244,
|
||||||
|
"quantity": 18057.8,
|
||||||
|
"sl": 0.276471,
|
||||||
|
"tp": 0.275244,
|
||||||
|
"gross_pnl": 14.773959,
|
||||||
|
"entry_fee": 1.994032,
|
||||||
|
"exit_fee": 1.988122,
|
||||||
|
"net_pnl": 10.791805,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 28.751521638219945,
|
||||||
|
"macd_hist": -8.36146151699088e-06,
|
||||||
|
"atr": 0.00040907415881422674,
|
||||||
|
"adx": 39.02029513782675
|
||||||
|
},
|
||||||
|
"fold": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-12-13 08:00:00",
|
||||||
|
"exit_time": "2025-12-13 10:15:00",
|
||||||
|
"entry_price": 0.272847,
|
||||||
|
"exit_price": 0.272547,
|
||||||
|
"quantity": 18235.1,
|
||||||
|
"sl": 0.272547,
|
||||||
|
"tp": 0.273447,
|
||||||
|
"gross_pnl": -5.469501,
|
||||||
|
"entry_fee": 1.990159,
|
||||||
|
"exit_fee": 1.987971,
|
||||||
|
"net_pnl": -9.447632,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 29.793316197464588,
|
||||||
|
"macd_hist": -7.840319164560051e-05,
|
||||||
|
"atr": 0.00029994358844533694,
|
||||||
|
"adx": 21.992497694217974
|
||||||
|
},
|
||||||
|
"fold": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-12-15 00:00:00",
|
||||||
|
"exit_time": "2025-12-15 01:00:00",
|
||||||
|
"entry_price": 0.277738,
|
||||||
|
"exit_price": 0.27865,
|
||||||
|
"quantity": 17963.3,
|
||||||
|
"sl": 0.277282,
|
||||||
|
"tp": 0.27865,
|
||||||
|
"gross_pnl": 16.381607,
|
||||||
|
"entry_fee": 1.995635,
|
||||||
|
"exit_fee": 2.002187,
|
||||||
|
"net_pnl": 12.383784,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 67.78500804560913,
|
||||||
|
"macd_hist": 4.083572769007836e-06,
|
||||||
|
"atr": 0.00045597430865612724,
|
||||||
|
"adx": 21.311964821547978
|
||||||
|
},
|
||||||
|
"fold": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-12-26 15:00:00",
|
||||||
|
"exit_time": "2025-12-26 15:30:00",
|
||||||
|
"entry_price": 0.277452,
|
||||||
|
"exit_price": 0.277801,
|
||||||
|
"quantity": 17933.7,
|
||||||
|
"sl": 0.277801,
|
||||||
|
"tp": 0.276756,
|
||||||
|
"gross_pnl": -6.246853,
|
||||||
|
"entry_fee": 1.990298,
|
||||||
|
"exit_fee": 1.992797,
|
||||||
|
"net_pnl": -10.229948,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 29.51622507815984,
|
||||||
|
"macd_hist": -4.6760678561223154e-05,
|
||||||
|
"atr": 0.0003483303942996465,
|
||||||
|
"adx": 36.669526375660595
|
||||||
|
},
|
||||||
|
"fold": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-12-27 20:00:00",
|
||||||
|
"exit_time": "2025-12-27 22:00:00",
|
||||||
|
"entry_price": 0.283318,
|
||||||
|
"exit_price": 0.284114,
|
||||||
|
"quantity": 17616.7,
|
||||||
|
"sl": 0.282921,
|
||||||
|
"tp": 0.284114,
|
||||||
|
"gross_pnl": 14.012606,
|
||||||
|
"entry_fee": 1.996454,
|
||||||
|
"exit_fee": 2.002059,
|
||||||
|
"net_pnl": 10.014094,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 77.49503286668957,
|
||||||
|
"macd_hist": 3.5147071292014305e-05,
|
||||||
|
"atr": 0.00039770804019150625,
|
||||||
|
"adx": 43.053847108404184
|
||||||
|
},
|
||||||
|
"fold": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "TRXUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2026-01-04 00:00:00",
|
||||||
|
"exit_time": "2026-01-04 00:30:00",
|
||||||
|
"entry_price": 0.294841,
|
||||||
|
"exit_price": 0.295197,
|
||||||
|
"quantity": 16893.5,
|
||||||
|
"sl": 0.295197,
|
||||||
|
"tp": 0.294128,
|
||||||
|
"gross_pnl": -6.016701,
|
||||||
|
"entry_fee": 1.992355,
|
||||||
|
"exit_fee": 1.994762,
|
||||||
|
"net_pnl": -10.003818,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 66.6678799584072,
|
||||||
|
"macd_hist": -3.2551549800375387e-05,
|
||||||
|
"atr": 0.00035615479915180313,
|
||||||
|
"adx": 74.06247870613235
|
||||||
|
},
|
||||||
|
"fold": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"validation": {
|
||||||
|
"overall": "FAIL",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "exit_after_entry",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "모든 트레이드에서 청산 > 진입"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sl_tp_direction",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "SL/TP 방향 정합"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no_overlap",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "포지션 비중첩 확인"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "positive_fees",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "수수료 양수 확인"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no_negative_balance",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "잔고 양수 유지"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "win_rate_high",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "승률 정상 (56.7%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "win_rate_low",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "승률 정상 (56.7%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mdd_nonzero",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "MDD 정상 (9.4%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trade_frequency",
|
||||||
|
"passed": false,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "월 평균 4.4건 < 5건 — 신호 생성 부족"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "profit_factor_high",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "PF 정상 (1.35)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
0
results/weekly/.gitkeep
Normal file
0
results/weekly/.gitkeep
Normal file
13402
results/weekly/report_2026-03-07.html
Normal file
13402
results/weekly/report_2026-03-07.html
Normal file
File diff suppressed because it is too large
Load Diff
2206
results/weekly/report_2026-03-07.json
Normal file
2206
results/weekly/report_2026-03-07.json
Normal file
File diff suppressed because it is too large
Load Diff
13402
results/weekly/report_2026-03-15.html
Normal file
13402
results/weekly/report_2026-03-15.html
Normal file
File diff suppressed because it is too large
Load Diff
2209
results/weekly/report_2026-03-15.json
Normal file
2209
results/weekly/report_2026-03-15.json
Normal file
File diff suppressed because it is too large
Load Diff
10767
results/xrpusdt/backtest_20260306_222136.json
Normal file
10767
results/xrpusdt/backtest_20260306_222136.json
Normal file
File diff suppressed because it is too large
Load Diff
10803
results/xrpusdt/backtest_20260306_222219.json
Normal file
10803
results/xrpusdt/backtest_20260306_222219.json
Normal file
File diff suppressed because it is too large
Load Diff
453
results/xrpusdt/backtest_20260306_222244.json
Normal file
453
results/xrpusdt/backtest_20260306_222244.json
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"symbols": [
|
||||||
|
"XRPUSDT"
|
||||||
|
],
|
||||||
|
"start": null,
|
||||||
|
"end": null,
|
||||||
|
"initial_balance": 1000.0,
|
||||||
|
"leverage": 10,
|
||||||
|
"fee_pct": 0.04,
|
||||||
|
"slippage_pct": 0.01,
|
||||||
|
"use_ml": true,
|
||||||
|
"ml_threshold": 0.55,
|
||||||
|
"max_daily_loss_pct": 0.05,
|
||||||
|
"max_positions": 3,
|
||||||
|
"max_same_direction": 2,
|
||||||
|
"margin_max_ratio": 0.5,
|
||||||
|
"margin_min_ratio": 0.2,
|
||||||
|
"margin_decay_rate": 0.0006,
|
||||||
|
"atr_sl_mult": 1.5,
|
||||||
|
"atr_tp_mult": 3.0,
|
||||||
|
"min_notional": 5.0
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 15,
|
||||||
|
"total_pnl": 539.9409,
|
||||||
|
"return_pct": 53.99,
|
||||||
|
"win_rate": 53.33,
|
||||||
|
"avg_win": 84.5208,
|
||||||
|
"avg_loss": -19.4607,
|
||||||
|
"profit_factor": 4.96,
|
||||||
|
"max_drawdown_pct": 3.57,
|
||||||
|
"sharpe_ratio": 83.64,
|
||||||
|
"total_fees": 45.0812,
|
||||||
|
"close_reasons": {
|
||||||
|
"TAKE_PROFIT": 7,
|
||||||
|
"REVERSE_SIGNAL": 3,
|
||||||
|
"STOP_LOSS": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trades": [
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-05-23 02:15:00+00:00",
|
||||||
|
"exit_time": "2025-05-23 04:30:00+00:00",
|
||||||
|
"entry_price": 2.470853,
|
||||||
|
"exit_price": 2.438268,
|
||||||
|
"quantity": 2023.4,
|
||||||
|
"sl": 2.487145,
|
||||||
|
"tp": 2.438268,
|
||||||
|
"gross_pnl": 65.931784,
|
||||||
|
"entry_fee": 1.999809,
|
||||||
|
"exit_fee": 1.973437,
|
||||||
|
"net_pnl": 61.958538,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.5847,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 75.08406565689027,
|
||||||
|
"macd_hist": 0.004905452274126379,
|
||||||
|
"atr": 0.010861550575088958,
|
||||||
|
"adx": 21.704459542796908
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-05-30 00:45:00+00:00",
|
||||||
|
"exit_time": "2025-05-30 02:15:00+00:00",
|
||||||
|
"entry_price": 2.155015,
|
||||||
|
"exit_price": 2.207692,
|
||||||
|
"quantity": 2282.6,
|
||||||
|
"sl": 2.128677,
|
||||||
|
"tp": 2.207692,
|
||||||
|
"gross_pnl": 120.238948,
|
||||||
|
"entry_fee": 1.967615,
|
||||||
|
"exit_fee": 2.015711,
|
||||||
|
"net_pnl": 116.255622,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.7602,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 13.158390769693794,
|
||||||
|
"macd_hist": -0.00797002840932291,
|
||||||
|
"atr": 0.01755877038478085,
|
||||||
|
"adx": 31.17699185815243
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-06-14 04:15:00+00:00",
|
||||||
|
"exit_time": "2025-06-14 08:15:00+00:00",
|
||||||
|
"entry_price": 2.164584,
|
||||||
|
"exit_price": 2.169417,
|
||||||
|
"quantity": 2145.0,
|
||||||
|
"sl": 2.175701,
|
||||||
|
"tp": 2.142349,
|
||||||
|
"gross_pnl": -10.367643,
|
||||||
|
"entry_fee": 1.857213,
|
||||||
|
"exit_fee": 1.86136,
|
||||||
|
"net_pnl": -14.086215,
|
||||||
|
"close_reason": "REVERSE_SIGNAL",
|
||||||
|
"ml_proba": 0.6115,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 69.92512937431012,
|
||||||
|
"macd_hist": 0.0026939087409630215,
|
||||||
|
"atr": 0.007411409293121909,
|
||||||
|
"adx": 20.278562659091943
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-08-14 12:30:00+00:00",
|
||||||
|
"exit_time": "2025-08-14 21:30:00+00:00",
|
||||||
|
"entry_price": 3.132487,
|
||||||
|
"exit_price": 3.035326,
|
||||||
|
"quantity": 1497.5,
|
||||||
|
"sl": 3.181067,
|
||||||
|
"tp": 3.035326,
|
||||||
|
"gross_pnl": 145.498181,
|
||||||
|
"entry_fee": 1.87636,
|
||||||
|
"exit_fee": 1.81816,
|
||||||
|
"net_pnl": 141.803661,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.8857,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 20.976757311144258,
|
||||||
|
"macd_hist": -0.0032317207367513617,
|
||||||
|
"atr": 0.032386907216145205,
|
||||||
|
"adx": 38.70665879423988
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-09-01 00:15:00+00:00",
|
||||||
|
"exit_time": "2025-09-01 02:45:00+00:00",
|
||||||
|
"entry_price": 2.750075,
|
||||||
|
"exit_price": 2.73304,
|
||||||
|
"quantity": 1515.8,
|
||||||
|
"sl": 2.73304,
|
||||||
|
"tp": 2.784144,
|
||||||
|
"gross_pnl": -25.820994,
|
||||||
|
"entry_fee": 1.667425,
|
||||||
|
"exit_fee": 1.657097,
|
||||||
|
"net_pnl": -29.145516,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.5754,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 20.77769666120342,
|
||||||
|
"macd_hist": -0.0054454742314916215,
|
||||||
|
"atr": 0.01135637668410908,
|
||||||
|
"adx": 32.97685850211662
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-10-05 19:00:00+00:00",
|
||||||
|
"exit_time": "2025-10-06 02:45:00+00:00",
|
||||||
|
"entry_price": 2.953995,
|
||||||
|
"exit_price": 2.990353,
|
||||||
|
"quantity": 1457.0,
|
||||||
|
"sl": 2.935817,
|
||||||
|
"tp": 2.990353,
|
||||||
|
"gross_pnl": 52.972491,
|
||||||
|
"entry_fee": 1.721589,
|
||||||
|
"exit_fee": 1.742777,
|
||||||
|
"net_pnl": 49.508125,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.6037,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 22.68978567945751,
|
||||||
|
"macd_hist": -0.003579814992577557,
|
||||||
|
"atr": 0.012119078271027253,
|
||||||
|
"adx": 40.35268005132035
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-10-10 20:45:00+00:00",
|
||||||
|
"exit_time": "2025-10-10 21:00:00+00:00",
|
||||||
|
"entry_price": 2.49595,
|
||||||
|
"exit_price": 2.373231,
|
||||||
|
"quantity": 1638.0,
|
||||||
|
"sl": 2.55731,
|
||||||
|
"tp": 2.373231,
|
||||||
|
"gross_pnl": 201.01468,
|
||||||
|
"entry_fee": 1.635347,
|
||||||
|
"exit_fee": 1.554941,
|
||||||
|
"net_pnl": 197.824393,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.8864,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 17.950089434981262,
|
||||||
|
"macd_hist": -0.010381022790605134,
|
||||||
|
"atr": 0.0409065283069771,
|
||||||
|
"adx": 56.13982003832872
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-10-14 16:15:00+00:00",
|
||||||
|
"exit_time": "2025-10-15 02:15:00+00:00",
|
||||||
|
"entry_price": 2.508449,
|
||||||
|
"exit_price": 2.523552,
|
||||||
|
"quantity": 1204.9,
|
||||||
|
"sl": 2.544104,
|
||||||
|
"tp": 2.437139,
|
||||||
|
"gross_pnl": -18.197846,
|
||||||
|
"entry_fee": 1.208972,
|
||||||
|
"exit_fee": 1.216251,
|
||||||
|
"net_pnl": -20.623069,
|
||||||
|
"close_reason": "REVERSE_SIGNAL",
|
||||||
|
"ml_proba": 0.5683,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 68.85343626442496,
|
||||||
|
"macd_hist": 0.010657476860447013,
|
||||||
|
"atr": 0.023769893751947626,
|
||||||
|
"adx": 39.4255156509299
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-10-27 16:00:00+00:00",
|
||||||
|
"exit_time": "2025-10-27 21:00:00+00:00",
|
||||||
|
"entry_price": 2.674233,
|
||||||
|
"exit_price": 2.623904,
|
||||||
|
"quantity": 1148.8,
|
||||||
|
"sl": 2.699397,
|
||||||
|
"tp": 2.623904,
|
||||||
|
"gross_pnl": 57.816994,
|
||||||
|
"entry_fee": 1.228863,
|
||||||
|
"exit_fee": 1.205737,
|
||||||
|
"net_pnl": 55.382395,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.5625,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 66.34709912155408,
|
||||||
|
"macd_hist": 0.005634259928464551,
|
||||||
|
"atr": 0.01677605457389115,
|
||||||
|
"adx": 23.34205636197947
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-11-28 01:45:00+00:00",
|
||||||
|
"exit_time": "2025-11-28 05:30:00+00:00",
|
||||||
|
"entry_price": 2.171517,
|
||||||
|
"exit_price": 2.200054,
|
||||||
|
"quantity": 1421.9,
|
||||||
|
"sl": 2.157249,
|
||||||
|
"tp": 2.200054,
|
||||||
|
"gross_pnl": 40.576615,
|
||||||
|
"entry_fee": 1.235072,
|
||||||
|
"exit_fee": 1.251303,
|
||||||
|
"net_pnl": 38.09024,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.6381,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 22.942287299402874,
|
||||||
|
"macd_hist": -0.003478384036068617,
|
||||||
|
"atr": 0.009512299239799599,
|
||||||
|
"adx": 35.89384138114383
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-12-19 13:15:00+00:00",
|
||||||
|
"exit_time": "2025-12-19 15:15:00+00:00",
|
||||||
|
"entry_price": 1.878712,
|
||||||
|
"exit_price": 1.889911,
|
||||||
|
"quantity": 1682.4,
|
||||||
|
"sl": 1.889911,
|
||||||
|
"tp": 1.856315,
|
||||||
|
"gross_pnl": -18.840313,
|
||||||
|
"entry_fee": 1.264298,
|
||||||
|
"exit_fee": 1.271834,
|
||||||
|
"net_pnl": -21.376445,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.5945,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 68.16547032772114,
|
||||||
|
"macd_hist": -4.5929936914913816e-05,
|
||||||
|
"atr": 0.007465649526915487,
|
||||||
|
"adx": 40.69667585881617
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-12-25 22:45:00+00:00",
|
||||||
|
"exit_time": "2025-12-25 23:30:00+00:00",
|
||||||
|
"entry_price": 1.844884,
|
||||||
|
"exit_price": 1.836907,
|
||||||
|
"quantity": 1689.1,
|
||||||
|
"sl": 1.836907,
|
||||||
|
"tp": 1.86084,
|
||||||
|
"gross_pnl": -13.475019,
|
||||||
|
"entry_fee": 1.246478,
|
||||||
|
"exit_fee": 1.241088,
|
||||||
|
"net_pnl": -15.962585,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.6099,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 22.431779710524914,
|
||||||
|
"macd_hist": -0.0022220637884117073,
|
||||||
|
"atr": 0.005318421811566107,
|
||||||
|
"adx": 20.682478174103885
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2026-01-17 15:00:00+00:00",
|
||||||
|
"exit_time": "2026-01-17 15:15:00+00:00",
|
||||||
|
"entry_price": 2.074093,
|
||||||
|
"exit_price": 2.080899,
|
||||||
|
"quantity": 1485.5,
|
||||||
|
"sl": 2.080899,
|
||||||
|
"tp": 2.06048,
|
||||||
|
"gross_pnl": -10.110948,
|
||||||
|
"entry_fee": 1.232426,
|
||||||
|
"exit_fee": 1.23647,
|
||||||
|
"net_pnl": -12.579844,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.594,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 69.16433708427633,
|
||||||
|
"macd_hist": 0.0015812464458042678,
|
||||||
|
"atr": 0.004537618137465387,
|
||||||
|
"adx": 16.189151941493567
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2026-01-30 01:30:00+00:00",
|
||||||
|
"exit_time": "2026-01-30 02:15:00+00:00",
|
||||||
|
"entry_price": 1.743626,
|
||||||
|
"exit_price": 1.733473,
|
||||||
|
"quantity": 1751.2,
|
||||||
|
"sl": 1.766432,
|
||||||
|
"tp": 1.698012,
|
||||||
|
"gross_pnl": 17.77869,
|
||||||
|
"entry_fee": 1.221375,
|
||||||
|
"exit_fee": 1.214263,
|
||||||
|
"net_pnl": 15.343052,
|
||||||
|
"close_reason": "REVERSE_SIGNAL",
|
||||||
|
"ml_proba": 0.6829,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 19.89605260729724,
|
||||||
|
"macd_hist": -0.003114826868995284,
|
||||||
|
"atr": 0.015204543588406344,
|
||||||
|
"adx": 20.39618087837
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2026-02-28 06:15:00+00:00",
|
||||||
|
"exit_time": "2026-02-28 06:30:00+00:00",
|
||||||
|
"entry_price": 1.331433,
|
||||||
|
"exit_price": 1.322797,
|
||||||
|
"quantity": 2315.1,
|
||||||
|
"sl": 1.322797,
|
||||||
|
"tp": 1.348705,
|
||||||
|
"gross_pnl": -19.993555,
|
||||||
|
"entry_fee": 1.23296,
|
||||||
|
"exit_fee": 1.224963,
|
||||||
|
"net_pnl": -22.451478,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.7416,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 22.07257216583472,
|
||||||
|
"macd_hist": -0.0019878129960472814,
|
||||||
|
"atr": 0.005757434514952236,
|
||||||
|
"adx": 36.23941502849302
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"validation": {
|
||||||
|
"overall": "FAIL",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "exit_after_entry",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "모든 트레이드에서 청산 > 진입"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sl_tp_direction",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "SL/TP 방향 정합"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no_overlap",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "포지션 비중첩 확인"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "positive_fees",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "수수료 양수 확인"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no_negative_balance",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "잔고 양수 유지"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "win_rate_high",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "승률 정상 (53.3%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "win_rate_low",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "승률 정상 (53.3%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mdd_nonzero",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "MDD 정상 (3.6%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trade_frequency",
|
||||||
|
"passed": false,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "월 평균 1.6건 < 5건 — 신호 생성 부족"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "profit_factor_high",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "PF 정상 (4.96)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
729
results/xrpusdt/backtest_20260306_222616.json
Normal file
729
results/xrpusdt/backtest_20260306_222616.json
Normal file
@@ -0,0 +1,729 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"symbols": [
|
||||||
|
"XRPUSDT"
|
||||||
|
],
|
||||||
|
"start": null,
|
||||||
|
"end": null,
|
||||||
|
"initial_balance": 1000.0,
|
||||||
|
"leverage": 10,
|
||||||
|
"fee_pct": 0.04,
|
||||||
|
"slippage_pct": 0.01,
|
||||||
|
"use_ml": true,
|
||||||
|
"ml_threshold": 0.5,
|
||||||
|
"max_daily_loss_pct": 0.05,
|
||||||
|
"max_positions": 3,
|
||||||
|
"max_same_direction": 2,
|
||||||
|
"margin_max_ratio": 0.5,
|
||||||
|
"margin_min_ratio": 0.2,
|
||||||
|
"margin_decay_rate": 0.0006,
|
||||||
|
"atr_sl_mult": 1.5,
|
||||||
|
"atr_tp_mult": 3.0,
|
||||||
|
"min_notional": 5.0
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 27,
|
||||||
|
"total_pnl": 578.3703,
|
||||||
|
"return_pct": 57.84,
|
||||||
|
"win_rate": 44.44,
|
||||||
|
"avg_win": 76.4564,
|
||||||
|
"avg_loss": -22.6071,
|
||||||
|
"profit_factor": 2.71,
|
||||||
|
"max_drawdown_pct": 5.94,
|
||||||
|
"sharpe_ratio": 56.92,
|
||||||
|
"total_fees": 78.3336,
|
||||||
|
"close_reasons": {
|
||||||
|
"TAKE_PROFIT": 11,
|
||||||
|
"STOP_LOSS": 13,
|
||||||
|
"REVERSE_SIGNAL": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trades": [
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-04-22 21:30:00+00:00",
|
||||||
|
"exit_time": "2025-04-22 21:45:00+00:00",
|
||||||
|
"entry_price": 2.193119,
|
||||||
|
"exit_price": 2.231438,
|
||||||
|
"quantity": 2280.1,
|
||||||
|
"sl": 2.17396,
|
||||||
|
"tp": 2.231438,
|
||||||
|
"gross_pnl": 87.370701,
|
||||||
|
"entry_fee": 2.000213,
|
||||||
|
"exit_fee": 2.035161,
|
||||||
|
"net_pnl": 83.335328,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.5255,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 71.28868810200514,
|
||||||
|
"macd_hist": 0.0011497360749260716,
|
||||||
|
"atr": 0.01277293415280779,
|
||||||
|
"adx": 21.692956014763777
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-04-30 13:45:00+00:00",
|
||||||
|
"exit_time": "2025-04-30 17:30:00+00:00",
|
||||||
|
"entry_price": 2.126213,
|
||||||
|
"exit_price": 2.178374,
|
||||||
|
"quantity": 2294.9,
|
||||||
|
"sl": 2.100132,
|
||||||
|
"tp": 2.178374,
|
||||||
|
"gross_pnl": 119.704067,
|
||||||
|
"entry_fee": 1.951778,
|
||||||
|
"exit_fee": 1.99966,
|
||||||
|
"net_pnl": 115.752629,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.5189,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 18.339202470468877,
|
||||||
|
"macd_hist": -0.009422791981245153,
|
||||||
|
"atr": 0.01738696923950385,
|
||||||
|
"adx": 37.751592918358305
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-05-12 07:00:00+00:00",
|
||||||
|
"exit_time": "2025-05-12 07:45:00+00:00",
|
||||||
|
"entry_price": 2.429643,
|
||||||
|
"exit_price": 2.406718,
|
||||||
|
"quantity": 1883.8,
|
||||||
|
"sl": 2.406718,
|
||||||
|
"tp": 2.475492,
|
||||||
|
"gross_pnl": -43.185431,
|
||||||
|
"entry_fee": 1.830785,
|
||||||
|
"exit_fee": 1.81351,
|
||||||
|
"net_pnl": -46.829726,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.5212,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 69.38835524860481,
|
||||||
|
"macd_hist": 0.0015144198204793255,
|
||||||
|
"atr": 0.01528309116959292,
|
||||||
|
"adx": 13.164741033292744
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-05-23 02:15:00+00:00",
|
||||||
|
"exit_time": "2025-05-23 04:30:00+00:00",
|
||||||
|
"entry_price": 2.470853,
|
||||||
|
"exit_price": 2.438268,
|
||||||
|
"quantity": 1912.0,
|
||||||
|
"sl": 2.487145,
|
||||||
|
"tp": 2.438268,
|
||||||
|
"gross_pnl": 62.301854,
|
||||||
|
"entry_fee": 1.889708,
|
||||||
|
"exit_fee": 1.864788,
|
||||||
|
"net_pnl": 58.547358,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.5847,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 75.08406565689027,
|
||||||
|
"macd_hist": 0.004905452274126379,
|
||||||
|
"atr": 0.010861550575088958,
|
||||||
|
"adx": 21.704459542796908
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-05-30 00:45:00+00:00",
|
||||||
|
"exit_time": "2025-05-30 02:15:00+00:00",
|
||||||
|
"entry_price": 2.155015,
|
||||||
|
"exit_price": 2.207692,
|
||||||
|
"quantity": 2111.2,
|
||||||
|
"sl": 2.128677,
|
||||||
|
"tp": 2.207692,
|
||||||
|
"gross_pnl": 111.210228,
|
||||||
|
"entry_fee": 1.819867,
|
||||||
|
"exit_fee": 1.864352,
|
||||||
|
"net_pnl": 107.526009,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.7602,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 13.158390769693794,
|
||||||
|
"macd_hist": -0.00797002840932291,
|
||||||
|
"atr": 0.01755877038478085,
|
||||||
|
"adx": 31.17699185815243
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-06-14 04:15:00+00:00",
|
||||||
|
"exit_time": "2025-06-14 08:15:00+00:00",
|
||||||
|
"entry_price": 2.164584,
|
||||||
|
"exit_price": 2.169417,
|
||||||
|
"quantity": 1902.7,
|
||||||
|
"sl": 2.175701,
|
||||||
|
"tp": 2.142349,
|
||||||
|
"gross_pnl": -9.19651,
|
||||||
|
"entry_fee": 1.647421,
|
||||||
|
"exit_fee": 1.6511,
|
||||||
|
"net_pnl": -12.495031,
|
||||||
|
"close_reason": "REVERSE_SIGNAL",
|
||||||
|
"ml_proba": 0.6115,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 69.92512937431012,
|
||||||
|
"macd_hist": 0.0026939087409630215,
|
||||||
|
"atr": 0.007411409293121909,
|
||||||
|
"adx": 20.278562659091943
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-08-09 05:00:00+00:00",
|
||||||
|
"exit_time": "2025-08-09 13:15:00+00:00",
|
||||||
|
"entry_price": 3.312231,
|
||||||
|
"exit_price": 3.292646,
|
||||||
|
"quantity": 1263.4,
|
||||||
|
"sl": 3.292646,
|
||||||
|
"tp": 3.351402,
|
||||||
|
"gross_pnl": -24.744208,
|
||||||
|
"entry_fee": 1.673869,
|
||||||
|
"exit_fee": 1.663971,
|
||||||
|
"net_pnl": -28.082049,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.5224,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 59.23947009852718,
|
||||||
|
"macd_hist": 0.0003900966619318647,
|
||||||
|
"atr": 0.013056940780994142,
|
||||||
|
"adx": 10.004914128021252
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-08-14 12:30:00+00:00",
|
||||||
|
"exit_time": "2025-08-14 21:30:00+00:00",
|
||||||
|
"entry_price": 3.132487,
|
||||||
|
"exit_price": 3.035326,
|
||||||
|
"quantity": 1377.0,
|
||||||
|
"sl": 3.181067,
|
||||||
|
"tp": 3.035326,
|
||||||
|
"gross_pnl": 133.790314,
|
||||||
|
"entry_fee": 1.725374,
|
||||||
|
"exit_fee": 1.671858,
|
||||||
|
"net_pnl": 130.393082,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.8857,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 20.976757311144258,
|
||||||
|
"macd_hist": -0.0032317207367513617,
|
||||||
|
"atr": 0.032386907216145205,
|
||||||
|
"adx": 38.70665879423988
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-09-01 00:15:00+00:00",
|
||||||
|
"exit_time": "2025-09-01 02:45:00+00:00",
|
||||||
|
"entry_price": 2.750075,
|
||||||
|
"exit_price": 2.73304,
|
||||||
|
"quantity": 1337.1,
|
||||||
|
"sl": 2.73304,
|
||||||
|
"tp": 2.784144,
|
||||||
|
"gross_pnl": -22.776917,
|
||||||
|
"entry_fee": 1.47085,
|
||||||
|
"exit_fee": 1.461739,
|
||||||
|
"net_pnl": -25.709506,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.5754,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 20.77769666120342,
|
||||||
|
"macd_hist": -0.0054454742314916215,
|
||||||
|
"atr": 0.01135637668410908,
|
||||||
|
"adx": 32.97685850211662
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-10-05 19:00:00+00:00",
|
||||||
|
"exit_time": "2025-10-06 02:45:00+00:00",
|
||||||
|
"entry_price": 2.953995,
|
||||||
|
"exit_price": 2.990353,
|
||||||
|
"quantity": 1296.0,
|
||||||
|
"sl": 2.935817,
|
||||||
|
"tp": 2.990353,
|
||||||
|
"gross_pnl": 47.118976,
|
||||||
|
"entry_fee": 1.531351,
|
||||||
|
"exit_fee": 1.550199,
|
||||||
|
"net_pnl": 44.037426,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.6037,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 22.68978567945751,
|
||||||
|
"macd_hist": -0.003579814992577557,
|
||||||
|
"atr": 0.012119078271027253,
|
||||||
|
"adx": 40.35268005132035
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-10-10 03:30:00+00:00",
|
||||||
|
"exit_time": "2025-10-10 04:30:00+00:00",
|
||||||
|
"entry_price": 2.824282,
|
||||||
|
"exit_price": 2.80916,
|
||||||
|
"quantity": 1270.4,
|
||||||
|
"sl": 2.80916,
|
||||||
|
"tp": 2.854527,
|
||||||
|
"gross_pnl": -19.211087,
|
||||||
|
"entry_fee": 1.435187,
|
||||||
|
"exit_fee": 1.427503,
|
||||||
|
"net_pnl": -22.073778,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.5018,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 62.01041612364824,
|
||||||
|
"macd_hist": 0.0007930491111498017,
|
||||||
|
"atr": 0.010081385063037305,
|
||||||
|
"adx": 15.010710180807658
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-10-10 20:45:00+00:00",
|
||||||
|
"exit_time": "2025-10-10 21:00:00+00:00",
|
||||||
|
"entry_price": 2.49595,
|
||||||
|
"exit_price": 2.373231,
|
||||||
|
"quantity": 1491.6,
|
||||||
|
"sl": 2.55731,
|
||||||
|
"tp": 2.373231,
|
||||||
|
"gross_pnl": 183.048533,
|
||||||
|
"entry_fee": 1.489184,
|
||||||
|
"exit_fee": 1.415964,
|
||||||
|
"net_pnl": 180.143385,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.8864,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 17.950089434981262,
|
||||||
|
"macd_hist": -0.010381022790605134,
|
||||||
|
"atr": 0.0409065283069771,
|
||||||
|
"adx": 56.13982003832872
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-10-14 16:15:00+00:00",
|
||||||
|
"exit_time": "2025-10-15 02:15:00+00:00",
|
||||||
|
"entry_price": 2.508449,
|
||||||
|
"exit_price": 2.523552,
|
||||||
|
"quantity": 1246.9,
|
||||||
|
"sl": 2.544104,
|
||||||
|
"tp": 2.437139,
|
||||||
|
"gross_pnl": -18.83218,
|
||||||
|
"entry_fee": 1.251114,
|
||||||
|
"exit_fee": 1.258647,
|
||||||
|
"net_pnl": -21.341941,
|
||||||
|
"close_reason": "REVERSE_SIGNAL",
|
||||||
|
"ml_proba": 0.5683,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 68.85343626442496,
|
||||||
|
"macd_hist": 0.010657476860447013,
|
||||||
|
"atr": 0.023769893751947626,
|
||||||
|
"adx": 39.4255156509299
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-10-27 16:00:00+00:00",
|
||||||
|
"exit_time": "2025-10-27 21:00:00+00:00",
|
||||||
|
"entry_price": 2.674233,
|
||||||
|
"exit_price": 2.623904,
|
||||||
|
"quantity": 1152.7,
|
||||||
|
"sl": 2.699397,
|
||||||
|
"tp": 2.623904,
|
||||||
|
"gross_pnl": 58.013274,
|
||||||
|
"entry_fee": 1.233035,
|
||||||
|
"exit_fee": 1.20983,
|
||||||
|
"net_pnl": 55.570409,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.5625,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 66.34709912155408,
|
||||||
|
"macd_hist": 0.005634259928464551,
|
||||||
|
"atr": 0.01677605457389115,
|
||||||
|
"adx": 23.34205636197947
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-11-09 23:00:00+00:00",
|
||||||
|
"exit_time": "2025-11-10 00:00:00+00:00",
|
||||||
|
"entry_price": 2.367437,
|
||||||
|
"exit_price": 2.346123,
|
||||||
|
"quantity": 1348.3,
|
||||||
|
"sl": 2.346123,
|
||||||
|
"tp": 2.410064,
|
||||||
|
"gross_pnl": -28.737235,
|
||||||
|
"entry_fee": 1.276806,
|
||||||
|
"exit_fee": 1.265311,
|
||||||
|
"net_pnl": -31.279352,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.5333,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 70.79337728670897,
|
||||||
|
"macd_hist": 0.0008725935921229458,
|
||||||
|
"atr": 0.014209120053368062,
|
||||||
|
"adx": 35.31657325998852
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-11-28 01:45:00+00:00",
|
||||||
|
"exit_time": "2025-11-28 05:30:00+00:00",
|
||||||
|
"entry_price": 2.171517,
|
||||||
|
"exit_price": 2.200054,
|
||||||
|
"quantity": 1439.9,
|
||||||
|
"sl": 2.157249,
|
||||||
|
"tp": 2.200054,
|
||||||
|
"gross_pnl": 41.090279,
|
||||||
|
"entry_fee": 1.250707,
|
||||||
|
"exit_fee": 1.267143,
|
||||||
|
"net_pnl": 38.572429,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.6381,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 22.942287299402874,
|
||||||
|
"macd_hist": -0.003478384036068617,
|
||||||
|
"atr": 0.009512299239799599,
|
||||||
|
"adx": 35.89384138114383
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-12-19 13:15:00+00:00",
|
||||||
|
"exit_time": "2025-12-19 15:15:00+00:00",
|
||||||
|
"entry_price": 1.878712,
|
||||||
|
"exit_price": 1.889911,
|
||||||
|
"quantity": 1703.8,
|
||||||
|
"sl": 1.889911,
|
||||||
|
"tp": 1.856315,
|
||||||
|
"gross_pnl": -19.07996,
|
||||||
|
"entry_fee": 1.28038,
|
||||||
|
"exit_fee": 1.288012,
|
||||||
|
"net_pnl": -21.648352,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.5945,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 68.16547032772114,
|
||||||
|
"macd_hist": -4.5929936914913816e-05,
|
||||||
|
"atr": 0.007465649526915487,
|
||||||
|
"adx": 40.69667585881617
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-12-25 22:45:00+00:00",
|
||||||
|
"exit_time": "2025-12-25 23:30:00+00:00",
|
||||||
|
"entry_price": 1.844884,
|
||||||
|
"exit_price": 1.836907,
|
||||||
|
"quantity": 1710.5,
|
||||||
|
"sl": 1.836907,
|
||||||
|
"tp": 1.86084,
|
||||||
|
"gross_pnl": -13.645741,
|
||||||
|
"entry_fee": 1.26227,
|
||||||
|
"exit_fee": 1.256812,
|
||||||
|
"net_pnl": -16.164822,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.6099,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 22.431779710524914,
|
||||||
|
"macd_hist": -0.0022220637884117073,
|
||||||
|
"atr": 0.005318421811566107,
|
||||||
|
"adx": 20.682478174103885
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2026-01-11 12:45:00+00:00",
|
||||||
|
"exit_time": "2026-01-11 14:45:00+00:00",
|
||||||
|
"entry_price": 2.10289,
|
||||||
|
"exit_price": 2.108508,
|
||||||
|
"quantity": 1483.7,
|
||||||
|
"sl": 2.108508,
|
||||||
|
"tp": 2.091653,
|
||||||
|
"gross_pnl": -8.336295,
|
||||||
|
"entry_fee": 1.248023,
|
||||||
|
"exit_fee": 1.251357,
|
||||||
|
"net_pnl": -10.835676,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.5098,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 67.90556933822843,
|
||||||
|
"macd_hist": 0.0011236246859926337,
|
||||||
|
"atr": 0.0037457236024969268,
|
||||||
|
"adx": 14.695994703326152
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2026-01-17 15:00:00+00:00",
|
||||||
|
"exit_time": "2026-01-17 15:15:00+00:00",
|
||||||
|
"entry_price": 2.074093,
|
||||||
|
"exit_price": 2.080899,
|
||||||
|
"quantity": 1492.7,
|
||||||
|
"sl": 2.080899,
|
||||||
|
"tp": 2.06048,
|
||||||
|
"gross_pnl": -10.159954,
|
||||||
|
"entry_fee": 1.238399,
|
||||||
|
"exit_fee": 1.242463,
|
||||||
|
"net_pnl": -12.640816,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.594,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 69.16433708427633,
|
||||||
|
"macd_hist": 0.0015812464458042678,
|
||||||
|
"atr": 0.004537618137465387,
|
||||||
|
"adx": 16.189151941493567
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2026-01-28 09:45:00+00:00",
|
||||||
|
"exit_time": "2026-01-28 14:15:00+00:00",
|
||||||
|
"entry_price": 1.931393,
|
||||||
|
"exit_price": 1.921506,
|
||||||
|
"quantity": 1588.9,
|
||||||
|
"sl": 1.921506,
|
||||||
|
"tp": 1.951168,
|
||||||
|
"gross_pnl": -15.71035,
|
||||||
|
"entry_fee": 1.227516,
|
||||||
|
"exit_fee": 1.221232,
|
||||||
|
"net_pnl": -18.159098,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.5361,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 69.49360491728265,
|
||||||
|
"macd_hist": 0.0009318141405021494,
|
||||||
|
"atr": 0.006591708992321306,
|
||||||
|
"adx": 23.59253632415462
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2026-01-29 14:15:00+00:00",
|
||||||
|
"exit_time": "2026-01-29 14:30:00+00:00",
|
||||||
|
"entry_price": 1.853915,
|
||||||
|
"exit_price": 1.862898,
|
||||||
|
"quantity": 1634.1,
|
||||||
|
"sl": 1.862898,
|
||||||
|
"tp": 1.835947,
|
||||||
|
"gross_pnl": -14.680593,
|
||||||
|
"entry_fee": 1.211793,
|
||||||
|
"exit_fee": 1.217665,
|
||||||
|
"net_pnl": -17.110051,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.5373,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 27.456064937316995,
|
||||||
|
"macd_hist": -0.0003642198670342086,
|
||||||
|
"atr": 0.00598926765439418,
|
||||||
|
"adx": 29.652186976597193
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2026-01-29 15:30:00+00:00",
|
||||||
|
"exit_time": "2026-01-29 17:15:00+00:00",
|
||||||
|
"entry_price": 1.792279,
|
||||||
|
"exit_price": 1.830034,
|
||||||
|
"quantity": 1687.4,
|
||||||
|
"sl": 1.773402,
|
||||||
|
"tp": 1.830034,
|
||||||
|
"gross_pnl": 63.70772,
|
||||||
|
"entry_fee": 1.209717,
|
||||||
|
"exit_fee": 1.2352,
|
||||||
|
"net_pnl": 61.262804,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.5189,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 16.64017828809098,
|
||||||
|
"macd_hist": -0.007714360437304368,
|
||||||
|
"atr": 0.012584986826922885,
|
||||||
|
"adx": 45.78370985540058
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2026-01-30 01:30:00+00:00",
|
||||||
|
"exit_time": "2026-01-30 02:15:00+00:00",
|
||||||
|
"entry_price": 1.743626,
|
||||||
|
"exit_price": 1.733473,
|
||||||
|
"quantity": 1785.3,
|
||||||
|
"sl": 1.766432,
|
||||||
|
"tp": 1.698012,
|
||||||
|
"gross_pnl": 18.124883,
|
||||||
|
"entry_fee": 1.245158,
|
||||||
|
"exit_fee": 1.237908,
|
||||||
|
"net_pnl": 15.641817,
|
||||||
|
"close_reason": "REVERSE_SIGNAL",
|
||||||
|
"ml_proba": 0.6829,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 19.89605260729724,
|
||||||
|
"macd_hist": -0.003114826868995284,
|
||||||
|
"atr": 0.015204543588406344,
|
||||||
|
"adx": 20.39618087837
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2026-02-21 14:00:00+00:00",
|
||||||
|
"exit_time": "2026-02-21 14:30:00+00:00",
|
||||||
|
"entry_price": 1.447345,
|
||||||
|
"exit_price": 1.460803,
|
||||||
|
"quantity": 2171.1,
|
||||||
|
"sl": 1.440616,
|
||||||
|
"tp": 1.460803,
|
||||||
|
"gross_pnl": 29.219526,
|
||||||
|
"entry_fee": 1.256932,
|
||||||
|
"exit_fee": 1.26862,
|
||||||
|
"net_pnl": 26.693974,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": 0.5205,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 65.66004200808446,
|
||||||
|
"macd_hist": 0.0002040437812304472,
|
||||||
|
"atr": 0.004486132335137116,
|
||||||
|
"adx": 22.48469775979257
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2026-02-25 01:00:00+00:00",
|
||||||
|
"exit_time": "2026-02-25 03:15:00+00:00",
|
||||||
|
"entry_price": 1.383138,
|
||||||
|
"exit_price": 1.370396,
|
||||||
|
"quantity": 2308.7,
|
||||||
|
"sl": 1.370396,
|
||||||
|
"tp": 1.408624,
|
||||||
|
"gross_pnl": -29.418901,
|
||||||
|
"entry_fee": 1.277301,
|
||||||
|
"exit_fee": 1.265533,
|
||||||
|
"net_pnl": -31.961735,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.5054,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 73.1504226046038,
|
||||||
|
"macd_hist": 0.0014379189881605118,
|
||||||
|
"atr": 0.008495084124686957,
|
||||||
|
"adx": 16.57739949935574
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2026-02-28 06:15:00+00:00",
|
||||||
|
"exit_time": "2026-02-28 06:30:00+00:00",
|
||||||
|
"entry_price": 1.331433,
|
||||||
|
"exit_price": 1.322797,
|
||||||
|
"quantity": 2348.4,
|
||||||
|
"sl": 1.322797,
|
||||||
|
"tp": 1.348705,
|
||||||
|
"gross_pnl": -20.281139,
|
||||||
|
"entry_fee": 1.250695,
|
||||||
|
"exit_fee": 1.242583,
|
||||||
|
"net_pnl": -22.774416,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": 0.7416,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 22.07257216583472,
|
||||||
|
"macd_hist": -0.0019878129960472814,
|
||||||
|
"atr": 0.005757434514952236,
|
||||||
|
"adx": 36.23941502849302
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"validation": {
|
||||||
|
"overall": "FAIL",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "exit_after_entry",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "모든 트레이드에서 청산 > 진입"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sl_tp_direction",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "SL/TP 방향 정합"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no_overlap",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "포지션 비중첩 확인"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "positive_fees",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "수수료 양수 확인"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no_negative_balance",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "잔고 양수 유지"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "win_rate_high",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "승률 정상 (44.4%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "win_rate_low",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "승률 정상 (44.4%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mdd_nonzero",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "MDD 정상 (5.9%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trade_frequency",
|
||||||
|
"passed": false,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "월 평균 2.6건 < 5건 — 신호 생성 부족"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "profit_factor_high",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "PF 정상 (2.71)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1281
results/xrpusdt/backtest_20260306_222621.json
Normal file
1281
results/xrpusdt/backtest_20260306_222621.json
Normal file
File diff suppressed because it is too large
Load Diff
10803
results/xrpusdt/backtest_20260306_223141.json
Normal file
10803
results/xrpusdt/backtest_20260306_223141.json
Normal file
File diff suppressed because it is too large
Load Diff
8750
results/xrpusdt/strategy_sweep_20260306_225408.json
Normal file
8750
results/xrpusdt/strategy_sweep_20260306_225408.json
Normal file
File diff suppressed because it is too large
Load Diff
7579
results/xrpusdt/strategy_sweep_20260306_230357.json
Normal file
7579
results/xrpusdt/strategy_sweep_20260306_230357.json
Normal file
File diff suppressed because it is too large
Load Diff
7291
results/xrpusdt/strategy_sweep_20260307_001303.json
Normal file
7291
results/xrpusdt/strategy_sweep_20260307_001303.json
Normal file
File diff suppressed because it is too large
Load Diff
7579
results/xrpusdt/strategy_sweep_20260307_001906.json
Normal file
7579
results/xrpusdt/strategy_sweep_20260307_001906.json
Normal file
File diff suppressed because it is too large
Load Diff
7579
results/xrpusdt/strategy_sweep_20260317_172135.json
Normal file
7579
results/xrpusdt/strategy_sweep_20260317_172135.json
Normal file
File diff suppressed because it is too large
Load Diff
1267
results/xrpusdt/wf_backtest_20260306_223446.json
Normal file
1267
results/xrpusdt/wf_backtest_20260306_223446.json
Normal file
File diff suppressed because it is too large
Load Diff
3016
results/xrpusdt/wf_backtest_20260306_223459.json
Normal file
3016
results/xrpusdt/wf_backtest_20260306_223459.json
Normal file
File diff suppressed because it is too large
Load Diff
1267
results/xrpusdt/wf_backtest_20260306_224057.json
Normal file
1267
results/xrpusdt/wf_backtest_20260306_224057.json
Normal file
File diff suppressed because it is too large
Load Diff
3016
results/xrpusdt/wf_backtest_20260306_224241.json
Normal file
3016
results/xrpusdt/wf_backtest_20260306_224241.json
Normal file
File diff suppressed because it is too large
Load Diff
3016
results/xrpusdt/wf_backtest_20260306_224303.json
Normal file
3016
results/xrpusdt/wf_backtest_20260306_224303.json
Normal file
File diff suppressed because it is too large
Load Diff
77
results/xrpusdt/wf_backtest_20260306_234947.json
Normal file
77
results/xrpusdt/wf_backtest_20260306_234947.json
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"mode": "walk_forward",
|
||||||
|
"config": {
|
||||||
|
"symbols": [
|
||||||
|
"XRPUSDT"
|
||||||
|
],
|
||||||
|
"start": null,
|
||||||
|
"end": null,
|
||||||
|
"initial_balance": 1000.0,
|
||||||
|
"leverage": 10,
|
||||||
|
"fee_pct": 0.04,
|
||||||
|
"slippage_pct": 0.01,
|
||||||
|
"use_ml": true,
|
||||||
|
"ml_threshold": 0.55,
|
||||||
|
"max_daily_loss_pct": 0.05,
|
||||||
|
"max_positions": 3,
|
||||||
|
"max_same_direction": 2,
|
||||||
|
"margin_max_ratio": 0.5,
|
||||||
|
"margin_min_ratio": 0.2,
|
||||||
|
"margin_decay_rate": 0.0006,
|
||||||
|
"atr_sl_mult": 2.0,
|
||||||
|
"atr_tp_mult": 2.0,
|
||||||
|
"min_notional": 5.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 25.0,
|
||||||
|
"volume_multiplier": 2.5,
|
||||||
|
"train_months": 6,
|
||||||
|
"test_months": 1,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"negative_ratio": 5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
},
|
||||||
|
"folds": [
|
||||||
|
{
|
||||||
|
"fold": 1,
|
||||||
|
"train_period": "2025-03-05 ~ 2025-09-05",
|
||||||
|
"test_period": "2025-09-05 ~ 2025-10-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"trades": [],
|
||||||
|
"validation": {
|
||||||
|
"overall": "PASS",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "trade_count",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "트레이드 없음 (검증 스킵)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
77
results/xrpusdt/wf_backtest_20260306_235157.json
Normal file
77
results/xrpusdt/wf_backtest_20260306_235157.json
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"mode": "walk_forward",
|
||||||
|
"config": {
|
||||||
|
"symbols": [
|
||||||
|
"XRPUSDT"
|
||||||
|
],
|
||||||
|
"start": null,
|
||||||
|
"end": null,
|
||||||
|
"initial_balance": 1000.0,
|
||||||
|
"leverage": 10,
|
||||||
|
"fee_pct": 0.04,
|
||||||
|
"slippage_pct": 0.01,
|
||||||
|
"use_ml": false,
|
||||||
|
"ml_threshold": 0.55,
|
||||||
|
"max_daily_loss_pct": 0.05,
|
||||||
|
"max_positions": 3,
|
||||||
|
"max_same_direction": 2,
|
||||||
|
"margin_max_ratio": 0.5,
|
||||||
|
"margin_min_ratio": 0.2,
|
||||||
|
"margin_decay_rate": 0.0006,
|
||||||
|
"atr_sl_mult": 2.0,
|
||||||
|
"atr_tp_mult": 2.0,
|
||||||
|
"min_notional": 5.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 25.0,
|
||||||
|
"volume_multiplier": 2.5,
|
||||||
|
"train_months": 6,
|
||||||
|
"test_months": 1,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"negative_ratio": 5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
},
|
||||||
|
"folds": [
|
||||||
|
{
|
||||||
|
"fold": 1,
|
||||||
|
"train_period": "2025-03-05 ~ 2025-09-05",
|
||||||
|
"test_period": "2025-09-05 ~ 2025-10-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"trades": [],
|
||||||
|
"validation": {
|
||||||
|
"overall": "PASS",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "trade_count",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "트레이드 없음 (검증 스킵)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
113
results/xrpusdt/wf_backtest_20260306_235416.json
Normal file
113
results/xrpusdt/wf_backtest_20260306_235416.json
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"mode": "walk_forward",
|
||||||
|
"config": {
|
||||||
|
"symbols": [
|
||||||
|
"XRPUSDT"
|
||||||
|
],
|
||||||
|
"start": null,
|
||||||
|
"end": null,
|
||||||
|
"initial_balance": 1000.0,
|
||||||
|
"leverage": 10,
|
||||||
|
"fee_pct": 0.04,
|
||||||
|
"slippage_pct": 0.01,
|
||||||
|
"use_ml": true,
|
||||||
|
"ml_threshold": 0.55,
|
||||||
|
"max_daily_loss_pct": 0.05,
|
||||||
|
"max_positions": 3,
|
||||||
|
"max_same_direction": 2,
|
||||||
|
"margin_max_ratio": 0.5,
|
||||||
|
"margin_min_ratio": 0.2,
|
||||||
|
"margin_decay_rate": 0.0006,
|
||||||
|
"atr_sl_mult": 2.0,
|
||||||
|
"atr_tp_mult": 2.0,
|
||||||
|
"min_notional": 5.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 25.0,
|
||||||
|
"volume_multiplier": 2.5,
|
||||||
|
"train_months": 3,
|
||||||
|
"test_months": 1,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"negative_ratio": 5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
},
|
||||||
|
"folds": [
|
||||||
|
{
|
||||||
|
"fold": 1,
|
||||||
|
"train_period": "2025-03-05 ~ 2025-06-05",
|
||||||
|
"test_period": "2025-06-05 ~ 2025-07-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 2,
|
||||||
|
"train_period": "2025-06-05 ~ 2025-09-05",
|
||||||
|
"test_period": "2025-09-05 ~ 2025-10-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 3,
|
||||||
|
"train_period": "2025-09-05 ~ 2025-12-05",
|
||||||
|
"test_period": "2025-12-05 ~ 2026-01-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"trades": [],
|
||||||
|
"validation": {
|
||||||
|
"overall": "PASS",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "trade_count",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "트레이드 없음 (검증 스킵)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
113
results/xrpusdt/wf_backtest_20260306_235536.json
Normal file
113
results/xrpusdt/wf_backtest_20260306_235536.json
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"mode": "walk_forward",
|
||||||
|
"config": {
|
||||||
|
"symbols": [
|
||||||
|
"XRPUSDT"
|
||||||
|
],
|
||||||
|
"start": null,
|
||||||
|
"end": null,
|
||||||
|
"initial_balance": 1000.0,
|
||||||
|
"leverage": 10,
|
||||||
|
"fee_pct": 0.04,
|
||||||
|
"slippage_pct": 0.01,
|
||||||
|
"use_ml": false,
|
||||||
|
"ml_threshold": 0.55,
|
||||||
|
"max_daily_loss_pct": 0.05,
|
||||||
|
"max_positions": 3,
|
||||||
|
"max_same_direction": 2,
|
||||||
|
"margin_max_ratio": 0.5,
|
||||||
|
"margin_min_ratio": 0.2,
|
||||||
|
"margin_decay_rate": 0.0006,
|
||||||
|
"atr_sl_mult": 2.0,
|
||||||
|
"atr_tp_mult": 2.0,
|
||||||
|
"min_notional": 5.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 25.0,
|
||||||
|
"volume_multiplier": 2.5,
|
||||||
|
"train_months": 3,
|
||||||
|
"test_months": 1,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"negative_ratio": 5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
},
|
||||||
|
"folds": [
|
||||||
|
{
|
||||||
|
"fold": 1,
|
||||||
|
"train_period": "2025-03-05 ~ 2025-06-05",
|
||||||
|
"test_period": "2025-06-05 ~ 2025-07-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 2,
|
||||||
|
"train_period": "2025-06-05 ~ 2025-09-05",
|
||||||
|
"test_period": "2025-09-05 ~ 2025-10-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 3,
|
||||||
|
"train_period": "2025-09-05 ~ 2025-12-05",
|
||||||
|
"test_period": "2025-12-05 ~ 2026-01-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"trades": [],
|
||||||
|
"validation": {
|
||||||
|
"overall": "PASS",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "trade_count",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "트레이드 없음 (검증 스킵)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
113
results/xrpusdt/wf_backtest_20260306_235621.json
Normal file
113
results/xrpusdt/wf_backtest_20260306_235621.json
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"mode": "walk_forward",
|
||||||
|
"config": {
|
||||||
|
"symbols": [
|
||||||
|
"XRPUSDT"
|
||||||
|
],
|
||||||
|
"start": null,
|
||||||
|
"end": null,
|
||||||
|
"initial_balance": 1000.0,
|
||||||
|
"leverage": 10,
|
||||||
|
"fee_pct": 0.04,
|
||||||
|
"slippage_pct": 0.01,
|
||||||
|
"use_ml": false,
|
||||||
|
"ml_threshold": 0.55,
|
||||||
|
"max_daily_loss_pct": 0.05,
|
||||||
|
"max_positions": 3,
|
||||||
|
"max_same_direction": 2,
|
||||||
|
"margin_max_ratio": 0.5,
|
||||||
|
"margin_min_ratio": 0.2,
|
||||||
|
"margin_decay_rate": 0.0006,
|
||||||
|
"atr_sl_mult": 2.0,
|
||||||
|
"atr_tp_mult": 2.0,
|
||||||
|
"min_notional": 5.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 25.0,
|
||||||
|
"volume_multiplier": 2.5,
|
||||||
|
"train_months": 3,
|
||||||
|
"test_months": 1,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"negative_ratio": 5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
},
|
||||||
|
"folds": [
|
||||||
|
{
|
||||||
|
"fold": 1,
|
||||||
|
"train_period": "2025-03-05 ~ 2025-06-05",
|
||||||
|
"test_period": "2025-06-05 ~ 2025-07-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 2,
|
||||||
|
"train_period": "2025-06-05 ~ 2025-09-05",
|
||||||
|
"test_period": "2025-09-05 ~ 2025-10-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 3,
|
||||||
|
"train_period": "2025-09-05 ~ 2025-12-05",
|
||||||
|
"test_period": "2025-12-05 ~ 2026-01-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"trades": [],
|
||||||
|
"validation": {
|
||||||
|
"overall": "PASS",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "trade_count",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "트레이드 없음 (검증 스킵)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
113
results/xrpusdt/wf_backtest_20260306_235910.json
Normal file
113
results/xrpusdt/wf_backtest_20260306_235910.json
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"mode": "walk_forward",
|
||||||
|
"config": {
|
||||||
|
"symbols": [
|
||||||
|
"XRPUSDT"
|
||||||
|
],
|
||||||
|
"start": null,
|
||||||
|
"end": null,
|
||||||
|
"initial_balance": 1000.0,
|
||||||
|
"leverage": 10,
|
||||||
|
"fee_pct": 0.04,
|
||||||
|
"slippage_pct": 0.01,
|
||||||
|
"use_ml": true,
|
||||||
|
"ml_threshold": 0.55,
|
||||||
|
"max_daily_loss_pct": 0.05,
|
||||||
|
"max_positions": 3,
|
||||||
|
"max_same_direction": 2,
|
||||||
|
"margin_max_ratio": 0.5,
|
||||||
|
"margin_min_ratio": 0.2,
|
||||||
|
"margin_decay_rate": 0.0006,
|
||||||
|
"atr_sl_mult": 2.0,
|
||||||
|
"atr_tp_mult": 2.0,
|
||||||
|
"min_notional": 5.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 25.0,
|
||||||
|
"volume_multiplier": 2.5,
|
||||||
|
"train_months": 3,
|
||||||
|
"test_months": 1,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"negative_ratio": 5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
},
|
||||||
|
"folds": [
|
||||||
|
{
|
||||||
|
"fold": 1,
|
||||||
|
"train_period": "2025-03-05 ~ 2025-06-05",
|
||||||
|
"test_period": "2025-06-05 ~ 2025-07-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 2,
|
||||||
|
"train_period": "2025-06-05 ~ 2025-09-05",
|
||||||
|
"test_period": "2025-09-05 ~ 2025-10-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 3,
|
||||||
|
"train_period": "2025-09-05 ~ 2025-12-05",
|
||||||
|
"test_period": "2025-12-05 ~ 2026-01-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"trades": [],
|
||||||
|
"validation": {
|
||||||
|
"overall": "PASS",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "trade_count",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "트레이드 없음 (검증 스킵)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
113
results/xrpusdt/wf_backtest_20260306_235938.json
Normal file
113
results/xrpusdt/wf_backtest_20260306_235938.json
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"mode": "walk_forward",
|
||||||
|
"config": {
|
||||||
|
"symbols": [
|
||||||
|
"XRPUSDT"
|
||||||
|
],
|
||||||
|
"start": null,
|
||||||
|
"end": null,
|
||||||
|
"initial_balance": 1000.0,
|
||||||
|
"leverage": 10,
|
||||||
|
"fee_pct": 0.04,
|
||||||
|
"slippage_pct": 0.01,
|
||||||
|
"use_ml": true,
|
||||||
|
"ml_threshold": 0.55,
|
||||||
|
"max_daily_loss_pct": 0.05,
|
||||||
|
"max_positions": 3,
|
||||||
|
"max_same_direction": 2,
|
||||||
|
"margin_max_ratio": 0.5,
|
||||||
|
"margin_min_ratio": 0.2,
|
||||||
|
"margin_decay_rate": 0.0006,
|
||||||
|
"atr_sl_mult": 2.0,
|
||||||
|
"atr_tp_mult": 2.0,
|
||||||
|
"min_notional": 5.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 25.0,
|
||||||
|
"volume_multiplier": 2.5,
|
||||||
|
"train_months": 3,
|
||||||
|
"test_months": 1,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"negative_ratio": 5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
},
|
||||||
|
"folds": [
|
||||||
|
{
|
||||||
|
"fold": 1,
|
||||||
|
"train_period": "2025-03-05 ~ 2025-06-05",
|
||||||
|
"test_period": "2025-06-05 ~ 2025-07-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 2,
|
||||||
|
"train_period": "2025-06-05 ~ 2025-09-05",
|
||||||
|
"test_period": "2025-09-05 ~ 2025-10-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 3,
|
||||||
|
"train_period": "2025-09-05 ~ 2025-12-05",
|
||||||
|
"test_period": "2025-12-05 ~ 2026-01-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"trades": [],
|
||||||
|
"validation": {
|
||||||
|
"overall": "PASS",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "trade_count",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "트레이드 없음 (검증 스킵)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
113
results/xrpusdt/wf_backtest_20260307_000234.json
Normal file
113
results/xrpusdt/wf_backtest_20260307_000234.json
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"mode": "walk_forward",
|
||||||
|
"config": {
|
||||||
|
"symbols": [
|
||||||
|
"XRPUSDT"
|
||||||
|
],
|
||||||
|
"start": null,
|
||||||
|
"end": null,
|
||||||
|
"initial_balance": 1000.0,
|
||||||
|
"leverage": 10,
|
||||||
|
"fee_pct": 0.04,
|
||||||
|
"slippage_pct": 0.01,
|
||||||
|
"use_ml": true,
|
||||||
|
"ml_threshold": 0.55,
|
||||||
|
"max_daily_loss_pct": 0.05,
|
||||||
|
"max_positions": 3,
|
||||||
|
"max_same_direction": 2,
|
||||||
|
"margin_max_ratio": 0.5,
|
||||||
|
"margin_min_ratio": 0.2,
|
||||||
|
"margin_decay_rate": 0.0006,
|
||||||
|
"atr_sl_mult": 2.0,
|
||||||
|
"atr_tp_mult": 2.0,
|
||||||
|
"min_notional": 5.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 25.0,
|
||||||
|
"volume_multiplier": 2.5,
|
||||||
|
"train_months": 3,
|
||||||
|
"test_months": 1,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"negative_ratio": 5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
},
|
||||||
|
"folds": [
|
||||||
|
{
|
||||||
|
"fold": 1,
|
||||||
|
"train_period": "2025-03-05 ~ 2025-06-05",
|
||||||
|
"test_period": "2025-06-05 ~ 2025-07-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 2,
|
||||||
|
"train_period": "2025-06-05 ~ 2025-09-05",
|
||||||
|
"test_period": "2025-09-05 ~ 2025-10-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 3,
|
||||||
|
"train_period": "2025-09-05 ~ 2025-12-05",
|
||||||
|
"test_period": "2025-12-05 ~ 2026-01-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"trades": [],
|
||||||
|
"validation": {
|
||||||
|
"overall": "PASS",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "trade_count",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "트레이드 없음 (검증 스킵)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
113
results/xrpusdt/wf_backtest_20260307_000339.json
Normal file
113
results/xrpusdt/wf_backtest_20260307_000339.json
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"mode": "walk_forward",
|
||||||
|
"config": {
|
||||||
|
"symbols": [
|
||||||
|
"XRPUSDT"
|
||||||
|
],
|
||||||
|
"start": null,
|
||||||
|
"end": null,
|
||||||
|
"initial_balance": 1000.0,
|
||||||
|
"leverage": 10,
|
||||||
|
"fee_pct": 0.04,
|
||||||
|
"slippage_pct": 0.01,
|
||||||
|
"use_ml": true,
|
||||||
|
"ml_threshold": 0.55,
|
||||||
|
"max_daily_loss_pct": 0.05,
|
||||||
|
"max_positions": 3,
|
||||||
|
"max_same_direction": 2,
|
||||||
|
"margin_max_ratio": 0.5,
|
||||||
|
"margin_min_ratio": 0.2,
|
||||||
|
"margin_decay_rate": 0.0006,
|
||||||
|
"atr_sl_mult": 2.0,
|
||||||
|
"atr_tp_mult": 2.0,
|
||||||
|
"min_notional": 5.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 25.0,
|
||||||
|
"volume_multiplier": 2.5,
|
||||||
|
"train_months": 3,
|
||||||
|
"test_months": 1,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"negative_ratio": 5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
},
|
||||||
|
"folds": [
|
||||||
|
{
|
||||||
|
"fold": 1,
|
||||||
|
"train_period": "2025-03-05 ~ 2025-06-05",
|
||||||
|
"test_period": "2025-06-05 ~ 2025-07-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 2,
|
||||||
|
"train_period": "2025-06-05 ~ 2025-09-05",
|
||||||
|
"test_period": "2025-09-05 ~ 2025-10-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 3,
|
||||||
|
"train_period": "2025-09-05 ~ 2025-12-05",
|
||||||
|
"test_period": "2025-12-05 ~ 2026-01-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"trades": [],
|
||||||
|
"validation": {
|
||||||
|
"overall": "PASS",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "trade_count",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "트레이드 없음 (검증 스킵)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
113
results/xrpusdt/wf_backtest_20260307_000404.json
Normal file
113
results/xrpusdt/wf_backtest_20260307_000404.json
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"mode": "walk_forward",
|
||||||
|
"config": {
|
||||||
|
"symbols": [
|
||||||
|
"XRPUSDT"
|
||||||
|
],
|
||||||
|
"start": null,
|
||||||
|
"end": null,
|
||||||
|
"initial_balance": 1000.0,
|
||||||
|
"leverage": 10,
|
||||||
|
"fee_pct": 0.04,
|
||||||
|
"slippage_pct": 0.01,
|
||||||
|
"use_ml": true,
|
||||||
|
"ml_threshold": 0.55,
|
||||||
|
"max_daily_loss_pct": 0.05,
|
||||||
|
"max_positions": 3,
|
||||||
|
"max_same_direction": 2,
|
||||||
|
"margin_max_ratio": 0.5,
|
||||||
|
"margin_min_ratio": 0.2,
|
||||||
|
"margin_decay_rate": 0.0006,
|
||||||
|
"atr_sl_mult": 2.0,
|
||||||
|
"atr_tp_mult": 2.0,
|
||||||
|
"min_notional": 5.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 25.0,
|
||||||
|
"volume_multiplier": 2.5,
|
||||||
|
"train_months": 3,
|
||||||
|
"test_months": 1,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"negative_ratio": 5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
},
|
||||||
|
"folds": [
|
||||||
|
{
|
||||||
|
"fold": 1,
|
||||||
|
"train_period": "2025-03-05 ~ 2025-06-05",
|
||||||
|
"test_period": "2025-06-05 ~ 2025-07-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 2,
|
||||||
|
"train_period": "2025-06-05 ~ 2025-09-05",
|
||||||
|
"test_period": "2025-09-05 ~ 2025-10-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 3,
|
||||||
|
"train_period": "2025-09-05 ~ 2025-12-05",
|
||||||
|
"test_period": "2025-12-05 ~ 2026-01-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"trades": [],
|
||||||
|
"validation": {
|
||||||
|
"overall": "PASS",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "trade_count",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "트레이드 없음 (검증 스킵)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
113
results/xrpusdt/wf_backtest_20260307_000511.json
Normal file
113
results/xrpusdt/wf_backtest_20260307_000511.json
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"mode": "walk_forward",
|
||||||
|
"config": {
|
||||||
|
"symbols": [
|
||||||
|
"XRPUSDT"
|
||||||
|
],
|
||||||
|
"start": null,
|
||||||
|
"end": null,
|
||||||
|
"initial_balance": 1000.0,
|
||||||
|
"leverage": 10,
|
||||||
|
"fee_pct": 0.04,
|
||||||
|
"slippage_pct": 0.01,
|
||||||
|
"use_ml": true,
|
||||||
|
"ml_threshold": 0.4,
|
||||||
|
"max_daily_loss_pct": 0.05,
|
||||||
|
"max_positions": 3,
|
||||||
|
"max_same_direction": 2,
|
||||||
|
"margin_max_ratio": 0.5,
|
||||||
|
"margin_min_ratio": 0.2,
|
||||||
|
"margin_decay_rate": 0.0006,
|
||||||
|
"atr_sl_mult": 2.0,
|
||||||
|
"atr_tp_mult": 2.0,
|
||||||
|
"min_notional": 5.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 25.0,
|
||||||
|
"volume_multiplier": 2.5,
|
||||||
|
"train_months": 3,
|
||||||
|
"test_months": 1,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"negative_ratio": 5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
},
|
||||||
|
"folds": [
|
||||||
|
{
|
||||||
|
"fold": 1,
|
||||||
|
"train_period": "2025-03-05 ~ 2025-06-05",
|
||||||
|
"test_period": "2025-06-05 ~ 2025-07-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 2,
|
||||||
|
"train_period": "2025-06-05 ~ 2025-09-05",
|
||||||
|
"test_period": "2025-09-05 ~ 2025-10-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 3,
|
||||||
|
"train_period": "2025-09-05 ~ 2025-12-05",
|
||||||
|
"test_period": "2025-12-05 ~ 2026-01-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"trades": [],
|
||||||
|
"validation": {
|
||||||
|
"overall": "PASS",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "trade_count",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "트레이드 없음 (검증 스킵)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
113
results/xrpusdt/wf_backtest_20260307_001101.json
Normal file
113
results/xrpusdt/wf_backtest_20260307_001101.json
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"mode": "walk_forward",
|
||||||
|
"config": {
|
||||||
|
"symbols": [
|
||||||
|
"XRPUSDT"
|
||||||
|
],
|
||||||
|
"start": null,
|
||||||
|
"end": null,
|
||||||
|
"initial_balance": 1000.0,
|
||||||
|
"leverage": 10,
|
||||||
|
"fee_pct": 0.04,
|
||||||
|
"slippage_pct": 0.01,
|
||||||
|
"use_ml": false,
|
||||||
|
"ml_threshold": 0.55,
|
||||||
|
"max_daily_loss_pct": 0.05,
|
||||||
|
"max_positions": 3,
|
||||||
|
"max_same_direction": 2,
|
||||||
|
"margin_max_ratio": 0.5,
|
||||||
|
"margin_min_ratio": 0.2,
|
||||||
|
"margin_decay_rate": 0.0006,
|
||||||
|
"atr_sl_mult": 2.0,
|
||||||
|
"atr_tp_mult": 2.0,
|
||||||
|
"min_notional": 5.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 25.0,
|
||||||
|
"volume_multiplier": 2.5,
|
||||||
|
"train_months": 3,
|
||||||
|
"test_months": 1,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"negative_ratio": 5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
},
|
||||||
|
"folds": [
|
||||||
|
{
|
||||||
|
"fold": 1,
|
||||||
|
"train_period": "2025-03-05 ~ 2025-06-05",
|
||||||
|
"test_period": "2025-06-05 ~ 2025-07-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 2,
|
||||||
|
"train_period": "2025-06-05 ~ 2025-09-05",
|
||||||
|
"test_period": "2025-09-05 ~ 2025-10-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 3,
|
||||||
|
"train_period": "2025-09-05 ~ 2025-12-05",
|
||||||
|
"test_period": "2025-12-05 ~ 2026-01-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"return_pct": 0.0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"avg_win": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": 0.0,
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 0.0,
|
||||||
|
"total_fees": 0.0,
|
||||||
|
"close_reasons": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"trades": [],
|
||||||
|
"validation": {
|
||||||
|
"overall": "PASS",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "trade_count",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "트레이드 없음 (검증 스킵)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
827
results/xrpusdt/wf_backtest_20260307_001513.json
Normal file
827
results/xrpusdt/wf_backtest_20260307_001513.json
Normal file
@@ -0,0 +1,827 @@
|
|||||||
|
{
|
||||||
|
"mode": "walk_forward",
|
||||||
|
"config": {
|
||||||
|
"symbols": [
|
||||||
|
"XRPUSDT"
|
||||||
|
],
|
||||||
|
"start": null,
|
||||||
|
"end": null,
|
||||||
|
"initial_balance": 1000.0,
|
||||||
|
"leverage": 10,
|
||||||
|
"fee_pct": 0.04,
|
||||||
|
"slippage_pct": 0.01,
|
||||||
|
"use_ml": false,
|
||||||
|
"ml_threshold": 0.55,
|
||||||
|
"max_daily_loss_pct": 0.05,
|
||||||
|
"max_positions": 3,
|
||||||
|
"max_same_direction": 2,
|
||||||
|
"margin_max_ratio": 0.5,
|
||||||
|
"margin_min_ratio": 0.2,
|
||||||
|
"margin_decay_rate": 0.0006,
|
||||||
|
"atr_sl_mult": 2.0,
|
||||||
|
"atr_tp_mult": 2.0,
|
||||||
|
"min_notional": 5.0,
|
||||||
|
"signal_threshold": 3,
|
||||||
|
"adx_threshold": 25.0,
|
||||||
|
"volume_multiplier": 2.5,
|
||||||
|
"train_months": 3,
|
||||||
|
"test_months": 1,
|
||||||
|
"time_weight_decay": 2.0,
|
||||||
|
"negative_ratio": 5
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 27,
|
||||||
|
"total_pnl": 217.0703,
|
||||||
|
"return_pct": 21.71,
|
||||||
|
"win_rate": 66.67,
|
||||||
|
"avg_win": 33.2223,
|
||||||
|
"avg_loss": -42.3256,
|
||||||
|
"profit_factor": 1.57,
|
||||||
|
"max_drawdown_pct": 11.99,
|
||||||
|
"sharpe_ratio": 33.32,
|
||||||
|
"total_fees": 102.7825,
|
||||||
|
"close_reasons": {
|
||||||
|
"STOP_LOSS": 9,
|
||||||
|
"TAKE_PROFIT": 18
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"folds": [
|
||||||
|
{
|
||||||
|
"fold": 1,
|
||||||
|
"train_period": "2025-03-05 ~ 2025-06-05",
|
||||||
|
"test_period": "2025-06-05 ~ 2025-07-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 9,
|
||||||
|
"total_pnl": -54.288,
|
||||||
|
"return_pct": -5.43,
|
||||||
|
"win_rate": 44.44,
|
||||||
|
"avg_win": 40.6662,
|
||||||
|
"avg_loss": -43.3906,
|
||||||
|
"profit_factor": 0.75,
|
||||||
|
"max_drawdown_pct": 11.99,
|
||||||
|
"sharpe_ratio": -21.46,
|
||||||
|
"total_fees": 32.8699,
|
||||||
|
"close_reasons": {
|
||||||
|
"STOP_LOSS": 5,
|
||||||
|
"TAKE_PROFIT": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 2,
|
||||||
|
"train_period": "2025-06-05 ~ 2025-09-05",
|
||||||
|
"test_period": "2025-09-05 ~ 2025-10-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 10,
|
||||||
|
"total_pnl": 13.0734,
|
||||||
|
"return_pct": 1.31,
|
||||||
|
"win_rate": 60.0,
|
||||||
|
"avg_win": 29.5084,
|
||||||
|
"avg_loss": -40.9943,
|
||||||
|
"profit_factor": 1.08,
|
||||||
|
"max_drawdown_pct": 10.97,
|
||||||
|
"sharpe_ratio": 5.62,
|
||||||
|
"total_fees": 39.3346,
|
||||||
|
"close_reasons": {
|
||||||
|
"TAKE_PROFIT": 6,
|
||||||
|
"STOP_LOSS": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 3,
|
||||||
|
"train_period": "2025-09-05 ~ 2025-12-05",
|
||||||
|
"test_period": "2025-12-05 ~ 2026-01-05",
|
||||||
|
"summary": {
|
||||||
|
"total_trades": 8,
|
||||||
|
"total_pnl": 258.2849,
|
||||||
|
"return_pct": 25.83,
|
||||||
|
"win_rate": 100.0,
|
||||||
|
"avg_win": 32.2856,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"profit_factor": "Infinity",
|
||||||
|
"max_drawdown_pct": 0.0,
|
||||||
|
"sharpe_ratio": 502.51,
|
||||||
|
"total_fees": 30.578,
|
||||||
|
"close_reasons": {
|
||||||
|
"TAKE_PROFIT": 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"trades": [
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-06-12 11:15:00",
|
||||||
|
"exit_time": "2025-06-12 13:00:00",
|
||||||
|
"entry_price": 2.223978,
|
||||||
|
"exit_price": 2.237955,
|
||||||
|
"quantity": 2248.0,
|
||||||
|
"sl": 2.237955,
|
||||||
|
"tp": 2.21,
|
||||||
|
"gross_pnl": -31.420573,
|
||||||
|
"entry_fee": 1.999801,
|
||||||
|
"exit_fee": 2.012369,
|
||||||
|
"net_pnl": -35.432743,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 26.97199737318929,
|
||||||
|
"macd_hist": -0.0007807103280135859,
|
||||||
|
"atr": 0.006988561711463904,
|
||||||
|
"adx": 43.4578914382015
|
||||||
|
},
|
||||||
|
"fold": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-06-12 20:30:00",
|
||||||
|
"exit_time": "2025-06-13 00:00:00",
|
||||||
|
"entry_price": 2.186419,
|
||||||
|
"exit_price": 2.163885,
|
||||||
|
"quantity": 2201.5,
|
||||||
|
"sl": 2.163885,
|
||||||
|
"tp": 2.208952,
|
||||||
|
"gross_pnl": -49.607437,
|
||||||
|
"entry_fee": 1.92536,
|
||||||
|
"exit_fee": 1.905517,
|
||||||
|
"net_pnl": -53.438315,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 26.41519543415668,
|
||||||
|
"macd_hist": -0.005495027011351469,
|
||||||
|
"atr": 0.011266735681191995,
|
||||||
|
"adx": 35.41443548413551
|
||||||
|
},
|
||||||
|
"fold": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-06-16 22:30:00",
|
||||||
|
"exit_time": "2025-06-16 23:45:00",
|
||||||
|
"entry_price": 2.260826,
|
||||||
|
"exit_price": 2.231631,
|
||||||
|
"quantity": 2006.6,
|
||||||
|
"sl": 2.231631,
|
||||||
|
"tp": 2.290021,
|
||||||
|
"gross_pnl": -58.582699,
|
||||||
|
"entry_fee": 1.814629,
|
||||||
|
"exit_fee": 1.791196,
|
||||||
|
"net_pnl": -62.188525,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 33.211506920436555,
|
||||||
|
"macd_hist": -0.007666291215691772,
|
||||||
|
"atr": 0.014597503086660083,
|
||||||
|
"adx": 41.77057022158849
|
||||||
|
},
|
||||||
|
"fold": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-06-17 15:00:00",
|
||||||
|
"exit_time": "2025-06-17 15:30:00",
|
||||||
|
"entry_price": 2.188781,
|
||||||
|
"exit_price": 2.164935,
|
||||||
|
"quantity": 1926.0,
|
||||||
|
"sl": 2.212627,
|
||||||
|
"tp": 2.164935,
|
||||||
|
"gross_pnl": 45.926824,
|
||||||
|
"entry_fee": 1.686237,
|
||||||
|
"exit_fee": 1.667866,
|
||||||
|
"net_pnl": 42.572721,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 35.98442517376965,
|
||||||
|
"macd_hist": -0.000473160901783975,
|
||||||
|
"atr": 0.011922851577807921,
|
||||||
|
"adx": 31.230008994240638
|
||||||
|
},
|
||||||
|
"fold": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-06-21 13:30:00",
|
||||||
|
"exit_time": "2025-06-21 15:15:00",
|
||||||
|
"entry_price": 2.119112,
|
||||||
|
"exit_price": 2.109684,
|
||||||
|
"quantity": 2086.2,
|
||||||
|
"sl": 2.109684,
|
||||||
|
"tp": 2.128539,
|
||||||
|
"gross_pnl": -19.667423,
|
||||||
|
"entry_fee": 1.768356,
|
||||||
|
"exit_fee": 1.76049,
|
||||||
|
"net_pnl": -23.196269,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 29.460371663394117,
|
||||||
|
"macd_hist": -0.002291006577745399,
|
||||||
|
"atr": 0.0047136955379463,
|
||||||
|
"adx": 26.139853452702802
|
||||||
|
},
|
||||||
|
"fold": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-06-21 21:15:00",
|
||||||
|
"exit_time": "2025-06-21 21:30:00",
|
||||||
|
"entry_price": 2.045995,
|
||||||
|
"exit_price": 2.018384,
|
||||||
|
"quantity": 2099.3,
|
||||||
|
"sl": 2.073607,
|
||||||
|
"tp": 2.018384,
|
||||||
|
"gross_pnl": 57.964484,
|
||||||
|
"entry_fee": 1.718063,
|
||||||
|
"exit_fee": 1.694877,
|
||||||
|
"net_pnl": 54.551543,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 24.112041978961905,
|
||||||
|
"macd_hist": -0.0015821538372272313,
|
||||||
|
"atr": 0.013805669484335523,
|
||||||
|
"adx": 47.020225174544926
|
||||||
|
},
|
||||||
|
"fold": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-06-24 05:30:00",
|
||||||
|
"exit_time": "2025-06-24 06:00:00",
|
||||||
|
"entry_price": 2.184818,
|
||||||
|
"exit_price": 2.207587,
|
||||||
|
"quantity": 2087.2,
|
||||||
|
"sl": 2.16205,
|
||||||
|
"tp": 2.207587,
|
||||||
|
"gross_pnl": 47.522393,
|
||||||
|
"entry_fee": 1.824061,
|
||||||
|
"exit_fee": 1.84307,
|
||||||
|
"net_pnl": 43.855261,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 73.02163697288638,
|
||||||
|
"macd_hist": 0.0005479493365071683,
|
||||||
|
"atr": 0.011384245075129916,
|
||||||
|
"adx": 47.36536786932758
|
||||||
|
},
|
||||||
|
"fold": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-07-01 13:00:00",
|
||||||
|
"exit_time": "2025-07-01 14:15:00",
|
||||||
|
"entry_price": 2.185781,
|
||||||
|
"exit_price": 2.203594,
|
||||||
|
"quantity": 2182.0,
|
||||||
|
"sl": 2.203594,
|
||||||
|
"tp": 2.167969,
|
||||||
|
"gross_pnl": -38.866074,
|
||||||
|
"entry_fee": 1.90775,
|
||||||
|
"exit_fee": 1.923296,
|
||||||
|
"net_pnl": -42.697121,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 34.25494593254047,
|
||||||
|
"macd_hist": -0.00014675405719808375,
|
||||||
|
"atr": 0.008906066514248343,
|
||||||
|
"adx": 38.40722178323835
|
||||||
|
},
|
||||||
|
"fold": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-07-04 06:00:00",
|
||||||
|
"exit_time": "2025-07-04 07:30:00",
|
||||||
|
"entry_price": 2.232877,
|
||||||
|
"exit_price": 2.220445,
|
||||||
|
"quantity": 2036.1,
|
||||||
|
"sl": 2.245309,
|
||||||
|
"tp": 2.220445,
|
||||||
|
"gross_pnl": 25.312421,
|
||||||
|
"entry_fee": 1.818544,
|
||||||
|
"exit_fee": 1.808419,
|
||||||
|
"net_pnl": 21.685458,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 31.442919224174045,
|
||||||
|
"macd_hist": -0.00029321477558042104,
|
||||||
|
"atr": 0.0062159081353788895,
|
||||||
|
"adx": 33.56850119916028
|
||||||
|
},
|
||||||
|
"fold": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-09-05 19:45:00",
|
||||||
|
"exit_time": "2025-09-05 21:00:00",
|
||||||
|
"entry_price": 2.863514,
|
||||||
|
"exit_price": 2.839468,
|
||||||
|
"quantity": 1745.9,
|
||||||
|
"sl": 2.887559,
|
||||||
|
"tp": 2.839468,
|
||||||
|
"gross_pnl": 41.980567,
|
||||||
|
"entry_fee": 1.999763,
|
||||||
|
"exit_fee": 1.982971,
|
||||||
|
"net_pnl": 37.997832,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 66.8667614741176,
|
||||||
|
"macd_hist": 0.005566660638547516,
|
||||||
|
"atr": 0.012022614930199287,
|
||||||
|
"adx": 25.794325095274626
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-09-07 07:30:00",
|
||||||
|
"exit_time": "2025-09-07 13:30:00",
|
||||||
|
"entry_price": 2.831483,
|
||||||
|
"exit_price": 2.839182,
|
||||||
|
"quantity": 1750.6,
|
||||||
|
"sl": 2.823784,
|
||||||
|
"tp": 2.839182,
|
||||||
|
"gross_pnl": 13.478415,
|
||||||
|
"entry_fee": 1.982718,
|
||||||
|
"exit_fee": 1.988109,
|
||||||
|
"net_pnl": 9.507588,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 70.72939012092385,
|
||||||
|
"macd_hist": 0.00013947818915852105,
|
||||||
|
"atr": 0.0038496557346203194,
|
||||||
|
"adx": 25.87514662158794
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-09-07 14:15:00",
|
||||||
|
"exit_time": "2025-09-07 17:45:00",
|
||||||
|
"entry_price": 2.888611,
|
||||||
|
"exit_price": 2.863658,
|
||||||
|
"quantity": 1711.8,
|
||||||
|
"sl": 2.913564,
|
||||||
|
"tp": 2.863658,
|
||||||
|
"gross_pnl": 42.714942,
|
||||||
|
"entry_fee": 1.97789,
|
||||||
|
"exit_fee": 1.960804,
|
||||||
|
"net_pnl": 38.776248,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 76.27147788789821,
|
||||||
|
"macd_hist": 0.006331113894477991,
|
||||||
|
"atr": 0.012476615713124774,
|
||||||
|
"adx": 29.135371839765913
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-09-09 07:00:00",
|
||||||
|
"exit_time": "2025-09-09 08:30:00",
|
||||||
|
"entry_price": 3.009099,
|
||||||
|
"exit_price": 3.032748,
|
||||||
|
"quantity": 1621.9,
|
||||||
|
"sl": 3.032748,
|
||||||
|
"tp": 2.98545,
|
||||||
|
"gross_pnl": -38.356788,
|
||||||
|
"entry_fee": 1.952183,
|
||||||
|
"exit_fee": 1.967526,
|
||||||
|
"net_pnl": -42.276497,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 73.38194905544675,
|
||||||
|
"macd_hist": 0.006160466326798137,
|
||||||
|
"atr": 0.011824646391069867,
|
||||||
|
"adx": 28.105801823891394
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-09-12 20:45:00",
|
||||||
|
"exit_time": "2025-09-12 21:30:00",
|
||||||
|
"entry_price": 3.121788,
|
||||||
|
"exit_price": 3.09291,
|
||||||
|
"quantity": 1587.4,
|
||||||
|
"sl": 3.150665,
|
||||||
|
"tp": 3.09291,
|
||||||
|
"gross_pnl": 45.839824,
|
||||||
|
"entry_fee": 1.98221,
|
||||||
|
"exit_fee": 1.963874,
|
||||||
|
"net_pnl": 41.893739,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 78.88995114530964,
|
||||||
|
"macd_hist": 0.004793783646225545,
|
||||||
|
"atr": 0.014438649390670705,
|
||||||
|
"adx": 33.848649235778474
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-09-14 11:15:00",
|
||||||
|
"exit_time": "2025-09-14 12:30:00",
|
||||||
|
"entry_price": 3.066993,
|
||||||
|
"exit_price": 3.050039,
|
||||||
|
"quantity": 1594.0,
|
||||||
|
"sl": 3.083947,
|
||||||
|
"tp": 3.050039,
|
||||||
|
"gross_pnl": 27.024629,
|
||||||
|
"entry_fee": 1.955515,
|
||||||
|
"exit_fee": 1.944705,
|
||||||
|
"net_pnl": 23.124409,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 31.419480021379645,
|
||||||
|
"macd_hist": -0.0003629091436070245,
|
||||||
|
"atr": 0.008476985221720718,
|
||||||
|
"adx": 31.882046477112183
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-09-20 14:00:00",
|
||||||
|
"exit_time": "2025-09-20 15:00:00",
|
||||||
|
"entry_price": 2.971603,
|
||||||
|
"exit_price": 2.984114,
|
||||||
|
"quantity": 1630.7,
|
||||||
|
"sl": 2.984114,
|
||||||
|
"tp": 2.959092,
|
||||||
|
"gross_pnl": -20.401941,
|
||||||
|
"entry_fee": 1.938317,
|
||||||
|
"exit_fee": 1.946478,
|
||||||
|
"net_pnl": -24.286736,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 36.09553473183674,
|
||||||
|
"macd_hist": -0.00018720159437711752,
|
||||||
|
"atr": 0.006255577606168083,
|
||||||
|
"adx": 28.84076206692945
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-09-24 19:45:00",
|
||||||
|
"exit_time": "2025-09-24 21:45:00",
|
||||||
|
"entry_price": 2.976198,
|
||||||
|
"exit_price": 2.948913,
|
||||||
|
"quantity": 1646.2,
|
||||||
|
"sl": 2.948913,
|
||||||
|
"tp": 3.003482,
|
||||||
|
"gross_pnl": -44.915196,
|
||||||
|
"entry_fee": 1.959767,
|
||||||
|
"exit_fee": 1.941801,
|
||||||
|
"net_pnl": -48.816763,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 77.25608496751632,
|
||||||
|
"macd_hist": 0.00019635721897620986,
|
||||||
|
"atr": 0.013642083686890932,
|
||||||
|
"adx": 66.36435142210216
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-09-25 12:15:00",
|
||||||
|
"exit_time": "2025-09-25 12:30:00",
|
||||||
|
"entry_price": 2.793221,
|
||||||
|
"exit_price": 2.81825,
|
||||||
|
"quantity": 1781.8,
|
||||||
|
"sl": 2.81825,
|
||||||
|
"tp": 2.768191,
|
||||||
|
"gross_pnl": -44.597807,
|
||||||
|
"entry_fee": 1.990784,
|
||||||
|
"exit_fee": 2.008623,
|
||||||
|
"net_pnl": -48.597214,
|
||||||
|
"close_reason": "STOP_LOSS",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 27.256149561284357,
|
||||||
|
"macd_hist": -0.0005773233003328118,
|
||||||
|
"atr": 0.012514818420656685,
|
||||||
|
"adx": 36.706433983392714
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-09-28 22:00:00",
|
||||||
|
"exit_time": "2025-09-29 02:15:00",
|
||||||
|
"entry_price": 2.850785,
|
||||||
|
"exit_price": 2.868214,
|
||||||
|
"quantity": 1700.7,
|
||||||
|
"sl": 2.833356,
|
||||||
|
"tp": 2.868214,
|
||||||
|
"gross_pnl": 29.641341,
|
||||||
|
"entry_fee": 1.939332,
|
||||||
|
"exit_fee": 1.951189,
|
||||||
|
"net_pnl": 25.750821,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 69.94607598871028,
|
||||||
|
"macd_hist": 0.0002172037763433672,
|
||||||
|
"atr": 0.008714453238038499,
|
||||||
|
"adx": 45.96715774504039
|
||||||
|
},
|
||||||
|
"fold": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-12-10 10:30:00",
|
||||||
|
"exit_time": "2025-12-10 14:30:00",
|
||||||
|
"entry_price": 2.069993,
|
||||||
|
"exit_price": 2.056326,
|
||||||
|
"quantity": 2415.2,
|
||||||
|
"sl": 2.08366,
|
||||||
|
"tp": 2.056326,
|
||||||
|
"gross_pnl": 33.009596,
|
||||||
|
"entry_fee": 1.999779,
|
||||||
|
"exit_fee": 1.986575,
|
||||||
|
"net_pnl": 29.023242,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 37.038012691554805,
|
||||||
|
"macd_hist": -9.211636302669133e-05,
|
||||||
|
"atr": 0.0068337189344636114,
|
||||||
|
"adx": 28.943927763667713
|
||||||
|
},
|
||||||
|
"fold": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-12-12 16:00:00",
|
||||||
|
"exit_time": "2025-12-12 18:30:00",
|
||||||
|
"entry_price": 1.988899,
|
||||||
|
"exit_price": 2.013001,
|
||||||
|
"quantity": 2498.4,
|
||||||
|
"sl": 1.964797,
|
||||||
|
"tp": 2.013001,
|
||||||
|
"gross_pnl": 60.216838,
|
||||||
|
"entry_fee": 1.987626,
|
||||||
|
"exit_fee": 2.011713,
|
||||||
|
"net_pnl": 56.2175,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 28.182013495720106,
|
||||||
|
"macd_hist": -0.00643391832048344,
|
||||||
|
"atr": 0.01205108033747697,
|
||||||
|
"adx": 30.769786891839054
|
||||||
|
},
|
||||||
|
"fold": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2025-12-19 13:15:00",
|
||||||
|
"exit_time": "2025-12-19 13:45:00",
|
||||||
|
"entry_price": 1.878712,
|
||||||
|
"exit_price": 1.863781,
|
||||||
|
"quantity": 2596.8,
|
||||||
|
"sl": 1.893643,
|
||||||
|
"tp": 1.863781,
|
||||||
|
"gross_pnl": 38.773597,
|
||||||
|
"entry_fee": 1.951456,
|
||||||
|
"exit_fee": 1.935946,
|
||||||
|
"net_pnl": 34.886195,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 68.16547032772114,
|
||||||
|
"macd_hist": -4.5929936914913816e-05,
|
||||||
|
"atr": 0.007465649526915487,
|
||||||
|
"adx": 40.69667585881617
|
||||||
|
},
|
||||||
|
"fold": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2025-12-25 23:30:00",
|
||||||
|
"exit_time": "2025-12-26 02:00:00",
|
||||||
|
"entry_price": 1.831783,
|
||||||
|
"exit_price": 1.844712,
|
||||||
|
"quantity": 2624.8,
|
||||||
|
"sl": 1.818854,
|
||||||
|
"tp": 1.844712,
|
||||||
|
"gross_pnl": 33.936201,
|
||||||
|
"entry_fee": 1.923226,
|
||||||
|
"exit_fee": 1.9368,
|
||||||
|
"net_pnl": 30.076175,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 18.688435627302994,
|
||||||
|
"macd_hist": -0.0034657628634239823,
|
||||||
|
"atr": 0.006464530874639477,
|
||||||
|
"adx": 30.228290924248867
|
||||||
|
},
|
||||||
|
"fold": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2026-01-01 15:45:00",
|
||||||
|
"exit_time": "2026-01-01 17:15:00",
|
||||||
|
"entry_price": 1.861986,
|
||||||
|
"exit_price": 1.870892,
|
||||||
|
"quantity": 2543.8,
|
||||||
|
"sl": 1.853081,
|
||||||
|
"tp": 1.870892,
|
||||||
|
"gross_pnl": 22.653457,
|
||||||
|
"entry_fee": 1.894608,
|
||||||
|
"exit_fee": 1.90367,
|
||||||
|
"net_pnl": 18.855179,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 64.9515011671277,
|
||||||
|
"macd_hist": 8.017825296896758e-05,
|
||||||
|
"atr": 0.004452680396625609,
|
||||||
|
"adx": 29.061543249865803
|
||||||
|
},
|
||||||
|
"fold": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2026-01-02 14:30:00",
|
||||||
|
"exit_time": "2026-01-02 14:45:00",
|
||||||
|
"entry_price": 1.906991,
|
||||||
|
"exit_price": 1.92129,
|
||||||
|
"quantity": 2458.8,
|
||||||
|
"sl": 1.892691,
|
||||||
|
"tp": 1.92129,
|
||||||
|
"gross_pnl": 35.159553,
|
||||||
|
"entry_fee": 1.875563,
|
||||||
|
"exit_fee": 1.889627,
|
||||||
|
"net_pnl": 31.394362,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 68.243618769103,
|
||||||
|
"macd_hist": 0.00021763021087121363,
|
||||||
|
"atr": 0.007149738287103824,
|
||||||
|
"adx": 34.75978288472445
|
||||||
|
},
|
||||||
|
"fold": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "SHORT",
|
||||||
|
"entry_time": "2026-01-04 00:45:00",
|
||||||
|
"exit_time": "2026-01-04 01:30:00",
|
||||||
|
"entry_price": 2.041396,
|
||||||
|
"exit_price": 2.026932,
|
||||||
|
"quantity": 2251.8,
|
||||||
|
"sl": 2.05586,
|
||||||
|
"tp": 2.026932,
|
||||||
|
"gross_pnl": 32.569888,
|
||||||
|
"entry_fee": 1.838726,
|
||||||
|
"exit_fee": 1.825698,
|
||||||
|
"net_pnl": 28.905464,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 78.42031027026397,
|
||||||
|
"macd_hist": 0.002773445174521872,
|
||||||
|
"atr": 0.007231967338050755,
|
||||||
|
"adx": 25.06146716762975
|
||||||
|
},
|
||||||
|
"fold": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "XRPUSDT",
|
||||||
|
"side": "LONG",
|
||||||
|
"entry_time": "2026-01-04 03:00:00",
|
||||||
|
"exit_time": "2026-01-04 03:45:00",
|
||||||
|
"entry_price": 2.038904,
|
||||||
|
"exit_price": 2.053633,
|
||||||
|
"quantity": 2209.5,
|
||||||
|
"sl": 2.024175,
|
||||||
|
"tp": 2.053633,
|
||||||
|
"gross_pnl": 32.54375,
|
||||||
|
"entry_fee": 1.801983,
|
||||||
|
"exit_fee": 1.815001,
|
||||||
|
"net_pnl": 28.926766,
|
||||||
|
"close_reason": "TAKE_PROFIT",
|
||||||
|
"ml_proba": null,
|
||||||
|
"indicators": {
|
||||||
|
"rsi": 66.00101725582081,
|
||||||
|
"macd_hist": 4.503977849404806e-05,
|
||||||
|
"atr": 0.007364505506906315,
|
||||||
|
"adx": 30.991168735172053
|
||||||
|
},
|
||||||
|
"fold": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"validation": {
|
||||||
|
"overall": "FAIL",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "exit_after_entry",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "모든 트레이드에서 청산 > 진입"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sl_tp_direction",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "SL/TP 방향 정합"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no_overlap",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "포지션 비중첩 확인"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "positive_fees",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "수수료 양수 확인"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no_negative_balance",
|
||||||
|
"passed": true,
|
||||||
|
"level": "FAIL",
|
||||||
|
"message": "잔고 양수 유지"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "win_rate_high",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "승률 정상 (66.7%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "win_rate_low",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "승률 정상 (66.7%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mdd_nonzero",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "MDD 정상 (12.0%)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trade_frequency",
|
||||||
|
"passed": false,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "월 평균 4.0건 < 5건 — 신호 생성 부족"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "profit_factor_high",
|
||||||
|
"passed": true,
|
||||||
|
"level": "WARNING",
|
||||||
|
"message": "PF 정상 (1.57)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
121
scripts/collect_ls_ratio.py
Normal file
121
scripts/collect_ls_ratio.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
Long/Short Ratio 장기 수집 스크립트.
|
||||||
|
15분마다 cron 실행하여 Binance Trading Data API에서
|
||||||
|
top_acct_ls_ratio, global_ls_ratio를 data/{symbol}/ls_ratio_15m.parquet에 누적한다.
|
||||||
|
|
||||||
|
수집 대상:
|
||||||
|
- topLongShortAccountRatio × 3심볼 (XRPUSDT, BTCUSDT, ETHUSDT)
|
||||||
|
- globalLongShortAccountRatio × 3심볼 (XRPUSDT, BTCUSDT, ETHUSDT)
|
||||||
|
→ 총 API 호출 6회/15분 (rate limit 무관)
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
python scripts/collect_ls_ratio.py
|
||||||
|
python scripts/collect_ls_ratio.py --symbols XRPUSDT BTCUSDT
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
BASE_URL = "https://fapi.binance.com"
|
||||||
|
DEFAULT_SYMBOLS = ["XRPUSDT", "BTCUSDT", "ETHUSDT"]
|
||||||
|
|
||||||
|
ENDPOINTS = {
|
||||||
|
"top_acct_ls_ratio": "/futures/data/topLongShortAccountRatio",
|
||||||
|
"global_ls_ratio": "/futures/data/globalLongShortAccountRatio",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_latest(session: aiohttp.ClientSession, symbol: str) -> dict | None:
|
||||||
|
"""심볼 하나에 대해 두 ratio의 최신 1건씩 가져온다."""
|
||||||
|
row = {"timestamp": None, "symbol": symbol}
|
||||||
|
|
||||||
|
for col_name, endpoint in ENDPOINTS.items():
|
||||||
|
url = f"{BASE_URL}{endpoint}"
|
||||||
|
params = {"symbol": symbol, "period": "15m", "limit": 1}
|
||||||
|
try:
|
||||||
|
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||||
|
data = await resp.json()
|
||||||
|
if isinstance(data, list) and data:
|
||||||
|
row[col_name] = float(data[0]["longShortRatio"])
|
||||||
|
# 타임스탬프는 첫 번째 응답에서 설정
|
||||||
|
if row["timestamp"] is None:
|
||||||
|
row["timestamp"] = pd.Timestamp(
|
||||||
|
int(data[0]["timestamp"]), unit="ms", tz="UTC"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f"[WARN] {symbol} {col_name}: unexpected response: {data}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] {symbol} {col_name}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def collect(symbols: list[str]):
|
||||||
|
"""모든 심볼 데이터를 수집하고 parquet에 추가한다."""
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
tasks = [fetch_latest(session, sym) for sym in symbols]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
collected = 0
|
||||||
|
|
||||||
|
for row in results:
|
||||||
|
if row is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
symbol = row["symbol"]
|
||||||
|
out_path = Path(f"data/{symbol.lower()}/ls_ratio_15m.parquet")
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
new_df = pd.DataFrame([{
|
||||||
|
"timestamp": row["timestamp"],
|
||||||
|
"top_acct_ls_ratio": row["top_acct_ls_ratio"],
|
||||||
|
"global_ls_ratio": row["global_ls_ratio"],
|
||||||
|
}])
|
||||||
|
|
||||||
|
if out_path.exists():
|
||||||
|
existing = pd.read_parquet(out_path)
|
||||||
|
# 중복 방지: 동일 timestamp가 이미 있으면 스킵
|
||||||
|
if row["timestamp"] in existing["timestamp"].values:
|
||||||
|
print(f"[SKIP] {symbol} ts={row['timestamp']} already exists")
|
||||||
|
continue
|
||||||
|
combined = pd.concat([existing, new_df], ignore_index=True)
|
||||||
|
else:
|
||||||
|
combined = new_df
|
||||||
|
|
||||||
|
combined.to_parquet(out_path, index=False)
|
||||||
|
collected += 1
|
||||||
|
print(
|
||||||
|
f"[{now.isoformat()}] {symbol}: "
|
||||||
|
f"top_acct={row['top_acct_ls_ratio']:.4f}, "
|
||||||
|
f"global={row['global_ls_ratio']:.4f} "
|
||||||
|
f"→ {out_path} ({len(combined)} rows)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if collected == 0:
|
||||||
|
print(f"[{now.isoformat()}] No new data collected")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="L/S Ratio 장기 수집")
|
||||||
|
parser.add_argument(
|
||||||
|
"--symbols", nargs="+", default=DEFAULT_SYMBOLS,
|
||||||
|
help="수집 대상 심볼 (기본: XRPUSDT BTCUSDT ETHUSDT)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
asyncio.run(collect(args.symbols))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
27
scripts/collect_ls_ratio_loop.sh
Executable file
27
scripts/collect_ls_ratio_loop.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# 15분 경계에 맞춰 collect_ls_ratio.py를 반복 실행한다.
|
||||||
|
# Docker 컨테이너 entrypoint용.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[collect_ls_ratio] Starting loop (interval: 15m)"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
# 현재 분/초를 기준으로 다음 15분 경계(00/15/30/45)까지 대기
|
||||||
|
now_min=$(date -u +%M | sed 's/^0//')
|
||||||
|
now_sec=$(date -u +%S | sed 's/^0//')
|
||||||
|
# 다음 15분 경계까지 남은 분
|
||||||
|
remainder=$((now_min % 15))
|
||||||
|
wait_min=$((15 - remainder))
|
||||||
|
# 초 단위로 변환 (경계 직후 10초 여유)
|
||||||
|
wait_sec=$(( wait_min * 60 - now_sec + 10 ))
|
||||||
|
if [ "$wait_sec" -le 10 ]; then
|
||||||
|
wait_sec=$((wait_sec + 900))
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[collect_ls_ratio] Next run in ${wait_sec}s ($(date -u))"
|
||||||
|
sleep "$wait_sec"
|
||||||
|
|
||||||
|
echo "[collect_ls_ratio] Running collection... ($(date -u))"
|
||||||
|
python scripts/collect_ls_ratio.py || echo "[collect_ls_ratio] ERROR: collection failed"
|
||||||
|
done
|
||||||
203
scripts/compare_symbols.py
Normal file
203
scripts/compare_symbols.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
종목 비교 백테스트: 후보 심볼별 파라미터 sweep → 최적 파라미터 기준 비교표 출력.
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
python scripts/compare_symbols.py --symbols SOLUSDT LINKUSDT AVAXUSDT
|
||||||
|
python scripts/compare_symbols.py --symbols SOLUSDT LINKUSDT AVAXUSDT --skip-fetch
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.backtester import WalkForwardBacktester, WalkForwardConfig
|
||||||
|
from scripts.strategy_sweep import generate_combinations, PARAM_GRID
|
||||||
|
|
||||||
|
|
||||||
|
TRAIN_MONTHS = 3
|
||||||
|
TEST_MONTHS = 1
|
||||||
|
FETCH_DAYS = 365
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_data(symbols: list[str], days: int = FETCH_DAYS) -> None:
|
||||||
|
script = str(Path(__file__).parent / "fetch_history.py")
|
||||||
|
for sym in symbols:
|
||||||
|
cmd = [
|
||||||
|
sys.executable, script,
|
||||||
|
"--symbol", sym,
|
||||||
|
"--interval", "15m",
|
||||||
|
"--days", str(days),
|
||||||
|
]
|
||||||
|
logger.info(f"데이터 수집: {sym} (최근 {days}일)")
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(f" {sym} 수집 실패: {result.stderr[:300]}")
|
||||||
|
else:
|
||||||
|
logger.info(f" {sym} 수집 완료")
|
||||||
|
|
||||||
|
|
||||||
|
def run_backtest(symbol: str, params: dict) -> dict:
|
||||||
|
cfg = WalkForwardConfig(
|
||||||
|
symbols=[symbol],
|
||||||
|
use_ml=False,
|
||||||
|
train_months=TRAIN_MONTHS,
|
||||||
|
test_months=TEST_MONTHS,
|
||||||
|
**params,
|
||||||
|
)
|
||||||
|
wf = WalkForwardBacktester(cfg)
|
||||||
|
return wf.run()
|
||||||
|
|
||||||
|
|
||||||
|
def sweep_symbol(symbol: str) -> dict:
|
||||||
|
"""심볼별 파라미터 sweep 실행 → 최적 조합 반환."""
|
||||||
|
combos = generate_combinations(PARAM_GRID)
|
||||||
|
logger.info(f"[{symbol}] 파라미터 sweep 시작: {len(combos)}개 조합")
|
||||||
|
|
||||||
|
best = None
|
||||||
|
best_params = None
|
||||||
|
|
||||||
|
for i, params in enumerate(combos):
|
||||||
|
try:
|
||||||
|
result = run_backtest(symbol, params)
|
||||||
|
summary = result["summary"]
|
||||||
|
|
||||||
|
# 거래 5건 미만은 스킵
|
||||||
|
if summary["total_trades"] < 5:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# PF 기준으로 최적 선택 (동률 시 승률 → 손익비 순)
|
||||||
|
if best is None or _is_better(summary, best):
|
||||||
|
best = summary
|
||||||
|
best_params = params
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f" [{symbol}] 조합 {i+1} 실패: {e}")
|
||||||
|
|
||||||
|
if (i + 1) % 50 == 0:
|
||||||
|
logger.info(f" [{symbol}] {i+1}/{len(combos)} 완료")
|
||||||
|
|
||||||
|
logger.info(f"[{symbol}] sweep 완료 → 최적 PF: {best['profit_factor'] if best else 'N/A'}")
|
||||||
|
return {"symbol": symbol, "best_params": best_params, "summary": best}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_better(new: dict, old: dict) -> bool:
|
||||||
|
"""PF → 손익비 → 승률 순으로 비교."""
|
||||||
|
new_pf = new["profit_factor"] if new["profit_factor"] != float("inf") else 999
|
||||||
|
old_pf = old["profit_factor"] if old["profit_factor"] != float("inf") else 999
|
||||||
|
|
||||||
|
if new_pf != old_pf:
|
||||||
|
return new_pf > old_pf
|
||||||
|
new_pr = new.get("payoff_ratio", 0) or 0
|
||||||
|
old_pr = old.get("payoff_ratio", 0) or 0
|
||||||
|
if new_pr != old_pr:
|
||||||
|
return new_pr > old_pr
|
||||||
|
return new["win_rate"] > old["win_rate"]
|
||||||
|
|
||||||
|
|
||||||
|
def print_comparison(results: list[dict]) -> None:
|
||||||
|
header = (
|
||||||
|
f"{'심볼':<10} {'파라미터':^30} {'거래수':>6} {'승률':>7} "
|
||||||
|
f"{'손익비':>7} {'연속손실':>8} {'PF':>6} {'수익률':>8} {'MDD':>6} {'총PnL':>10}"
|
||||||
|
)
|
||||||
|
sep = "=" * len(header)
|
||||||
|
print(f"\n{sep}")
|
||||||
|
print("종목 비교 백테스트 결과 (심볼별 최적 파라미터)")
|
||||||
|
print(sep)
|
||||||
|
print(header)
|
||||||
|
print("-" * len(header))
|
||||||
|
|
||||||
|
for r in results:
|
||||||
|
s = r["summary"]
|
||||||
|
p = r["best_params"]
|
||||||
|
if not s or not p:
|
||||||
|
print(f"{r['symbol'].replace('USDT', ''):<10} {'데이터 부족 또는 sweep 실패':^30}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
short = r["symbol"].replace("USDT", "")
|
||||||
|
param_str = f"SL={p['atr_sl_mult']}/TP={p['atr_tp_mult']}/ADX={p['adx_threshold']}"
|
||||||
|
pf = s["profit_factor"]
|
||||||
|
pf_str = f"{pf:.2f}" if pf != float("inf") else "INF"
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"{short:<10} {param_str:^30} {s['total_trades']:>6} "
|
||||||
|
f"{s['win_rate']:>6.1f}% "
|
||||||
|
f"{s.get('payoff_ratio', 0):>7.2f} "
|
||||||
|
f"{s.get('max_consecutive_losses', 0):>8} "
|
||||||
|
f"{pf_str:>6} "
|
||||||
|
f"{s['return_pct']:>7.2f}% "
|
||||||
|
f"{s['max_drawdown_pct']:>5.1f}% "
|
||||||
|
f"{s['total_pnl']:>+10.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("-" * len(header))
|
||||||
|
print("\n[판정 기준]")
|
||||||
|
print(" - 승률 50%+ & 손익비 1.0+ → 실전 지속 가능")
|
||||||
|
print(" - 연속 손실 5회 이하 → 멘탈 관리 가능")
|
||||||
|
print(" - 거래 20건+ → 통계적 유의성 있음")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 상세 파라미터 출력
|
||||||
|
print("[심볼별 최적 파라미터 상세]")
|
||||||
|
for r in results:
|
||||||
|
if r["best_params"]:
|
||||||
|
p = r["best_params"]
|
||||||
|
print(f" {r['symbol']}: SL={p['atr_sl_mult']}, TP={p['atr_tp_mult']}, "
|
||||||
|
f"Signal={p['signal_threshold']}, ADX={p['adx_threshold']}, "
|
||||||
|
f"Vol={p['volume_multiplier']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="종목 비교 백테스트 (심볼별 파라미터 sweep)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--symbols", nargs="+", required=True,
|
||||||
|
help="비교할 심볼 리스트 (e.g., SOLUSDT LINKUSDT AVAXUSDT)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--skip-fetch", action="store_true", help="데이터 수집 스킵")
|
||||||
|
parser.add_argument("--days", type=int, default=FETCH_DAYS, help="데이터 수집 기간 (일)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# 1) 데이터 수집
|
||||||
|
if not args.skip_fetch:
|
||||||
|
fetch_data(args.symbols, args.days)
|
||||||
|
|
||||||
|
# 2) 심볼별 sweep
|
||||||
|
results = []
|
||||||
|
for sym in args.symbols:
|
||||||
|
try:
|
||||||
|
result = sweep_symbol(sym)
|
||||||
|
results.append(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f" {sym} sweep 실패: {e}")
|
||||||
|
results.append({"symbol": sym, "best_params": None, "summary": None})
|
||||||
|
|
||||||
|
# 3) 비교표
|
||||||
|
if results:
|
||||||
|
print_comparison(results)
|
||||||
|
|
||||||
|
# 4) JSON 저장
|
||||||
|
out_dir = Path("results/compare")
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path = out_dir / f"compare_{date.today().isoformat()}.json"
|
||||||
|
with open(out_path, "w") as f:
|
||||||
|
json.dump(
|
||||||
|
[{
|
||||||
|
"symbol": r["symbol"],
|
||||||
|
"best_params": r["best_params"],
|
||||||
|
"summary": r["summary"],
|
||||||
|
} for r in results],
|
||||||
|
f, indent=2, ensure_ascii=False,
|
||||||
|
default=lambda x: str(x) if isinstance(x, float) and x == float("inf") else x,
|
||||||
|
)
|
||||||
|
logger.info(f"결과 저장: {out_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
175
scripts/evaluate_oos.py
Normal file
175
scripts/evaluate_oos.py
Normal 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()
|
||||||
@@ -28,6 +28,35 @@ load_dotenv()
|
|||||||
# 심볼 간 딜레이 없이 연속 요청하면 레이트 리밋(-1003) 발생
|
# 심볼 간 딜레이 없이 연속 요청하면 레이트 리밋(-1003) 발생
|
||||||
_REQUEST_DELAY = 0.3 # 초당 ~3.3 req → 안전 마진 충분
|
_REQUEST_DELAY = 0.3 # 초당 ~3.3 req → 안전 마진 충분
|
||||||
_FAPI_BASE = "https://fapi.binance.com"
|
_FAPI_BASE = "https://fapi.binance.com"
|
||||||
|
_MAX_RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_json_with_retry(
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
url: str,
|
||||||
|
params: dict,
|
||||||
|
symbol: str,
|
||||||
|
) -> list | dict | None:
|
||||||
|
"""aiohttp GET 요청 + exponential backoff retry (최대 3회)."""
|
||||||
|
for attempt in range(_MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
async with session.get(url, params=params) as resp:
|
||||||
|
if resp.status == 429:
|
||||||
|
wait = 2 ** (attempt + 1)
|
||||||
|
print(f" [{symbol}] Rate limit(429), {wait}초 후 재시도 ({attempt+1}/{_MAX_RETRIES})")
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
continue
|
||||||
|
resp.raise_for_status()
|
||||||
|
return await resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
if attempt < _MAX_RETRIES - 1:
|
||||||
|
wait = 2 ** (attempt + 1)
|
||||||
|
print(f" [{symbol}] API 오류 ({e}), {wait}초 후 재시도 ({attempt+1}/{_MAX_RETRIES})")
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
else:
|
||||||
|
print(f" [{symbol}] API {_MAX_RETRIES}회 실패: {e}")
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _now_ms() -> int:
|
def _now_ms() -> int:
|
||||||
@@ -44,12 +73,23 @@ async def _fetch_klines_with_client(
|
|||||||
start_ts = int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp() * 1000)
|
start_ts = int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp() * 1000)
|
||||||
all_klines = []
|
all_klines = []
|
||||||
while True:
|
while True:
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
klines = await client.futures_klines(
|
klines = await client.futures_klines(
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
interval=interval,
|
interval=interval,
|
||||||
startTime=start_ts,
|
startTime=start_ts,
|
||||||
limit=1500,
|
limit=1500,
|
||||||
)
|
)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
if attempt < 2:
|
||||||
|
wait = 2 ** (attempt + 1)
|
||||||
|
print(f" [{symbol}] API 오류 ({e}), {wait}초 후 재시도 ({attempt+1}/3)")
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
else:
|
||||||
|
print(f" [{symbol}] API 3회 실패, 수집 중단: {e}")
|
||||||
|
raise
|
||||||
if not klines:
|
if not klines:
|
||||||
break
|
break
|
||||||
all_klines.extend(klines)
|
all_klines.extend(klines)
|
||||||
@@ -137,8 +177,7 @@ async def _fetch_oi_hist(
|
|||||||
"limit": 500,
|
"limit": 500,
|
||||||
"startTime": start_ts,
|
"startTime": start_ts,
|
||||||
}
|
}
|
||||||
async with session.get(url, params=params) as resp:
|
data = await _get_json_with_retry(session, url, params, symbol)
|
||||||
data = await resp.json()
|
|
||||||
|
|
||||||
if not data or not isinstance(data, list):
|
if not data or not isinstance(data, list):
|
||||||
break
|
break
|
||||||
@@ -188,8 +227,7 @@ async def _fetch_funding_rate(
|
|||||||
"startTime": start_ts,
|
"startTime": start_ts,
|
||||||
"limit": 1000,
|
"limit": 1000,
|
||||||
}
|
}
|
||||||
async with session.get(url, params=params) as resp:
|
data = await _get_json_with_retry(session, url, params, symbol)
|
||||||
data = await resp.json()
|
|
||||||
|
|
||||||
if not data or not isinstance(data, list):
|
if not data or not isinstance(data, list):
|
||||||
break
|
break
|
||||||
@@ -311,6 +349,7 @@ def upsert_parquet(path: "Path | str", new_df: pd.DataFrame) -> pd.DataFrame:
|
|||||||
if col in existing.columns:
|
if col in existing.columns:
|
||||||
existing[col] = existing[col].fillna(0.0)
|
existing[col] = existing[col].fillna(0.0)
|
||||||
|
|
||||||
|
existing = existing[~existing.index.duplicated(keep='last')]
|
||||||
return existing.sort_index()
|
return existing.sort_index()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
342
scripts/mtf_backtest.py
Normal file
342
scripts/mtf_backtest.py
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
"""
|
||||||
|
MTF Pullback Backtest
|
||||||
|
─────────────────────
|
||||||
|
Trigger: 1h 추세 방향으로 15m 눌림목 진입
|
||||||
|
LONG: 1h Meta=LONG + 15m close < EMA20 + vol < SMA20*0.5 → 다음 봉 close > EMA20 시 진입
|
||||||
|
SHORT: 1h Meta=SHORT + 15m close > EMA20 + vol < SMA20*0.5 → 다음 봉 close < EMA20 시 진입
|
||||||
|
|
||||||
|
SL/TP: 1h ATR 기반 (진입 시점 직전 완성된 1h 캔들)
|
||||||
|
Look-ahead bias 방지: 1h 지표는 직전 완성 봉만 사용
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pandas_ta as ta
|
||||||
|
import numpy as np
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
# ─── 설정 ────────────────────────────────────────────────────────
|
||||||
|
SYMBOL = "xrpusdt"
|
||||||
|
DATA_PATH = Path(f"data/{SYMBOL}/combined_15m.parquet")
|
||||||
|
START = "2026-02-01"
|
||||||
|
END = "2026-03-30"
|
||||||
|
|
||||||
|
ATR_SL_MULT = 1.5
|
||||||
|
ATR_TP_MULT = 2.3
|
||||||
|
FEE_RATE = 0.0004 # 0.04% per side
|
||||||
|
|
||||||
|
# 1h 메타필터
|
||||||
|
MTF_EMA_FAST = 50
|
||||||
|
MTF_EMA_SLOW = 200
|
||||||
|
MTF_ADX_THRESHOLD = 20
|
||||||
|
|
||||||
|
# 15m Trigger
|
||||||
|
EMA_PULLBACK_LEN = 20
|
||||||
|
VOL_DRY_RATIO = 0.5 # volume < vol_ma20 * 0.5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Trade:
|
||||||
|
entry_time: pd.Timestamp
|
||||||
|
entry_price: float
|
||||||
|
side: str
|
||||||
|
sl: float
|
||||||
|
tp: float
|
||||||
|
exit_time: pd.Timestamp | None = None
|
||||||
|
exit_price: float | None = None
|
||||||
|
pnl_pct: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def build_1h_data(df_15m: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""15m → 1h 리샘플링 + EMA50, EMA200, ADX, ATR."""
|
||||||
|
df_1h = df_15m[["open", "high", "low", "close", "volume"]].resample("1h").agg(
|
||||||
|
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
|
||||||
|
).dropna()
|
||||||
|
|
||||||
|
df_1h["ema50_1h"] = ta.ema(df_1h["close"], length=MTF_EMA_FAST)
|
||||||
|
df_1h["ema200_1h"] = ta.ema(df_1h["close"], length=MTF_EMA_SLOW)
|
||||||
|
adx_df = ta.adx(df_1h["high"], df_1h["low"], df_1h["close"], length=14)
|
||||||
|
df_1h["adx_1h"] = adx_df["ADX_14"]
|
||||||
|
df_1h["atr_1h"] = ta.atr(df_1h["high"], df_1h["low"], df_1h["close"], length=14)
|
||||||
|
|
||||||
|
return df_1h[["ema50_1h", "ema200_1h", "adx_1h", "atr_1h"]]
|
||||||
|
|
||||||
|
|
||||||
|
def merge_1h_to_15m(df_15m: pd.DataFrame, df_1h: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""Look-ahead bias 방지: 1h 봉 완성 시점(+1h) 기준 backward merge."""
|
||||||
|
df_1h_shifted = df_1h.copy()
|
||||||
|
df_1h_shifted.index = df_1h_shifted.index + pd.Timedelta(hours=1)
|
||||||
|
|
||||||
|
df_15m_reset = df_15m.reset_index()
|
||||||
|
df_1h_reset = df_1h_shifted.reset_index()
|
||||||
|
df_1h_reset.rename(columns={"index": "timestamp"}, inplace=True)
|
||||||
|
if "timestamp" not in df_15m_reset.columns:
|
||||||
|
df_15m_reset.rename(columns={df_15m_reset.columns[0]: "timestamp"}, inplace=True)
|
||||||
|
|
||||||
|
df_15m_reset["timestamp"] = pd.to_datetime(df_15m_reset["timestamp"]).astype("datetime64[us]")
|
||||||
|
df_1h_reset["timestamp"] = pd.to_datetime(df_1h_reset["timestamp"]).astype("datetime64[us]")
|
||||||
|
|
||||||
|
merged = pd.merge_asof(
|
||||||
|
df_15m_reset.sort_values("timestamp"),
|
||||||
|
df_1h_reset.sort_values("timestamp"),
|
||||||
|
on="timestamp",
|
||||||
|
direction="backward",
|
||||||
|
)
|
||||||
|
return merged.set_index("timestamp")
|
||||||
|
|
||||||
|
|
||||||
|
def get_1h_meta(row) -> str:
|
||||||
|
"""1h 메타필터: EMA50/200 방향 + ADX > 20."""
|
||||||
|
ema50 = row.get("ema50_1h")
|
||||||
|
ema200 = row.get("ema200_1h")
|
||||||
|
adx = row.get("adx_1h")
|
||||||
|
|
||||||
|
if pd.isna(ema50) or pd.isna(ema200) or pd.isna(adx):
|
||||||
|
return "HOLD"
|
||||||
|
if adx < MTF_ADX_THRESHOLD:
|
||||||
|
return "HOLD"
|
||||||
|
if ema50 > ema200:
|
||||||
|
return "LONG"
|
||||||
|
elif ema50 < ema200:
|
||||||
|
return "SHORT"
|
||||||
|
return "HOLD"
|
||||||
|
|
||||||
|
|
||||||
|
def calc_metrics(trades: list[Trade]) -> dict:
|
||||||
|
if not trades:
|
||||||
|
return {"trades": 0, "win_rate": 0, "pf": 0, "pnl_bps": 0, "max_dd_bps": 0,
|
||||||
|
"avg_win_bps": 0, "avg_loss_bps": 0, "long_trades": 0, "short_trades": 0}
|
||||||
|
|
||||||
|
pnls = [t.pnl_pct for t in trades]
|
||||||
|
wins = [p for p in pnls if p > 0]
|
||||||
|
losses = [p for p in pnls if p <= 0]
|
||||||
|
|
||||||
|
gross_profit = sum(wins) if wins else 0
|
||||||
|
gross_loss = abs(sum(losses)) if losses else 0
|
||||||
|
pf = gross_profit / gross_loss if gross_loss > 0 else float("inf")
|
||||||
|
|
||||||
|
cumulative = np.cumsum(pnls)
|
||||||
|
peak = np.maximum.accumulate(cumulative)
|
||||||
|
dd = cumulative - peak
|
||||||
|
max_dd = abs(dd.min()) if len(dd) > 0 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"trades": len(trades),
|
||||||
|
"win_rate": len(wins) / len(trades) * 100,
|
||||||
|
"pf": round(pf, 2),
|
||||||
|
"pnl_bps": round(sum(pnls) * 10000, 1),
|
||||||
|
"max_dd_bps": round(max_dd * 10000, 1),
|
||||||
|
"avg_win_bps": round(np.mean(wins) * 10000, 1) if wins else 0,
|
||||||
|
"avg_loss_bps": round(np.mean(losses) * 10000, 1) if losses else 0,
|
||||||
|
"long_trades": sum(1 for t in trades if t.side == "LONG"),
|
||||||
|
"short_trades": sum(1 for t in trades if t.side == "SHORT"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 70)
|
||||||
|
print(" MTF Pullback Backtest")
|
||||||
|
print(f" {SYMBOL.upper()} | {START} ~ {END}")
|
||||||
|
print(f" SL: 1h ATR×{ATR_SL_MULT} | TP: 1h ATR×{ATR_TP_MULT} | Fee: {FEE_RATE*100:.2f}%/side")
|
||||||
|
print(f" Pullback: EMA{EMA_PULLBACK_LEN} | Vol dry: <{VOL_DRY_RATIO*100:.0f}% of SMA20")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# ── 데이터 로드 ──
|
||||||
|
df_raw = pd.read_parquet(DATA_PATH)
|
||||||
|
if df_raw.index.tz is not None:
|
||||||
|
df_raw.index = df_raw.index.tz_localize(None)
|
||||||
|
|
||||||
|
# 1h EMA200 워밍업 (200h = 800 bars)
|
||||||
|
warmup_start = pd.Timestamp(START) - pd.Timedelta(hours=250)
|
||||||
|
df_full = df_raw[df_raw.index >= warmup_start].copy()
|
||||||
|
print(f"\n데이터: {len(df_full)} bars (워밍업 포함)")
|
||||||
|
|
||||||
|
# ── 15m 지표: EMA20, vol_ma20 ──
|
||||||
|
df_full["ema20"] = ta.ema(df_full["close"], length=EMA_PULLBACK_LEN)
|
||||||
|
df_full["vol_ma20"] = ta.sma(df_full["volume"], length=20)
|
||||||
|
|
||||||
|
# ── 1h 지표 ──
|
||||||
|
df_1h = build_1h_data(df_full)
|
||||||
|
print(f"1h 캔들: {len(df_1h)} bars")
|
||||||
|
|
||||||
|
# ── 병합 ──
|
||||||
|
df_merged = merge_1h_to_15m(df_full, df_1h)
|
||||||
|
|
||||||
|
# ── 분석 기간 ──
|
||||||
|
df = df_merged[(df_merged.index >= START) & (df_merged.index <= END)].copy()
|
||||||
|
print(f"분석 기간: {len(df)} bars ({df.index.min()} ~ {df.index.max()})")
|
||||||
|
|
||||||
|
# ── 신호 스캔 & 시뮬레이션 ──
|
||||||
|
trades: list[Trade] = []
|
||||||
|
in_trade = False
|
||||||
|
current_trade: Trade | None = None
|
||||||
|
pullback_ready = False # 눌림 감지 상태
|
||||||
|
pullback_side = ""
|
||||||
|
|
||||||
|
# 디버그 카운터
|
||||||
|
meta_long_count = 0
|
||||||
|
meta_short_count = 0
|
||||||
|
pullback_detected = 0
|
||||||
|
entry_triggered = 0
|
||||||
|
|
||||||
|
for i in range(1, len(df)):
|
||||||
|
row = df.iloc[i]
|
||||||
|
prev = df.iloc[i - 1]
|
||||||
|
|
||||||
|
# ── 기존 포지션 SL/TP 체크 ──
|
||||||
|
if in_trade and current_trade is not None:
|
||||||
|
hit_sl = False
|
||||||
|
hit_tp = False
|
||||||
|
|
||||||
|
if current_trade.side == "LONG":
|
||||||
|
if row["low"] <= current_trade.sl:
|
||||||
|
hit_sl = True
|
||||||
|
if row["high"] >= current_trade.tp:
|
||||||
|
hit_tp = True
|
||||||
|
else:
|
||||||
|
if row["high"] >= current_trade.sl:
|
||||||
|
hit_sl = True
|
||||||
|
if row["low"] <= current_trade.tp:
|
||||||
|
hit_tp = True
|
||||||
|
|
||||||
|
if hit_sl or hit_tp:
|
||||||
|
exit_price = current_trade.sl if hit_sl else current_trade.tp
|
||||||
|
if hit_sl and hit_tp:
|
||||||
|
exit_price = current_trade.sl # 보수적
|
||||||
|
|
||||||
|
if current_trade.side == "LONG":
|
||||||
|
raw_pnl = (exit_price - current_trade.entry_price) / current_trade.entry_price
|
||||||
|
else:
|
||||||
|
raw_pnl = (current_trade.entry_price - exit_price) / current_trade.entry_price
|
||||||
|
|
||||||
|
current_trade.exit_time = df.index[i]
|
||||||
|
current_trade.exit_price = exit_price
|
||||||
|
current_trade.pnl_pct = raw_pnl - FEE_RATE * 2
|
||||||
|
trades.append(current_trade)
|
||||||
|
in_trade = False
|
||||||
|
current_trade = None
|
||||||
|
|
||||||
|
# ── 포지션 중이면 새 진입 스킵 ──
|
||||||
|
if in_trade:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# NaN 체크
|
||||||
|
if pd.isna(row.get("ema20")) or pd.isna(row.get("vol_ma20")) or pd.isna(row.get("atr_1h")):
|
||||||
|
pullback_ready = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── Step 1: 1h Meta Filter ──
|
||||||
|
meta = get_1h_meta(row)
|
||||||
|
if meta == "LONG":
|
||||||
|
meta_long_count += 1
|
||||||
|
elif meta == "SHORT":
|
||||||
|
meta_short_count += 1
|
||||||
|
|
||||||
|
if meta == "HOLD":
|
||||||
|
pullback_ready = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── Step 2: 눌림(Pullback) 감지 ──
|
||||||
|
# 이전 봉이 눌림 조건을 충족했는지 확인
|
||||||
|
if pullback_ready and pullback_side == meta:
|
||||||
|
# ── Step 4: 추세 재개 확인 (현재 봉 close 기준) ──
|
||||||
|
if pullback_side == "LONG" and row["close"] > row["ema20"]:
|
||||||
|
# 진입: 이 봉의 open (추세 재개 확인된 봉)
|
||||||
|
# 실제로는 close 시점에 확인하므로 다음 봉 open에 진입해야 look-ahead 방지
|
||||||
|
# 하지만 사양서에 "직후 캔들의 종가가 EMA20 상향 돌파한 첫 번째 캔들의 시가"라고 되어 있으므로
|
||||||
|
# → 이 봉(close > EMA20)의 open에서 진입은 look-ahead bias
|
||||||
|
# → 정확히는: prev가 pullback, 현재 봉 close > EMA20 확인 → 다음 봉 open 진입
|
||||||
|
# 여기서는 다음 봉 open으로 처리
|
||||||
|
if i + 1 < len(df):
|
||||||
|
next_row = df.iloc[i + 1]
|
||||||
|
entry_price = next_row["open"]
|
||||||
|
atr_1h = row["atr_1h"]
|
||||||
|
|
||||||
|
sl = entry_price - atr_1h * ATR_SL_MULT
|
||||||
|
tp = entry_price + atr_1h * ATR_TP_MULT
|
||||||
|
|
||||||
|
current_trade = Trade(
|
||||||
|
entry_time=df.index[i + 1],
|
||||||
|
entry_price=entry_price,
|
||||||
|
side="LONG",
|
||||||
|
sl=sl, tp=tp,
|
||||||
|
)
|
||||||
|
in_trade = True
|
||||||
|
pullback_ready = False
|
||||||
|
entry_triggered += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif pullback_side == "SHORT" and row["close"] < row["ema20"]:
|
||||||
|
if i + 1 < len(df):
|
||||||
|
next_row = df.iloc[i + 1]
|
||||||
|
entry_price = next_row["open"]
|
||||||
|
atr_1h = row["atr_1h"]
|
||||||
|
|
||||||
|
sl = entry_price + atr_1h * ATR_SL_MULT
|
||||||
|
tp = entry_price - atr_1h * ATR_TP_MULT
|
||||||
|
|
||||||
|
current_trade = Trade(
|
||||||
|
entry_time=df.index[i + 1],
|
||||||
|
entry_price=entry_price,
|
||||||
|
side="SHORT",
|
||||||
|
sl=sl, tp=tp,
|
||||||
|
)
|
||||||
|
in_trade = True
|
||||||
|
pullback_ready = False
|
||||||
|
entry_triggered += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── Step 2+3: 눌림 + 거래량 고갈 감지 (다음 봉에서 재개 확인) ──
|
||||||
|
vol_dry = row["volume"] < row["vol_ma20"] * VOL_DRY_RATIO
|
||||||
|
|
||||||
|
if meta == "LONG" and row["close"] < row["ema20"] and vol_dry:
|
||||||
|
pullback_ready = True
|
||||||
|
pullback_side = "LONG"
|
||||||
|
pullback_detected += 1
|
||||||
|
elif meta == "SHORT" and row["close"] > row["ema20"] and vol_dry:
|
||||||
|
pullback_ready = True
|
||||||
|
pullback_side = "SHORT"
|
||||||
|
pullback_detected += 1
|
||||||
|
else:
|
||||||
|
# 조건 불충족 시 pullback 상태 리셋
|
||||||
|
# 단, 연속 pullback 허용 (여러 봉 동안 눌림 지속 가능)
|
||||||
|
if not (meta == pullback_side):
|
||||||
|
pullback_ready = False
|
||||||
|
|
||||||
|
# ── 결과 출력 ──
|
||||||
|
m = calc_metrics(trades)
|
||||||
|
long_trades = [t for t in trades if t.side == "LONG"]
|
||||||
|
short_trades = [t for t in trades if t.side == "SHORT"]
|
||||||
|
lm = calc_metrics(long_trades)
|
||||||
|
sm = calc_metrics(short_trades)
|
||||||
|
|
||||||
|
print(f"\n─── 신호 파이프라인 ───")
|
||||||
|
print(f"1h Meta LONG: {meta_long_count} bars | SHORT: {meta_short_count} bars")
|
||||||
|
print(f"Pullback 감지: {pullback_detected}건")
|
||||||
|
print(f"진입 트리거: {entry_triggered}건")
|
||||||
|
print(f"실제 거래: {m['trades']}건 (L:{m['long_trades']} / S:{m['short_trades']})")
|
||||||
|
|
||||||
|
print(f"\n{'=' * 70}")
|
||||||
|
print(f" 결과")
|
||||||
|
print(f"{'=' * 70}")
|
||||||
|
|
||||||
|
header = f"{'구분':<10} {'Trades':>7} {'WinRate':>8} {'PF':>6} {'PnL(bps)':>10} {'MaxDD(bps)':>11} {'AvgWin':>8} {'AvgLoss':>8}"
|
||||||
|
print(header)
|
||||||
|
print("-" * len(header))
|
||||||
|
print(f"{'전체':<10} {m['trades']:>7} {m['win_rate']:>7.1f}% {m['pf']:>6.2f} {m['pnl_bps']:>10.1f} {m['max_dd_bps']:>11.1f} {m['avg_win_bps']:>8.1f} {m['avg_loss_bps']:>8.1f}")
|
||||||
|
print(f"{'LONG':<10} {lm['trades']:>7} {lm['win_rate']:>7.1f}% {lm['pf']:>6.2f} {lm['pnl_bps']:>10.1f} {lm['max_dd_bps']:>11.1f} {lm['avg_win_bps']:>8.1f} {lm['avg_loss_bps']:>8.1f}")
|
||||||
|
print(f"{'SHORT':<10} {sm['trades']:>7} {sm['win_rate']:>7.1f}% {sm['pf']:>6.2f} {sm['pnl_bps']:>10.1f} {sm['max_dd_bps']:>11.1f} {sm['avg_win_bps']:>8.1f} {sm['avg_loss_bps']:>8.1f}")
|
||||||
|
|
||||||
|
# 개별 거래 목록
|
||||||
|
if trades:
|
||||||
|
print(f"\n─── 개별 거래 ───")
|
||||||
|
print(f"{'#':>3} {'Side':<6} {'Entry Time':<20} {'Entry':>10} {'Exit':>10} {'PnL(bps)':>10} {'Result':>8}")
|
||||||
|
print("-" * 75)
|
||||||
|
for idx, t in enumerate(trades, 1):
|
||||||
|
result = "WIN" if t.pnl_pct > 0 else "LOSS"
|
||||||
|
pnl_bps = t.pnl_pct * 10000
|
||||||
|
print(f"{idx:>3} {t.side:<6} {str(t.entry_time):<20} {t.entry_price:>10.4f} {t.exit_price:>10.4f} {pnl_bps:>+10.1f} {result:>8}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
321
scripts/position_sizing_analysis.py
Normal file
321
scripts/position_sizing_analysis.py
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
포지션 사이징 분석: Robust Monte Carlo 방식.
|
||||||
|
|
||||||
|
핵심: 백테스트 31건의 승률/손익비를 고정값으로 믿지 않고,
|
||||||
|
불확실성 범위(승률 30~45%, 손익비 3.0~5.0)를 넣어
|
||||||
|
worst-case 조합에서도 파산하지 않는 리스크 비중을 산출한다.
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
python scripts/position_sizing_analysis.py --symbol SOLUSDT
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import numpy as np
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.backtester import WalkForwardBacktester, WalkForwardConfig
|
||||||
|
|
||||||
|
|
||||||
|
def run_backtest(symbol: str, params: dict) -> dict:
|
||||||
|
cfg = WalkForwardConfig(
|
||||||
|
symbols=[symbol],
|
||||||
|
use_ml=False,
|
||||||
|
train_months=3,
|
||||||
|
test_months=1,
|
||||||
|
**params,
|
||||||
|
)
|
||||||
|
wf = WalkForwardBacktester(cfg)
|
||||||
|
return wf.run()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_r_multiples(trades: list[dict]) -> np.ndarray:
|
||||||
|
"""각 트레이드의 R-multiple을 추출 (1R = SL 히트 시 손실)."""
|
||||||
|
r_multiples = []
|
||||||
|
for t in trades:
|
||||||
|
sl_distance = abs(t["entry_price"] - t["sl"])
|
||||||
|
sl_loss = sl_distance * t["quantity"]
|
||||||
|
if sl_loss <= 0:
|
||||||
|
continue
|
||||||
|
r_multiples.append(t["net_pnl"] / sl_loss)
|
||||||
|
return np.array(r_multiples)
|
||||||
|
|
||||||
|
|
||||||
|
def kelly_criterion(win_rate: float, avg_win_r: float, avg_loss_r: float) -> float:
|
||||||
|
"""Kelly: f* = (W * avg_win_R - (1-W) * |avg_loss_R|) / avg_win_R"""
|
||||||
|
if avg_win_r <= 0:
|
||||||
|
return 0.0
|
||||||
|
expectancy = win_rate * avg_win_r - (1 - win_rate) * abs(avg_loss_r)
|
||||||
|
if expectancy <= 0:
|
||||||
|
return 0.0
|
||||||
|
return expectancy / avg_win_r
|
||||||
|
|
||||||
|
|
||||||
|
def consecutive_loss_survival(risk_pct: float, n: int) -> float:
|
||||||
|
"""n연패 후 잔고 비율(%)."""
|
||||||
|
return (1 - risk_pct) ** n * 100
|
||||||
|
|
||||||
|
|
||||||
|
def robust_monte_carlo(
|
||||||
|
risk_pct: float,
|
||||||
|
win_rate_range: tuple[float, float],
|
||||||
|
payoff_range: tuple[float, float],
|
||||||
|
loss_r_range: tuple[float, float],
|
||||||
|
n_simulations: int = 10000,
|
||||||
|
n_trades: int = 200,
|
||||||
|
initial_balance: float = 1000.0,
|
||||||
|
ruin_threshold: float = 0.20,
|
||||||
|
) -> dict:
|
||||||
|
"""Robust Monte Carlo: 매 시뮬레이션마다 승률/손익비를 범위 내에서 샘플링.
|
||||||
|
|
||||||
|
각 시뮬레이션:
|
||||||
|
1) 승률을 win_rate_range에서 uniform 추출
|
||||||
|
2) 승리 R-multiple을 payoff_range에서 uniform 추출
|
||||||
|
3) 패배 R-multiple을 loss_r_range에서 uniform 추출
|
||||||
|
4) 해당 파라미터로 n_trades건을 생성하여 에퀴티 시뮬레이션
|
||||||
|
"""
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
final_balances = np.zeros(n_simulations)
|
||||||
|
max_drawdowns = np.zeros(n_simulations)
|
||||||
|
ruin_count = 0
|
||||||
|
|
||||||
|
for sim in range(n_simulations):
|
||||||
|
# 파라미터 샘플링
|
||||||
|
wr = rng.uniform(*win_rate_range)
|
||||||
|
win_r = rng.uniform(*payoff_range)
|
||||||
|
loss_r = rng.uniform(*loss_r_range)
|
||||||
|
|
||||||
|
# 트레이드 생성
|
||||||
|
outcomes = rng.random(n_trades)
|
||||||
|
r_multiples = np.where(outcomes < wr, win_r, loss_r)
|
||||||
|
|
||||||
|
# 에퀴티 시뮬레이션
|
||||||
|
balance = initial_balance
|
||||||
|
peak = balance
|
||||||
|
max_dd = 0.0
|
||||||
|
ruined = False
|
||||||
|
|
||||||
|
for r in r_multiples:
|
||||||
|
pnl = balance * risk_pct * r
|
||||||
|
balance += pnl
|
||||||
|
|
||||||
|
if balance <= initial_balance * ruin_threshold:
|
||||||
|
ruined = True
|
||||||
|
break
|
||||||
|
|
||||||
|
peak = max(peak, balance)
|
||||||
|
dd = (peak - balance) / peak
|
||||||
|
max_dd = max(max_dd, dd)
|
||||||
|
|
||||||
|
if ruined:
|
||||||
|
ruin_count += 1
|
||||||
|
final_balances[sim] = 0
|
||||||
|
max_drawdowns[sim] = 1.0
|
||||||
|
else:
|
||||||
|
final_balances[sim] = balance
|
||||||
|
max_drawdowns[sim] = max_dd
|
||||||
|
|
||||||
|
return {
|
||||||
|
"risk_pct": risk_pct,
|
||||||
|
"ruin_probability": round(ruin_count / n_simulations * 100, 2),
|
||||||
|
"median_return": round((np.median(final_balances) - initial_balance) / initial_balance * 100, 1),
|
||||||
|
"p5_return": round((np.percentile(final_balances, 5) - initial_balance) / initial_balance * 100, 1),
|
||||||
|
"p25_return": round((np.percentile(final_balances, 25) - initial_balance) / initial_balance * 100, 1),
|
||||||
|
"p75_return": round((np.percentile(final_balances, 75) - initial_balance) / initial_balance * 100, 1),
|
||||||
|
"p95_return": round((np.percentile(final_balances, 95) - initial_balance) / initial_balance * 100, 1),
|
||||||
|
"median_max_dd": round(np.median(max_drawdowns) * 100, 1),
|
||||||
|
"p95_max_dd": round(np.percentile(max_drawdowns, 95) * 100, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="포지션 사이징 분석 (Robust Monte Carlo)")
|
||||||
|
parser.add_argument("--symbol", required=True, type=str)
|
||||||
|
parser.add_argument("--sl-mult", type=float, default=1.0)
|
||||||
|
parser.add_argument("--tp-mult", type=float, default=4.0)
|
||||||
|
parser.add_argument("--signal-threshold", type=int, default=3)
|
||||||
|
parser.add_argument("--adx", type=float, default=20)
|
||||||
|
parser.add_argument("--vol-mult", type=float, default=2.5)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
symbol = args.symbol.upper()
|
||||||
|
params = {
|
||||||
|
"atr_sl_mult": args.sl_mult,
|
||||||
|
"atr_tp_mult": args.tp_mult,
|
||||||
|
"signal_threshold": args.signal_threshold,
|
||||||
|
"adx_threshold": args.adx,
|
||||||
|
"volume_multiplier": args.vol_mult,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1) 백테스트로 기준값 추출
|
||||||
|
logger.info(f"[{symbol}] 백테스트 실행")
|
||||||
|
result = run_backtest(symbol, params)
|
||||||
|
trades = result.get("trades", [])
|
||||||
|
summary = result["summary"]
|
||||||
|
|
||||||
|
if len(trades) < 5:
|
||||||
|
logger.error(f"트레이드 {len(trades)}건 — 분석 불가")
|
||||||
|
return
|
||||||
|
|
||||||
|
r_multiples = extract_r_multiples(trades)
|
||||||
|
wins = r_multiples[r_multiples > 0]
|
||||||
|
losses = r_multiples[r_multiples <= 0]
|
||||||
|
obs_wr = len(wins) / len(r_multiples)
|
||||||
|
obs_win_r = float(np.mean(wins)) if len(wins) > 0 else 0
|
||||||
|
obs_loss_r = float(np.mean(losses)) if len(losses) > 0 else 0
|
||||||
|
obs_expectancy = obs_wr * obs_win_r + (1 - obs_wr) * obs_loss_r
|
||||||
|
obs_kelly = kelly_criterion(obs_wr, obs_win_r, abs(obs_loss_r))
|
||||||
|
|
||||||
|
# 불확실성 범위 설정 (관측값 기준 ±보정)
|
||||||
|
# 승률: 관측 38.7% → 30~45% (하방으로 더 넓게)
|
||||||
|
wr_lo = max(0.25, obs_wr - 0.10)
|
||||||
|
wr_hi = min(0.55, obs_wr + 0.07)
|
||||||
|
# 승리 R: 관측 3.85R → 3.0~5.0
|
||||||
|
win_r_lo = max(2.0, obs_win_r - 1.0)
|
||||||
|
win_r_hi = obs_win_r + 1.2
|
||||||
|
# 패배 R: 관측 -1.18R → -1.5 ~ -0.9
|
||||||
|
loss_r_lo = min(-0.8, obs_loss_r + 0.3)
|
||||||
|
loss_r_hi = obs_loss_r - 0.3
|
||||||
|
|
||||||
|
print(f"\n{'=' * 85}")
|
||||||
|
print(f" 포지션 사이징 분석: {symbol} (Robust Monte Carlo)")
|
||||||
|
print(f" 파라미터: SL={args.sl_mult}x ATR, TP={args.tp_mult}x ATR, ADX={args.adx}")
|
||||||
|
print(f"{'=' * 85}")
|
||||||
|
|
||||||
|
# 관측값
|
||||||
|
print(f"\n[백테스트 관측값] ({len(trades)}건)")
|
||||||
|
print(f" 승률: {obs_wr*100:.1f}% | 승리 평균: +{obs_win_r:.2f}R | 패배 평균: {obs_loss_r:.2f}R")
|
||||||
|
print(f" 기대값: {obs_expectancy:.2f}R | Kelly: {obs_kelly*100:.1f}%")
|
||||||
|
print(f" 최대 연속 손실: {summary.get('max_consecutive_losses', 'N/A')}회")
|
||||||
|
|
||||||
|
# R-multiple 분포 (간략)
|
||||||
|
print(f"\n R 분포: 패배 {obs_loss_r:.2f}R (SL+수수료) | 승리 +{obs_win_r:.2f}R (TP-수수료)")
|
||||||
|
print(f" → 거의 바이너리: SL 아니면 TP, 중간 청산 없음")
|
||||||
|
|
||||||
|
# 불확실성 범위
|
||||||
|
print(f"\n[불확실성 범위] (실전 괴리 반영)")
|
||||||
|
print(f" 승률: {wr_lo*100:.0f}% ~ {wr_hi*100:.0f}% (관측: {obs_wr*100:.1f}%)")
|
||||||
|
print(f" 승리 R: +{win_r_lo:.1f}R ~ +{win_r_hi:.1f}R (관측: +{obs_win_r:.2f}R)")
|
||||||
|
print(f" 패배 R: {loss_r_hi:.1f}R ~ {loss_r_lo:.1f}R (관측: {obs_loss_r:.2f}R)")
|
||||||
|
|
||||||
|
# Worst-case Kelly
|
||||||
|
worst_kelly = kelly_criterion(wr_lo, win_r_lo, abs(loss_r_hi))
|
||||||
|
best_kelly = kelly_criterion(wr_hi, win_r_hi, abs(loss_r_lo))
|
||||||
|
print(f"\n Worst-case Kelly: {worst_kelly*100:.1f}% | Best-case Kelly: {best_kelly*100:.1f}%")
|
||||||
|
|
||||||
|
# 연속 손실 생존 테이블
|
||||||
|
print(f"\n[연속 손실 생존 테이블]")
|
||||||
|
print(f" {'리스크%':>8} {'4연패':>7} {'6연패':>7} {'8연패':>7} {'10연패':>7} {'12연패':>7}")
|
||||||
|
print(f" {'-' * 50}")
|
||||||
|
for rp in [0.005, 0.01, 0.015, 0.02, 0.025, 0.03, 0.04, 0.05]:
|
||||||
|
cols = [f"{consecutive_loss_survival(rp, n):.1f}%" for n in [4, 6, 8, 10, 12]]
|
||||||
|
print(f" {rp*100:>7.1f}% {' '.join(f'{c:>7}' for c in cols)}")
|
||||||
|
|
||||||
|
# Robust Monte Carlo
|
||||||
|
risk_levels = [0.005, 0.01, 0.015, 0.02, 0.025, 0.03, 0.04, 0.05, 0.07]
|
||||||
|
|
||||||
|
print(f"\n[Robust Monte Carlo (10,000회 × 200건, 파라미터 매회 랜덤 샘플링)]")
|
||||||
|
print(f" 파산 기준: 잔고 ≤ 20%")
|
||||||
|
print(f" {'리스크%':>8} {'파산%':>6} {'하위5%':>9} {'하위25%':>9} {'중위':>9} "
|
||||||
|
f"{'상위75%':>9} {'상위95%':>10} {'중위MDD':>7} {'95%MDD':>7}")
|
||||||
|
print(f" {'-' * 85}")
|
||||||
|
|
||||||
|
best_risk = None
|
||||||
|
best_score = -999
|
||||||
|
mc_results = []
|
||||||
|
|
||||||
|
for rp in risk_levels:
|
||||||
|
mc = robust_monte_carlo(
|
||||||
|
risk_pct=rp,
|
||||||
|
win_rate_range=(wr_lo, wr_hi),
|
||||||
|
payoff_range=(win_r_lo, win_r_hi),
|
||||||
|
loss_r_range=(loss_r_hi, loss_r_lo), # hi is more negative
|
||||||
|
)
|
||||||
|
mc_results.append(mc)
|
||||||
|
|
||||||
|
# 숫자 포맷
|
||||||
|
def fmt_ret(v):
|
||||||
|
if abs(v) >= 10000:
|
||||||
|
return f"{v/1000:>+7.0f}k%"
|
||||||
|
return f"{v:>+8.1f}%"
|
||||||
|
|
||||||
|
print(f" {rp*100:>7.1f}% {mc['ruin_probability']:>5.1f}% "
|
||||||
|
f"{fmt_ret(mc['p5_return'])} {fmt_ret(mc['p25_return'])} "
|
||||||
|
f"{fmt_ret(mc['median_return'])} {fmt_ret(mc['p75_return'])} "
|
||||||
|
f"{fmt_ret(mc['p95_return']):>10} {mc['median_max_dd']:>6.1f}% "
|
||||||
|
f"{mc['p95_max_dd']:>6.1f}%")
|
||||||
|
|
||||||
|
# 선정 기준: 파산 <1% AND 95%MDD ≤ 30% 에서 중위 수익 최대
|
||||||
|
if (mc["ruin_probability"] <= 1.0
|
||||||
|
and mc["p95_max_dd"] <= 30.0
|
||||||
|
and mc["median_return"] > best_score):
|
||||||
|
best_score = mc["median_return"]
|
||||||
|
best_risk = rp
|
||||||
|
|
||||||
|
# Worst-case 전용 MC (승률 30%, 손익비 3.0 고정)
|
||||||
|
print(f"\n[Worst-Case 시나리오 (승률={wr_lo*100:.0f}%, 승리R=+{win_r_lo:.1f}, 패배R={loss_r_hi:.1f})]")
|
||||||
|
print(f" {'리스크%':>8} {'파산%':>6} {'중위수익':>9} {'95%MDD':>7}")
|
||||||
|
print(f" {'-' * 35}")
|
||||||
|
|
||||||
|
worst_best_risk = None
|
||||||
|
worst_best_score = -999
|
||||||
|
|
||||||
|
for rp in risk_levels:
|
||||||
|
mc = robust_monte_carlo(
|
||||||
|
risk_pct=rp,
|
||||||
|
win_rate_range=(wr_lo, wr_lo + 0.001), # 거의 고정
|
||||||
|
payoff_range=(win_r_lo, win_r_lo + 0.001),
|
||||||
|
loss_r_range=(loss_r_hi, loss_r_hi + 0.001),
|
||||||
|
)
|
||||||
|
|
||||||
|
def fmt_ret(v):
|
||||||
|
if abs(v) >= 10000:
|
||||||
|
return f"{v/1000:>+7.0f}k%"
|
||||||
|
return f"{v:>+8.1f}%"
|
||||||
|
|
||||||
|
print(f" {rp*100:>7.1f}% {mc['ruin_probability']:>5.1f}% "
|
||||||
|
f"{fmt_ret(mc['median_return'])} {mc['p95_max_dd']:>6.1f}%")
|
||||||
|
|
||||||
|
if (mc["ruin_probability"] <= 1.0
|
||||||
|
and mc["p95_max_dd"] <= 30.0
|
||||||
|
and mc["median_return"] > worst_best_score):
|
||||||
|
worst_best_score = mc["median_return"]
|
||||||
|
worst_best_risk = rp
|
||||||
|
|
||||||
|
# 최종 권장
|
||||||
|
print(f"\n{'=' * 85}")
|
||||||
|
print(f" 최종 권장")
|
||||||
|
print(f"{'=' * 85}")
|
||||||
|
|
||||||
|
# 가장 보수적인 값: worst-case MC 최적과 robust MC 최적 중 작은 값
|
||||||
|
candidates = [r for r in [best_risk, worst_best_risk, worst_kelly / 2] if r and r > 0]
|
||||||
|
recommended = min(candidates) if candidates else 0.01
|
||||||
|
recommended = max(0.005, min(recommended, 0.05))
|
||||||
|
|
||||||
|
print(f" Robust MC 최적 (파산<1%, 95%MDD≤30%): {best_risk*100:.1f}%" if best_risk else " Robust MC: 조건 충족 없음")
|
||||||
|
print(f" Worst-Case MC 최적: {worst_best_risk*100:.1f}%" if worst_best_risk else " Worst-Case MC: 조건 충족 없음")
|
||||||
|
print(f" Worst-Case Half Kelly: {worst_kelly/2*100:.1f}%")
|
||||||
|
|
||||||
|
print(f"\n >>> 실전 권장: 1회 리스크 = 계좌의 {recommended*100:.1f}%")
|
||||||
|
print(f" 근거: worst-case에서도 파산하지 않는 가장 보수적 기준")
|
||||||
|
survival_6 = consecutive_loss_survival(recommended, 6)
|
||||||
|
survival_10 = consecutive_loss_survival(recommended, 10)
|
||||||
|
print(f" 6연패 후 잔고: {survival_6:.1f}% | 10연패 후: {survival_10:.1f}%")
|
||||||
|
|
||||||
|
print(f"\n [.env 설정 가이드]")
|
||||||
|
print(f" ATR_SL_MULT_SOLUSDT={args.sl_mult}")
|
||||||
|
print(f" ATR_TP_MULT_SOLUSDT={args.tp_mult}")
|
||||||
|
print(f" ADX_THRESHOLD_SOLUSDT={args.adx}")
|
||||||
|
print(f" SIGNAL_THRESHOLD_SOLUSDT={args.signal_threshold}")
|
||||||
|
for atr_pct in [0.01, 0.012, 0.015]:
|
||||||
|
margin_ratio = recommended / (10 * atr_pct)
|
||||||
|
margin_ratio = min(margin_ratio, 0.50)
|
||||||
|
print(f" ATR≈{atr_pct*100:.1f}% → MARGIN_MAX_RATIO_SOLUSDT ≈ {margin_ratio:.2f}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user