Compare commits
104 Commits
9f4c22b5e6
...
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 | ||
|
|
67692b3ebd | ||
|
|
02e41881ac | ||
|
|
15fb9c158a | ||
|
|
2b3f39b5d1 | ||
| d92fae13f8 | |||
|
|
dfcd803db5 | ||
|
|
852d3a8265 | ||
|
|
9ac839fd83 | ||
|
|
b03182691e | ||
|
|
2bb2bf2896 | ||
|
|
909d6af944 |
16
.env.example
16
.env.example
@@ -2,10 +2,22 @@ BINANCE_API_KEY=
|
||||
BINANCE_API_SECRET=
|
||||
SYMBOLS=XRPUSDT
|
||||
CORRELATION_SYMBOLS=BTCUSDT,ETHUSDT
|
||||
LEVERAGE=10
|
||||
RISK_PER_TRADE=0.02
|
||||
LEVERAGE=20
|
||||
DISCORD_WEBHOOK_URL=
|
||||
ML_THRESHOLD=0.55
|
||||
NO_ML_FILTER=true
|
||||
MAX_SAME_DIRECTION=2
|
||||
# Global defaults (fallback when no per-symbol override)
|
||||
ATR_SL_MULT=2.0
|
||||
ATR_TP_MULT=2.0
|
||||
SIGNAL_THRESHOLD=3
|
||||
ADX_THRESHOLD=25
|
||||
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_SECRET=
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -15,3 +15,6 @@ data/*.parquet
|
||||
.cursor/
|
||||
|
||||
.worktrees/
|
||||
.venv
|
||||
dashboard/ui/node_modules/
|
||||
dashboard/ui/dist/
|
||||
632
ARCHITECTURE.md
632
ARCHITECTURE.md
@@ -1,25 +1,72 @@
|
||||
# CoinTrader — 아키텍처 문서
|
||||
|
||||
> 이 문서는 CoinTrader 코드베이스를 처음 접하는 개발자와 트레이딩 배경 독자 모두를 위해 작성되었습니다.
|
||||
> 기술 스택, 레이어별 역할, MLOps 파이프라인, 핵심 동작 시나리오를 순서대로 설명합니다.
|
||||
> 이 문서는 CoinTrader의 내부 구조를 설명합니다.
|
||||
> **봇 사용법**은 [README.md](README.md)를 참고하세요.
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [시스템 오버뷰](#1-시스템-오버뷰)
|
||||
2. [코어 레이어 아키텍처](#2-코어-레이어-아키텍처)
|
||||
3. [MLOps 파이프라인 — 자가 진화 시스템](#3-mlops-파이프라인--자가-진화-시스템)
|
||||
4. [핵심 동작 시나리오](#4-핵심-동작-시나리오)
|
||||
5. [테스트 커버리지](#5-테스트-커버리지)
|
||||
1. [시스템 개요](#1-시스템-개요) — 봇이 무엇을 하는지, 어떤 구조인지
|
||||
2. [매매 판단 과정](#2-매매-판단-과정) — 15분마다 어떤 과정을 거쳐 매매하는지
|
||||
3. [5개 레이어 상세](#3-5개-레이어-상세) — 각 레이어의 역할과 동작 원리
|
||||
4. [MLOps 파이프라인](#4-mlops-파이프라인) — ML 모델의 학습·배포·모니터링 전체 흐름
|
||||
5. [핵심 동작 시나리오](#5-핵심-동작-시나리오) — 실제 상황별 봇의 동작 흐름도
|
||||
5-1. [MTF Pullback Bot](#5-1-mtf-pullback-bot) — 멀티타임프레임 풀백 전략 Dry-run 봇
|
||||
6. [테스트 커버리지](#6-테스트-커버리지) — 무엇을 어떻게 테스트하는지
|
||||
7. [파일 구조](#7-파일-구조) — 전체 파일 역할 요약
|
||||
|
||||
---
|
||||
|
||||
## 1. 시스템 오버뷰
|
||||
## 1. 시스템 개요
|
||||
|
||||
CoinTrader는 **Binance Futures 자동매매 봇**입니다. 기술 지표 신호를 1차 필터로, LightGBM(또는 MLX 신경망) 모델을 2차 필터로 사용하여 XRPUSDT 선물 포지션을 자동 진입·청산합니다.
|
||||
CoinTrader는 **Binance Futures 자동매매 봇**입니다.
|
||||
|
||||
### 전체 데이터 파이프라인 흐름도
|
||||
**한 줄 요약**: 15분마다 기술 지표로 매매 신호를 생성하고, ML 모델로 한 번 더 검증한 뒤, 조건을 충족하면 자동으로 주문을 넣습니다.
|
||||
|
||||
### 1.1 전체 흐름 (간략)
|
||||
|
||||
```
|
||||
15분봉 마감 → 기술 지표 계산 → 매매 신호 생성 → ML 필터 검증 → 리스크 체크 → 주문 실행 → Discord 알림
|
||||
```
|
||||
|
||||
### 1.2 멀티심볼 아키텍처
|
||||
|
||||
여러 심볼을 동시에 거래합니다. 각 심볼은 독립된 봇 인스턴스로 실행되며, 리스크 관리만 공유합니다.
|
||||
|
||||
```
|
||||
main.py
|
||||
└─ Config (SYMBOLS=XRPUSDT) # 멀티심볼 지원, 현재 XRP만 운영
|
||||
└─ RiskManager (공유 싱글턴, asyncio.Lock)
|
||||
└─ asyncio.gather(
|
||||
TradingBot(symbol="XRPUSDT", risk=shared_risk),
|
||||
)
|
||||
```
|
||||
|
||||
> **운영 이력**: 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
|
||||
flowchart TD
|
||||
@@ -30,23 +77,23 @@ flowchart TD
|
||||
end
|
||||
|
||||
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"]
|
||||
MF["ml_features.py<br/>23개 피처 추출<br/>(XRP 13 + BTC/ETH 8 + OI/FR 2)"]
|
||||
ML["ml_filter.py<br/>MLFilter<br/>ONNX 우선 / LightGBM 폴백<br/>확률 ≥ 0.60 시 진입 허용"]
|
||||
RM["risk_manager.py<br/>RiskManager<br/>일일 손실 5% 한도<br/>동적 증거금 비율"]
|
||||
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.55 시 진입 허용"]
|
||||
RM["risk_manager.py<br/>RiskManager (공유 싱글턴)<br/>일일 손실 5% 한도<br/>동적 증거금 비율<br/>동일 방향 제한"]
|
||||
EX["exchange.py<br/>BinanceFuturesClient<br/>주문·레버리지·잔고 API"]
|
||||
UDS["user_data_stream.py<br/>UserDataStream<br/>TP/SL 즉시 감지"]
|
||||
NT["notifier.py<br/>DiscordNotifier<br/>진입·청산·오류 알림"]
|
||||
end
|
||||
|
||||
subgraph mlops["MLOps 파이프라인 (맥미니 — 수동/크론)"]
|
||||
subgraph mlops["MLOps 파이프라인 (수동/크론)"]
|
||||
FH["fetch_history.py<br/>과거 캔들 + OI/펀딩비<br/>Parquet Upsert"]
|
||||
DB["dataset_builder.py<br/>벡터화 데이터셋 생성<br/>레이블: ATR SL/TP 6시간 룩어헤드"]
|
||||
TM["train_model.py<br/>LightGBM 학습<br/>Walk-Forward 5폴드 검증"]
|
||||
TN["tune_hyperparams.py<br/>Optuna 50 trials<br/>TPE + MedianPruner"]
|
||||
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
|
||||
|
||||
WS1 -->|캔들 마감 이벤트| DS
|
||||
@@ -69,26 +116,59 @@ flowchart TD
|
||||
DM -->|모델 파일 전송| ML
|
||||
```
|
||||
|
||||
### 기술 스택 요약
|
||||
---
|
||||
|
||||
| 분류 | 기술 |
|
||||
|------|------|
|
||||
| 언어 | Python 3.11+ |
|
||||
| 비동기 런타임 | `asyncio` + `python-binance` WebSocket |
|
||||
| 기술 지표 | `pandas-ta` (RSI, MACD, BB, EMA, StochRSI, ATR) |
|
||||
| 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 |
|
||||
| 운영 서버 | LXC 컨테이너 (`10.1.10.24`) |
|
||||
## 2. 매매 판단 과정
|
||||
|
||||
봇이 매매를 결정하는 과정을 단계별로 설명합니다. 코드를 읽기 전에 이 섹션을 먼저 이해하면 전체 구조가 명확해집니다.
|
||||
|
||||
### 2.1 진입 판단 (5단계 게이트)
|
||||
|
||||
```
|
||||
Gate 0: 킬스위치 확인
|
||||
└─ 해당 심볼이 킬 상태인가? → 킬이면 즉시 return (신규 진입 차단)
|
||||
└─ Fast Kill: 8연속 순손실 / Slow Kill: 최근 15거래 PF < 0.75
|
||||
|
||||
Gate 1: 추세 존재 확인
|
||||
└─ ADX ≥ 25 인가? → 미만이면 HOLD (횡보장 진입 차단)
|
||||
|
||||
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개의 레이어로 구성됩니다. 각 레이어는 단일 책임을 가지며, 위에서 아래로 데이터가 흐릅니다.
|
||||
|
||||
@@ -119,17 +199,17 @@ flowchart TD
|
||||
|
||||
**파일:** `src/data_stream.py`
|
||||
|
||||
봇이 시작되면 가장 먼저 실행되는 레이어입니다. Binance Combined WebSocket 단일 연결로 XRP·BTC·ETH 3개 심볼의 15분봉 캔들을 동시에 수신합니다.
|
||||
Binance Combined WebSocket 단일 연결로 주 거래 심볼 + 상관관계 심볼(BTC/ETH)의 15분봉 캔들을 동시에 수신합니다.
|
||||
|
||||
**핵심 동작:**
|
||||
|
||||
1. **프리로드**: 봇 시작 시 REST API로 과거 캔들 200개를 `deque`에 즉시 채웁니다. EMA50 안정화에 필요한 최소 캔들(100개)을 확보하여 첫 캔들부터 신호를 계산할 수 있게 합니다.
|
||||
2. **버퍼 관리**: 심볼별 `deque(maxlen=200)`에 마감된 캔들만 추가합니다. 미마감 캔들(`is_closed=False`)은 무시합니다.
|
||||
3. **콜백 트리거**: XRP(주 심볼) 캔들이 마감되면 `bot._on_candle_closed()`를 호출합니다. BTC·ETH는 버퍼에만 쌓이고 콜백을 트리거하지 않습니다.
|
||||
3. **콜백 트리거**: 주 거래 심볼 캔들이 마감되면 `bot._on_candle_closed()`를 호출합니다. 상관관계 심볼(BTC·ETH)은 버퍼에만 쌓이고 콜백을 트리거하지 않습니다.
|
||||
|
||||
```
|
||||
Combined WebSocket
|
||||
├── xrpusdt@kline_15m → buffers["xrpusdt"] → on_candle() 호출
|
||||
예: TRXUSDT 봇의 Combined WebSocket
|
||||
├── trxusdt@kline_15m → buffers["trxusdt"] → on_candle() 호출
|
||||
├── btcusdt@kline_15m → buffers["btcusdt"] (콜백 없음)
|
||||
└── ethusdt@kline_15m → buffers["ethusdt"] (콜백 없음)
|
||||
```
|
||||
@@ -155,7 +235,7 @@ Combined WebSocket
|
||||
| ADX | length=14 | 추세 강도 측정 → 횡보장 필터 (ADX < 25 시 진입 차단) |
|
||||
| Volume MA | length=20 | 거래량 급증 감지 |
|
||||
|
||||
**신호 생성 로직 (ADX 필터 + 가중치 합산):**
|
||||
**신호 생성 로직:**
|
||||
|
||||
```
|
||||
[1단계] ADX 횡보장 필터:
|
||||
@@ -168,9 +248,13 @@ Combined WebSocket
|
||||
EMA 정배열 (9 > 21 > 50) → +1
|
||||
StochRSI K < 20 and K > D → +1
|
||||
|
||||
진입 조건: 점수 ≥ 3 AND (거래량 급증 OR 점수 ≥ 4)
|
||||
SL = 진입가 - ATR × 1.5
|
||||
TP = 진입가 + ATR × 3.0 (리스크:리워드 = 1:2)
|
||||
진입 조건: 점수 ≥ SIGNAL_THRESHOLD(기본 3)
|
||||
AND (거래량 ≥ 20MA × VOL_MULTIPLIER(기본 2.5) OR 점수 ≥ SIGNAL_THRESHOLD + 1)
|
||||
|
||||
SL = 진입가 - ATR × ATR_SL_MULT (기본 2.0)
|
||||
TP = 진입가 + ATR × ATR_TP_MULT (기본 2.0)
|
||||
|
||||
※ SL/TP/신호임계값/ADX/거래량배수 모두 환경변수로 설정 가능 (심볼별 오버라이드 지원)
|
||||
```
|
||||
|
||||
숏 신호는 롱의 대칭 조건으로 계산됩니다.
|
||||
@@ -181,7 +265,7 @@ TP = 진입가 + ATR × 3.0 (리스크:리워드 = 1:2)
|
||||
|
||||
**파일:** `src/ml_filter.py`, `src/ml_features.py`
|
||||
|
||||
기술 지표 신호가 발생해도 ML 모델이 "이 타점은 실패 확률이 높다"고 판단하면 진입을 차단합니다. 오진입(억까 타점)을 줄이는 2차 게이트키퍼입니다.
|
||||
기술 지표 신호가 발생해도 ML 모델이 "이 타점은 실패 확률이 높다"고 판단하면 진입을 차단합니다. 오진입을 줄이는 2차 게이트키퍼입니다.
|
||||
|
||||
**모델 우선순위:**
|
||||
|
||||
@@ -191,7 +275,7 @@ ONNX (MLX 신경망) → LightGBM → 폴백(항상 허용)
|
||||
|
||||
모델 파일이 없으면 모든 신호를 허용합니다. 봇 재시작 없이 모델 파일을 교체하면 다음 캔들 마감 시 자동으로 핫리로드됩니다(`mtime` 감지).
|
||||
|
||||
**23개 ML 피처:**
|
||||
**26개 ML 피처:**
|
||||
|
||||
```
|
||||
XRP 기술 지표 (13개):
|
||||
@@ -207,6 +291,13 @@ BTC/ETH 상관관계 (8개):
|
||||
시장 미시구조 (2개):
|
||||
oi_change ← 이전 캔들 대비 미결제약정 변화율
|
||||
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`으로 폴백하여 봇이 멈추지 않습니다.
|
||||
@@ -215,7 +306,7 @@ BTC/ETH 상관관계 (8개):
|
||||
|
||||
```python
|
||||
proba = model.predict_proba(features)[0][1] # 성공 확률
|
||||
return proba >= 0.60 # 임계값 60%
|
||||
return proba >= 0.55 # 임계값 (ML_THRESHOLD 환경변수로 조절)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -239,24 +330,33 @@ ML 필터를 통과한 신호를 실제 주문으로 변환하고, 리스크 한
|
||||
**주문 흐름:**
|
||||
|
||||
```
|
||||
1. set_leverage(10x)
|
||||
1. set_leverage(20x)
|
||||
2. place_order(MARKET) ← 진입
|
||||
3. place_order(STOP_MARKET) ← SL 설정
|
||||
4. place_order(TAKE_PROFIT_MARKET) ← TP 설정
|
||||
3. place_order(STOP_MARKET) ← SL 설정 (3회 재시도)
|
||||
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% |
|
||||
| 최대 동시 포지션 | 3개 |
|
||||
| 최소 명목금액 | $5 USDT |
|
||||
| 제어 항목 | 기준 | 방어 대상 |
|
||||
|----------|------|-----------|
|
||||
| 일일 최대 손실 | 기준 잔고의 5% | 단일 충격 (하루 급락) |
|
||||
| 킬스위치 Fast Kill | 8연속 순손실 | 전략 급격 붕괴 |
|
||||
| 킬스위치 Slow Kill | 최근 15거래 PF < 0.75 | 점진적 엣지 소실 (Slow Bleed) |
|
||||
| 최대 동시 포지션 | 3개 (전체 심볼 합산) | 과노출 |
|
||||
| 동일 방향 제한 | 2개 (LONG 2개면 3번째 LONG 차단) | 방향 편중 |
|
||||
| 같은 심볼 중복 | 차단 (1심볼 1포지션) | 중복 진입 |
|
||||
| 최소 명목금액 | $5 USDT | 거래소 제약 |
|
||||
|
||||
**반대 시그널 재진입:** 보유 포지션과 반대 방향 신호 발생 시 기존 포지션을 즉시 청산하고, ML 필터 통과 시 반대 방향으로 재진입합니다. 재진입 중 User Data Stream 콜백이 신규 포지션 상태를 덮어쓰지 않도록 `_is_reentering` 플래그로 보호합니다.
|
||||
|
||||
**마진 균등 배분:** 멀티심볼 모드에서 각 봇은 전체 잔고를 심볼 수로 나눈 금액만큼만 사용합니다 (`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
|
||||
@@ -272,7 +372,7 @@ Binance `ORDER_TRADE_UPDATE` 웹소켓 이벤트를 구독하여 TP/SL 체결을
|
||||
```
|
||||
이벤트 필터링 조건:
|
||||
e == "ORDER_TRADE_UPDATE"
|
||||
AND s == "XRPUSDT" ← 심볼 필터
|
||||
AND s == self.symbol ← 심볼 필터 (봇별 독립)
|
||||
AND x == "TRADE" ← 실제 체결
|
||||
AND X == "FILLED" ← 완전 체결
|
||||
AND (reduceOnly OR order_type in {STOP_MARKET, TAKE_PROFIT_MARKET} OR rp != 0)
|
||||
@@ -288,7 +388,7 @@ Binance `ORDER_TRADE_UPDATE` 웹소켓 이벤트를 구독하여 TP/SL 체결을
|
||||
net_pnl = realized_pnl - commission
|
||||
```
|
||||
|
||||
**Discord 알림 포맷:**
|
||||
**Discord 알림 예시:**
|
||||
|
||||
진입 시:
|
||||
```
|
||||
@@ -300,7 +400,7 @@ RSI: 32.50 | MACD Hist: -0.000123 | ATR: 0.023400
|
||||
|
||||
청산 시:
|
||||
```
|
||||
✅ [XRPUSDT] LONG TP 청산
|
||||
[XRPUSDT] LONG TP 청산
|
||||
청산가: 2.4150
|
||||
예상 수익: +7.0000 USDT
|
||||
실제 순수익: +6.7800 USDT
|
||||
@@ -309,20 +409,20 @@ RSI: 32.50 | MACD Hist: -0.000123 | ATR: 0.023400
|
||||
|
||||
---
|
||||
|
||||
## 3. MLOps 파이프라인 — 자가 진화 시스템
|
||||
## 4. MLOps 파이프라인
|
||||
|
||||
봇의 ML 모델은 고정된 것이 아니라 주기적으로 재학습·개선됩니다. 전체 라이프사이클은 다음과 같습니다.
|
||||
봇의 ML 모델은 고정된 것이 아니라 주기적으로 재학습·개선됩니다.
|
||||
|
||||
### 3.1 전체 라이프사이클
|
||||
### 4.1 전체 라이프사이클
|
||||
|
||||
```mermaid
|
||||
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 비교"]
|
||||
C{"개선폭 충분?<br/>(AUC +0.01 이상<br/>폴드 분산 낮음)"}
|
||||
D["active_lgbm_params.json<br/>업데이트<br/>(Active Config 패턴)"]
|
||||
E["새벽 2시 크론탭<br/>train_and_deploy.sh<br/>(데이터 수집 → 학습 → 배포)"]
|
||||
F["LXC 서버<br/>lgbm_filter.pkl 교체"]
|
||||
E["크론탭 또는 수동 실행<br/>train_and_deploy.sh<br/>(데이터 수집 → 학습 → 배포)"]
|
||||
F["운영 서버<br/>lgbm_filter.pkl 교체"]
|
||||
G["봇 핫리로드<br/>다음 캔들 mtime 감지<br/>→ 자동 리로드"]
|
||||
|
||||
A --> B
|
||||
@@ -335,7 +435,7 @@ flowchart LR
|
||||
G --> A
|
||||
```
|
||||
|
||||
### 3.2 단계별 상세 설명
|
||||
### 4.2 단계별 상세
|
||||
|
||||
#### Step 1: Optuna 하이퍼파라미터 탐색
|
||||
|
||||
@@ -360,11 +460,11 @@ reg_alpha: 1e-4 ~ 1.0 (log scale)
|
||||
reg_lambda: 1e-4 ~ 1.0 (log scale)
|
||||
```
|
||||
|
||||
결과는 `models/tune_results_YYYYMMDD_HHMMSS.json`에 저장됩니다.
|
||||
결과는 `models/{symbol}/tune_results_YYYYMMDD_HHMMSS.json`에 저장됩니다.
|
||||
|
||||
#### Step 2: Active Config 패턴으로 파라미터 승인
|
||||
|
||||
Optuna가 찾은 파라미터는 **자동으로 적용되지 않습니다.** 사람이 결과를 검토하고 직접 `models/active_lgbm_params.json`을 업데이트해야 합니다.
|
||||
Optuna가 찾은 파라미터는 **자동으로 적용되지 않습니다.** 사람이 결과를 검토하고 직접 `models/{symbol}/active_lgbm_params.json`을 업데이트해야 합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -385,23 +485,25 @@ Optuna가 찾은 파라미터는 **자동으로 적용되지 않습니다.** 사
|
||||
|
||||
> **주의**: Optuna 결과는 과적합 위험이 있습니다. 폴드별 AUC 분산이 크거나 (std > 0.05), 개선폭이 미미하면 (< 0.01) 적용하지 않는 것을 권장합니다.
|
||||
|
||||
#### Step 3: 자동 학습 및 배포 (크론탭)
|
||||
#### Step 3: 자동 학습 및 배포
|
||||
|
||||
`scripts/train_and_deploy.sh`는 3단계를 자동으로 실행합니다:
|
||||
|
||||
```
|
||||
[1/3] 데이터 수집 (fetch_history.py)
|
||||
- 기존 parquet 없음 → 1년치(365일) 전체 수집
|
||||
- 기존 parquet 있음 → 35일치 Upsert (OI/펀딩비 0.0 구간 보충)
|
||||
[심볼별 반복] --symbol 지정 시 단일 심볼, --all 시 전체 심볼 순차 처리
|
||||
|
||||
[2/3] 모델 학습 (train_model.py)
|
||||
- active_lgbm_params.json 파라미터 로드
|
||||
[1/3] 데이터 수집 (fetch_history.py --symbol {SYM})
|
||||
- data/{symbol}/combined_15m.parquet 없음 → 1년치(365일) 전체 수집
|
||||
- 있음 → 35일치 Upsert (OI/펀딩비 0.0 구간 보충)
|
||||
|
||||
[2/3] 모델 학습 (train_model.py --symbol {SYM})
|
||||
- models/{symbol}/active_lgbm_params.json 파라미터 로드
|
||||
- 벡터화 데이터셋 생성 (dataset_builder.py)
|
||||
- Walk-Forward 5폴드 검증 후 최종 모델 저장
|
||||
- 학습 로그: models/training_log.json
|
||||
- 학습 로그: models/{symbol}/training_log.json
|
||||
|
||||
[3/3] LXC 배포 (deploy_model.sh)
|
||||
- rsync로 lgbm_filter.pkl → LXC 서버 전송
|
||||
[3/3] 운영 서버 배포 (deploy_model.sh --symbol {SYM})
|
||||
- rsync로 models/{symbol}/lgbm_filter.pkl → 운영 서버 전송
|
||||
- 기존 모델 자동 백업 (lgbm_filter_prev.pkl)
|
||||
- ONNX 파일 충돌 방지 (우선순위 보장)
|
||||
```
|
||||
@@ -423,14 +525,34 @@ if onnx_changed or lgbm_changed:
|
||||
|
||||
매 캔들 마감(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캔들) 룩어헤드**로 생성됩니다.
|
||||
|
||||
```
|
||||
신호 발생 시점 기준:
|
||||
SL = 진입가 - ATR × 1.5
|
||||
TP = 진입가 + ATR × 3.0
|
||||
SL = 진입가 - ATR × ATR_SL_MULT (기본 2.0)
|
||||
TP = 진입가 + ATR × ATR_TP_MULT (기본 2.0)
|
||||
|
||||
향후 24캔들 동안:
|
||||
- 저가가 SL에 먼저 닿으면 → label = 0 (실패)
|
||||
@@ -442,11 +564,11 @@ if onnx_changed or lgbm_changed:
|
||||
|
||||
---
|
||||
|
||||
## 4. 핵심 동작 시나리오
|
||||
## 5. 핵심 동작 시나리오
|
||||
|
||||
### 시나리오 1: 15분 캔들 마감 시 봇의 동작 흐름
|
||||
### 시나리오 1: 15분 캔들 마감 → 진입 판단
|
||||
|
||||
> "XRP 15분봉이 마감되면 봇은 무엇을 하는가?"
|
||||
> "15분봉이 마감되면 봇은 무엇을 하는가?"
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -475,13 +597,13 @@ sequenceDiagram
|
||||
|
||||
alt 신호 = LONG 또는 SHORT, 포지션 없음
|
||||
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)
|
||||
ML-->>BOT: proba=0.73 ≥ 0.60 → True
|
||||
ML-->>BOT: proba=0.73 ≥ 0.55 → True
|
||||
|
||||
BOT->>EX: get_balance()
|
||||
BOT->>RM: get_dynamic_margin_ratio(balance)
|
||||
BOT->>EX: set_leverage(10)
|
||||
BOT->>EX: set_leverage(20)
|
||||
BOT->>EX: place_order(MARKET, BUY, qty=100.0)
|
||||
BOT->>EX: place_order(STOP_MARKET, SELL, stop=2.3100)
|
||||
BOT->>EX: place_order(TAKE_PROFIT_MARKET, SELL, stop=2.4150)
|
||||
@@ -499,7 +621,7 @@ sequenceDiagram
|
||||
|
||||
---
|
||||
|
||||
### 시나리오 2: TP/SL 체결 시 봇의 동작 흐름
|
||||
### 시나리오 2: TP/SL 체결 → 포지션 종료
|
||||
|
||||
> "거래소에서 TP가 작동하면 봇은 어떻게 반응하는가?"
|
||||
|
||||
@@ -529,6 +651,9 @@ sequenceDiagram
|
||||
BOT->>NT: notify_close(TP, exit=2.4150, est=+7.00, net=+6.78, diff=-0.22)
|
||||
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: _entry_price = None
|
||||
BOT->>BOT: _entry_quantity = None
|
||||
@@ -537,110 +662,321 @@ sequenceDiagram
|
||||
|
||||
**핵심 포인트:**
|
||||
- User Data Stream은 `asyncio.gather()`로 캔들 스트림과 **병렬** 실행
|
||||
- 체결 즉시 감지 (최대 15분 지연이었던 폴링 방식 대비 실시간)
|
||||
- 체결 즉시 감지 (폴링 방식의 최대 15분 지연 해소)
|
||||
- `realized_pnl - commission` = 정확한 순수익 (슬리피지·수수료 포함)
|
||||
- `_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/` 폴더에 12개 테스트 파일, 총 **81개의 테스트 케이스**가 작성되어 있습니다.
|
||||
**파일:** `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
|
||||
pytest tests/ -v # 전체 실행
|
||||
bash scripts/run_tests.sh # 래퍼 스크립트 실행
|
||||
```
|
||||
|
||||
### 5.2 모듈별 테스트 현황
|
||||
`tests/` 폴더에 19개 테스트 파일, 총 **191개의 테스트 케이스**가 작성되어 있습니다.
|
||||
|
||||
| 테스트 파일 | 대상 모듈 | 테스트 케이스 | 주요 검증 항목 |
|
||||
|------------|----------|:------------:|--------------|
|
||||
| `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_ml_filter.py` | `src/ml_filter.py` | 5 | 모델 없을 때 폴백 허용, 임계값 이상/미만 판단, 핫리로드 후 상태 변화 |
|
||||
| `test_risk_manager.py` | `src/risk_manager.py` | 8 | 일일 손실 한도 초과 차단, 최대 포지션 수 제한, 동적 증거금 비율 상한/하한 클램핑 |
|
||||
| `test_exchange.py` | `src/exchange.py` | 8 | 수량 계산(기본/최소명목금액/잔고0), OI·펀딩비 조회 정상/오류 시 반환값 |
|
||||
| `test_data_stream.py` | `src/data_stream.py` | 6 | 3심볼 버퍼 존재, 빈 버퍼 None 반환, 캔들 파싱, 마감 캔들 콜백 호출, 프리로드 200개 |
|
||||
| `test_label_builder.py` | `src/label_builder.py` | 4 | LONG TP 도달 → 1, LONG SL 도달 → 0, 미결 → None, SHORT TP 도달 → 1 |
|
||||
| `test_dataset_builder.py` | `src/dataset_builder.py` | 9 | DataFrame 반환, 필수 컬럼 존재, 레이블 이진값, BTC/ETH 포함 시 23개 피처, inf/NaN 없음, OI nan 마스킹, RS 분모 0 처리 |
|
||||
| `test_mlx_filter.py` | `src/mlx_filter.py` | 5 | GPU 디바이스 확인, 학습 전 예측 형태, 학습 후 유효 확률, NaN 피처 처리, 저장/로드 후 동일 예측 |
|
||||
| `test_fetch_history.py` | `scripts/fetch_history.py` | 5 | OI=0 구간 Upsert, 신규 행 추가, 기존 비0값 보존, 파일 없을 때 신규 반환, 타임스탬프 오름차순 정렬 |
|
||||
| `test_config.py` | `src/config.py` | 2 | 환경변수 로드, 동적 증거금 파라미터 로드 |
|
||||
### 6.2 모듈별 테스트 현황
|
||||
|
||||
| 테스트 파일 | 대상 모듈 | 케이스 | 주요 검증 항목 |
|
||||
|------------|----------|:------:|--------------|
|
||||
| `test_bot.py` | `src/bot.py` | 18 | 반대 시그널 재진입, ML 차단 시 스킵, OI/펀딩비 피처 전달 |
|
||||
| `test_indicators.py` | `src/indicators.py` | 7 | RSI 범위, MACD 컬럼, 볼린저 대소관계, ADX 횡보장 차단 |
|
||||
| `test_ml_features.py` | `src/ml_features.py` | 14 | 26개 피처 수, RS 분모 0 처리, NaN 없음 |
|
||||
| `test_ml_filter.py` | `src/ml_filter.py` | 5 | 모델 없을 때 폴백, 임계값 판단, 핫리로드 |
|
||||
| `test_risk_manager.py` | `src/risk_manager.py` | 15 | 일일 손실 한도, 동일 방향 제한, 동적 증거금 비율 |
|
||||
| `test_exchange.py` | `src/exchange.py` | 12 | 수량 계산, OI·펀딩비 조회 정상/오류 |
|
||||
| `test_data_stream.py` | `src/data_stream.py` | 7 | 3심볼 버퍼, 캔들 파싱, 프리로드 200개 |
|
||||
| `test_label_builder.py` | `src/label_builder.py` | 4 | TP/SL 도달 레이블, 미결 → None |
|
||||
| `test_dataset_builder.py` | `src/dataset_builder.py` | 14 | DataFrame 반환, 필수 컬럼, inf/NaN 없음 |
|
||||
| `test_mlx_filter.py` | `src/mlx_filter.py` | 5 | GPU 학습, 저장/로드 동일 예측 (Apple Silicon 전용) |
|
||||
| `test_fetch_history.py` | `scripts/fetch_history.py` | 5 | OI=0 Upsert, 중복 방지, 타임스탬프 정렬 |
|
||||
| `test_config.py` | `src/config.py` | 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` 패키지)이 없는 환경에서 자동 스킵됩니다.
|
||||
|
||||
### 5.3 커버리지 매트릭스
|
||||
### 6.3 커버리지 매트릭스
|
||||
|
||||
아래는 핵심 비즈니스 로직의 테스트 커버 여부입니다.
|
||||
|
||||
| 기능 | 단위 테스트 | 통합 수준 테스트 | 비고 |
|
||||
|------|:----------:|:--------------:|------|
|
||||
| 기술 지표 계산 (RSI/MACD/BB/EMA/StochRSI/ADX) | ✅ | ✅ | `test_indicators` + `test_ml_features` + `test_dataset_builder` |
|
||||
| 기능 | 단위 | 통합 | 비고 |
|
||||
|------|:----:|:----:|------|
|
||||
| 기술 지표 계산 | ✅ | ✅ | `test_indicators` + `test_ml_features` + `test_dataset_builder` |
|
||||
| 신호 생성 (가중치 합산) | ✅ | ✅ | `test_indicators` + `test_dataset_builder` |
|
||||
| ADX 횡보장 필터 (ADX < 25 차단) | ✅ | ✅ | `test_indicators` + `test_dataset_builder` (`_calc_signals` 실제 호출) |
|
||||
| ML 피처 추출 (23개) | ✅ | ✅ | `test_ml_features` + `test_dataset_builder` (`_calc_features_vectorized` 실제 호출) |
|
||||
| ML 필터 추론 (임계값 판단) | ✅ | — | `test_ml_filter` |
|
||||
| ADX 횡보장 필터 | ✅ | ✅ | `test_indicators` |
|
||||
| ML 피처 추출 (26개) | ✅ | ✅ | `test_ml_features` + `test_dataset_builder` |
|
||||
| ML 필터 추론 | ✅ | — | `test_ml_filter` |
|
||||
| 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_risk_manager` |
|
||||
| 일일 손실 한도 제어 | ✅ | — | `test_risk_manager` |
|
||||
| 동적 증거금 비율 | ✅ | — | `test_risk_manager` |
|
||||
| 동일 방향 포지션 제한 | ✅ | — | `test_risk_manager` |
|
||||
| 일일 손실 한도 | ✅ | — | `test_risk_manager` |
|
||||
| 포지션 수량 계산 | ✅ | — | `test_exchange` |
|
||||
| OI/펀딩비 API 조회 (정상/오류) | ✅ | ✅ | `test_exchange` + `test_bot` (`process_candle` → OI/펀딩비 → `build_features` 전달) |
|
||||
| 반대 시그널 재진입 흐름 | ✅ | ✅ | `test_bot` |
|
||||
| ML 차단 시 재진입 스킵 | ✅ | ✅ | `test_bot` (`_close_and_reenter` → ML 판단 → 스킵 전체 흐름) |
|
||||
| OI 변화율 계산 (API 실패 폴백) | ✅ | ✅ | `test_bot` (`process_candle` → OI 조회 → `_calc_oi_change` 흐름) |
|
||||
| 캔들 버퍼 관리 및 프리로드 | ✅ | — | `test_data_stream` |
|
||||
| Parquet Upsert (OI=0 보충) | ✅ | — | `test_fetch_history` |
|
||||
| User Data Stream TP/SL 감지 | ❌ | — | 미작성 (실제 WebSocket 의존) |
|
||||
| OI/펀딩비 API 조회 | ✅ | ✅ | `test_exchange` + `test_bot` |
|
||||
| 반대 시그널 재진입 | ✅ | ✅ | `test_bot` |
|
||||
| OI 변화율 계산 | ✅ | ✅ | `test_bot` |
|
||||
| Parquet Upsert | ✅ | — | `test_fetch_history` |
|
||||
| 주간 리포트 | ✅ | ✅ | `test_weekly_report` |
|
||||
| MTF Pullback Bot | ✅ | ✅ | `test_mtf_bot` (20 cases) |
|
||||
| User Data Stream TP/SL | ❌ | — | 미작성 (WebSocket 의존) |
|
||||
| Discord 알림 전송 | ❌ | — | 미작성 (외부 웹훅 의존) |
|
||||
| CI/CD 파이프라인 | ❌ | — | Jenkins 환경 의존 |
|
||||
|
||||
### 5.4 테스트 전략
|
||||
### 6.4 테스트 전략
|
||||
|
||||
**Mock 활용 원칙:**
|
||||
- Binance API 호출(`BinanceFuturesClient`, `AsyncClient`)은 모두 `unittest.mock.AsyncMock`으로 대체합니다.
|
||||
- 외부 의존성(Discord Webhook, Binance WebSocket)은 테스트 대상에서 제외합니다.
|
||||
- `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 보존/덮어쓰기 조건
|
||||
- **Mock 원칙**: Binance API 호출은 모두 `unittest.mock.AsyncMock`으로 대체. 외부 의존성(Discord, WebSocket)은 테스트 대상에서 제외.
|
||||
- **비동기 테스트**: `pytest-asyncio` + `@pytest.mark.asyncio`
|
||||
- **경계값 중심**: 분모 0 처리, API 실패 폴백, 최소 주문 금액 미달, OI=0 구간 Upsert
|
||||
|
||||
---
|
||||
|
||||
## 부록: 파일별 역할 요약
|
||||
## 7. 파일 구조
|
||||
|
||||
| 파일 | 레이어 | 역할 |
|
||||
|------|--------|------|
|
||||
| `main.py` | — | 진입점. `Config` 로드 후 `TradingBot.run()` 실행 |
|
||||
| `src/bot.py` | 오케스트레이터 | 모든 레이어를 조율하는 메인 트레이딩 루프 |
|
||||
| `src/config.py` | — | 환경변수 기반 설정 (`@dataclass`) |
|
||||
| `main.py` | — | 진입점. 심볼별 `TradingBot` 생성 + 공유 `RiskManager` + `asyncio.gather()` + SIGTERM/SIGINT graceful shutdown |
|
||||
| `src/bot.py` | 오케스트레이터 | 심볼별 독립 트레이딩 루프 + 듀얼 레이어 킬스위치 |
|
||||
| `src/config.py` | — | 환경변수 기반 설정 (`symbols` 리스트, `correlation_symbols`, 심볼별 `SymbolStrategyParams`) |
|
||||
| `src/data_stream.py` | Data | Combined WebSocket 캔들 수신·버퍼 관리 |
|
||||
| `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/mlx_filter.py` | ML Filter | Apple Silicon GPU 학습 + ONNX export |
|
||||
| `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/notifier.py` | Alert | Discord 웹훅 알림 |
|
||||
| `src/label_builder.py` | MLOps | 학습 레이블 생성 (ATR SL/TP 룩어헤드) |
|
||||
| `src/dataset_builder.py` | MLOps | 벡터화 데이터셋 빌더 (학습용) |
|
||||
| `src/backtester.py` | MLOps | 백테스트 엔진 (단일 + Walk-Forward) |
|
||||
| `src/mtf_bot.py` | MTF Bot | 멀티타임프레임 풀백 봇 (1h MetaFilter + 15m TriggerStrategy + Dry-run ExecutionManager) |
|
||||
| `src/backtest_validator.py` | MLOps | 백테스트 결과 검증 |
|
||||
| `src/logger_setup.py` | — | Loguru 로거 설정 |
|
||||
| `scripts/fetch_history.py` | MLOps | 과거 캔들 + OI/펀딩비 수집 (Parquet Upsert) |
|
||||
| `scripts/train_model.py` | MLOps | LightGBM 모델 학습 (CPU) |
|
||||
| `scripts/fetch_history.py` | MLOps | 과거 캔들 + OI/펀딩비 수집 |
|
||||
| `scripts/train_model.py` | MLOps | LightGBM 모델 학습 |
|
||||
| `scripts/train_mlx_model.py` | MLOps | MLX 신경망 학습 (Apple Silicon GPU) |
|
||||
| `scripts/tune_hyperparams.py` | MLOps | Optuna 하이퍼파라미터 탐색 (수동 트리거) |
|
||||
| `scripts/train_and_deploy.sh` | MLOps | 전체 파이프라인 (수집→학습→배포) |
|
||||
| `scripts/deploy_model.sh` | MLOps | 모델 파일 LXC 서버 전송 |
|
||||
| `models/active_lgbm_params.json` | MLOps | 승인된 LightGBM 파라미터 (Active Config) |
|
||||
| `scripts/tune_hyperparams.py` | MLOps | Optuna 하이퍼파라미터 탐색 |
|
||||
| `scripts/train_and_deploy.sh` | MLOps | 전체 파이프라인 (수집 → 학습 → 배포) |
|
||||
| `scripts/deploy_model.sh` | MLOps | 모델 파일 운영 서버 전송 |
|
||||
| `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 파라미터 |
|
||||
|
||||
63
CLAUDE.md
63
CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
CoinTrader is a Python asyncio-based automated cryptocurrency trading bot for Binance Futures. It trades XRPUSDT on 15-minute candles, using BTC/ETH as correlation features. The system has 5 layers: Data (WebSocket streams) → Signal (technical indicators) → ML Filter (ONNX/LightGBM) → Execution & Risk → Event/Alert (Discord).
|
||||
CoinTrader is a Python asyncio-based automated cryptocurrency trading bot for Binance Futures. It supports multi-symbol simultaneous trading (XRP, TRX, DOGE etc.) on 15-minute candles, using BTC/ETH as correlation features. The system has 5 layers: Data (WebSocket streams) → Signal (technical indicators) → ML Filter (ONNX/LightGBM) → Execution & Risk → Event/Alert (Discord).
|
||||
|
||||
## Common Commands
|
||||
|
||||
@@ -24,34 +24,52 @@ bash scripts/run_tests.sh -k "bot"
|
||||
# Run pytest directly
|
||||
pytest tests/ -v --tb=short
|
||||
|
||||
# ML training pipeline (LightGBM default)
|
||||
# ML training pipeline (all symbols)
|
||||
bash scripts/train_and_deploy.sh
|
||||
|
||||
# Single symbol training
|
||||
bash scripts/train_and_deploy.sh --symbol TRXUSDT
|
||||
|
||||
# MLX GPU training (macOS Apple Silicon)
|
||||
bash scripts/train_and_deploy.sh mlx
|
||||
bash scripts/train_and_deploy.sh mlx --symbol XRPUSDT
|
||||
|
||||
# Hyperparameter tuning (50 trials, 5-fold walk-forward)
|
||||
python scripts/tune_hyperparams.py
|
||||
python scripts/tune_hyperparams.py --symbol XRPUSDT
|
||||
|
||||
# Fetch historical data
|
||||
# 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)
|
||||
python scripts/fetch_history.py --symbol TRXUSDT --interval 15m --days 365
|
||||
|
||||
# Fetch historical data (explicit symbols)
|
||||
python scripts/fetch_history.py --symbols XRPUSDT BTCUSDT ETHUSDT --interval 15m --days 365
|
||||
|
||||
# Deploy models to production
|
||||
bash scripts/deploy_model.sh
|
||||
bash scripts/deploy_model.sh --symbol XRPUSDT
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Entry point**: `main.py` → creates `Config` (dataclass from env vars) → runs `TradingBot`
|
||||
**Entry point**: `main.py` → creates `Config` → shared `RiskManager` → per-symbol `TradingBot` instances → `asyncio.gather()`
|
||||
|
||||
**Multi-symbol architecture**: Each symbol gets its own `TradingBot` instance with independent `Exchange`, `MLFilter`, and `DataStream`. The `RiskManager` is shared as a singleton across all bots, enforcing global daily loss limits and same-direction position limits via `asyncio.Lock`.
|
||||
|
||||
**5-layer data flow on each 15m candle close:**
|
||||
1. `src/data_stream.py` — Combined WebSocket for XRP/BTC/ETH, deque buffers (200 candles each)
|
||||
1. `src/data_stream.py` — Combined WebSocket for primary+correlation symbols, deque buffers (200 candles each)
|
||||
2. `src/indicators.py` — RSI, MACD, BB, EMA, StochRSI, ATR; weighted signal aggregation → LONG/SHORT/HOLD
|
||||
3. `src/ml_filter.py` + `src/ml_features.py` — 26-feature extraction (ADX + OI 파생 피처 포함), ONNX priority > LightGBM fallback, threshold ≥ 0.55
|
||||
4. `src/exchange.py` + `src/risk_manager.py` — Dynamic margin, MARKET orders with SL/TP, daily loss limit (5%)
|
||||
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
|
||||
|
||||
**Parallel execution**: `user_data_stream` runs independently via `asyncio.gather()` alongside candle processing.
|
||||
**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.
|
||||
|
||||
**Model/data directories**: `models/{symbol}/` and `data/{symbol}/` for per-symbol models. Falls back to `models/` root if symbol dir doesn't exist.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
@@ -72,15 +90,15 @@ bash scripts/deploy_model.sh
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables via `.env` file (see `.env.example`). Key vars: `BINANCE_API_KEY`, `BINANCE_API_SECRET`, `SYMBOL` (default XRPUSDT), `LEVERAGE`, `DISCORD_WEBHOOK_URL`, `MARGIN_MAX_RATIO`, `MARGIN_MIN_RATIO`, `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
|
||||
|
||||
- **Docker**: `Dockerfile` (Python 3.12-slim) + `docker-compose.yml`
|
||||
- **CI/CD**: Jenkins pipeline (Gitea → Docker registry → LXC production server)
|
||||
- Models stored in `models/`, data cache in `data/`, logs in `logs/`
|
||||
- Models stored in `models/{symbol}/`, data cache in `data/{symbol}/`, logs in `logs/`
|
||||
|
||||
## Design & Implementation Plans
|
||||
|
||||
@@ -118,3 +136,22 @@ All design documents and implementation plans are stored in `docs/plans/` with t
|
||||
| 2026-03-03 | `optuna-precision-objective-plan` | Completed |
|
||||
| 2026-03-03 | `demo-1m-125x` (design + plan) | In Progress |
|
||||
| 2026-03-04 | `oi-derived-features` (design + plan) | Completed |
|
||||
| 2026-03-05 | `multi-symbol-trading` (design + plan) | Completed |
|
||||
| 2026-03-06 | `multi-symbol-dashboard` (design + 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') {
|
||||
steps {
|
||||
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}"
|
||||
|
||||
if (changes == 'ALL') {
|
||||
// 첫 빌드이거나 diff 실패 시 전체 빌드
|
||||
env.BOT_CHANGED = 'true'
|
||||
env.MTF_CHANGED = 'true'
|
||||
env.DASH_API_CHANGED = 'true'
|
||||
env.DASH_UI_CHANGED = 'true'
|
||||
} else {
|
||||
env.BOT_CHANGED = (changes =~ /(?m)^(src\/|main\.py|requirements\.txt|Dockerfile)/).find() ? 'true' : 'false'
|
||||
// mtf_bot.py 변경 감지 (mtf-bot 서비스만 재시작)
|
||||
env.MTF_CHANGED = (changes =~ /(?m)^src\/mtf_bot\.py/).find() ? 'true' : 'false'
|
||||
// src/ 변경 중 mtf_bot.py만 바뀐 경우 메인 봇은 재시작 불필요
|
||||
def botFiles = changes.split('\n').findAll { it =~ /^(src\/(?!mtf_bot\.py)|scripts\/|main\.py|requirements\.txt|Dockerfile)/ }
|
||||
env.BOT_CHANGED = botFiles.size() > 0 ? 'true' : 'false'
|
||||
env.DASH_API_CHANGED = (changes =~ /(?m)^dashboard\/api\//).find() ? 'true' : 'false'
|
||||
env.DASH_UI_CHANGED = (changes =~ /(?m)^dashboard\/ui\//).find() ? 'true' : 'false'
|
||||
}
|
||||
@@ -58,7 +67,7 @@ pipeline {
|
||||
env.COMPOSE_CHANGED = 'false'
|
||||
}
|
||||
|
||||
echo "BOT_CHANGED=${env.BOT_CHANGED}, DASH_API_CHANGED=${env.DASH_API_CHANGED}, DASH_UI_CHANGED=${env.DASH_UI_CHANGED}, COMPOSE_CHANGED=${env.COMPOSE_CHANGED}"
|
||||
echo "BOT_CHANGED=${env.BOT_CHANGED}, MTF_CHANGED=${env.MTF_CHANGED}, DASH_API_CHANGED=${env.DASH_API_CHANGED}, DASH_UI_CHANGED=${env.DASH_UI_CHANGED}, COMPOSE_CHANGED=${env.COMPOSE_CHANGED}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +75,7 @@ pipeline {
|
||||
stage('Build Docker Images') {
|
||||
parallel {
|
||||
stage('Bot') {
|
||||
when { expression { env.BOT_CHANGED == 'true' } }
|
||||
when { expression { env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true' } }
|
||||
steps {
|
||||
sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ."
|
||||
}
|
||||
@@ -91,7 +100,7 @@ pipeline {
|
||||
withCredentials([usernamePassword(credentialsId: 'gitea-registry-cred', passwordVariable: 'GITEA_TOKEN', usernameVariable: 'GITEA_USER')]) {
|
||||
sh "echo \$GITEA_TOKEN | docker login ${REGISTRY} -u \$GITEA_USER --password-stdin"
|
||||
script {
|
||||
if (env.BOT_CHANGED == 'true') {
|
||||
if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') {
|
||||
sh "docker push ${FULL_IMAGE}"
|
||||
sh "docker push ${LATEST_IMAGE}"
|
||||
}
|
||||
@@ -119,7 +128,13 @@ pipeline {
|
||||
|
||||
// 변경된 서비스만 pull & recreate (나머지는 중단 없음)
|
||||
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_UI_CHANGED == 'true') services.add('dashboard-ui')
|
||||
|
||||
@@ -137,7 +152,7 @@ pipeline {
|
||||
stage('Cleanup') {
|
||||
steps {
|
||||
script {
|
||||
if (env.BOT_CHANGED == 'true') {
|
||||
if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') {
|
||||
sh "docker rmi ${FULL_IMAGE} || true"
|
||||
sh "docker rmi ${LATEST_IMAGE} || true"
|
||||
}
|
||||
@@ -160,7 +175,7 @@ pipeline {
|
||||
sh """
|
||||
curl -H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
-d '{"content": "✅ **[배포 성공]** `cointrader` (Build #${env.BUILD_NUMBER}) 운영 서버(10.1.10.24) 배포 완료!\\n- 🤖 봇: ${env.BOT_CHANGED}\\n- 📊 API: ${env.DASH_API_CHANGED}\\n- 🖥️ UI: ${env.DASH_UI_CHANGED}"}' \
|
||||
-d '{"content": "✅ **[배포 성공]** `cointrader` (Build #${env.BUILD_NUMBER}) 운영 서버(10.1.10.24) 배포 완료!\\n- 🤖 봇: ${env.BOT_CHANGED}\\n- 📈 MTF: ${env.MTF_CHANGED}\\n- 📊 API: ${env.DASH_API_CHANGED}\\n- 🖥️ UI: ${env.DASH_UI_CHANGED}"}' \
|
||||
${DISCORD_WEBHOOK}
|
||||
"""
|
||||
}
|
||||
|
||||
572
README.md
572
README.md
@@ -1,206 +1,100 @@
|
||||
# CoinTrader
|
||||
|
||||
Binance Futures 자동매매 봇. 복합 기술 지표와 ML 필터(LightGBM / MLX 신경망)를 결합하여 XRPUSDT(기본) 선물 포지션을 자동으로 진입·청산하며, 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 시 진입
|
||||
- **ML 필터 (ONNX 우선 / LightGBM 폴백)**: 기술 지표 신호를 한 번 더 검증하여 오진입 차단. 우선순위: ONNX > LightGBM > 폴백(항상 허용)
|
||||
- **모델 핫리로드**: 캔들마다 모델 파일 mtime을 감지해 변경 시 자동 리로드 (봇 재시작 불필요)
|
||||
- **멀티심볼 스트림**: XRP/BTC/ETH 3개 심볼을 단일 Combined WebSocket으로 수신, BTC·ETH 상관관계 피처 활용
|
||||
- **23개 ML 피처**: XRP 기술 지표 13개 + BTC/ETH 수익률·상대강도 8개 + OI 변화율·펀딩비 2개 (캔들 마감 시 실시간 조회, 실패 시 0으로 폴백)
|
||||
- **점진적 OI 데이터 축적 (Upsert)**: 바이낸스 OI 히스토리 API는 최근 30일치만 제공. `fetch_history.py` 실행 시 기존 parquet의 `oi_change/funding_rate=0` 구간을 신규 값으로 채워 학습 데이터 품질을 점진적으로 개선
|
||||
- **실시간 OI/펀딩비 조회**: 캔들 마감마다 `get_open_interest()` / `get_funding_rate()`를 비동기 병렬 조회하여 ML 피처에 전달. 이전 캔들 대비 OI 변화율로 변환하여 train-serve skew 해소
|
||||
- **ATR 기반 손절/익절**: 변동성에 따라 동적으로 SL/TP 계산 (1.5× / 3.0× ATR)
|
||||
- **Algo Order API 지원**: 계정 설정에 따라 STOP_MARKET/TAKE_PROFIT_MARKET 주문을 `/fapi/v1/algoOrder` 엔드포인트로 자동 전송 (오류 코드 -4120 대응)
|
||||
- **동적 증거금 비율**: 잔고 증가에 따라 선형 감소 (최대 50% → 최소 20%)
|
||||
- **반대 시그널 재진입**: 보유 포지션과 반대 신호 발생 시 즉시 청산 후 ML 필터 통과 시 반대 방향 재진입
|
||||
- **리스크 관리**: 트레이드당 리스크 비율, 최대 포지션 수, 일일 손실 한도(5%) 제어
|
||||
- **포지션 복구**: 봇 재시작 시 기존 포지션 자동 감지 및 상태 복원
|
||||
- **실시간 TP/SL 감지**: Binance User Data Stream으로 TP/SL 작동을 즉시 감지 (캔들 마감 대기 없음)
|
||||
- **순수익(Net PnL) 기록**: 바이낸스 `realizedProfit - commission`으로 정확한 순수익 계산
|
||||
- **Discord 상세 청산 알림**: 예상 수익 vs 실제 순수익 + 슬리피지/수수료 차이 표시
|
||||
- **listenKey 자동 갱신**: 30분 keepalive + 네트워크 단절 시 자동 재연결. `stream.recv()` 기반으로 수신하며, 라이브러리 내부 에러 페이로드(`{"e":"error"}`) 감지 시 즉시 재연결하여 좀비 커넥션 방지
|
||||
- **Discord 알림**: 진입·청산·오류 이벤트 실시간 웹훅 알림
|
||||
- **CI/CD**: Jenkins + Gitea Container Registry 기반 Docker 이미지 자동 빌드·배포 (LXC 운영 서버 자동 적용)
|
||||
- **멀티심볼 동시 거래**: 심볼별 독립 봇 인스턴스를 병렬 실행, 공유 RiskManager로 글로벌 리스크 관리
|
||||
- **복합 기술 지표 신호**: RSI, MACD, 볼린저 밴드, EMA, Stochastic RSI, ADX, 거래량 급증 — 가중치 합산 시스템
|
||||
- **ML 필터 (선택)**: LightGBM / ONNX 모델로 오진입 차단 (비활성화 가능)
|
||||
- **ATR 기반 손절/익절**: 변동성에 따라 동적으로 SL/TP 계산, 환경변수로 배수 조절
|
||||
- **반대 시그널 재진입**: 보유 포지션과 반대 신호 발생 시 즉시 청산 후 재진입
|
||||
- **리스크 관리**: 동일 방향 포지션 제한, 일일 손실 한도(5%), 동적 증거금 비율
|
||||
- **듀얼 레이어 킬스위치**: Fast Kill(8연속 순손실) + Slow Kill(15거래 PF<0.75) — 심볼별 독립 차단, 기존 포지션 청산은 정상 작동
|
||||
- **SL/TP 원자성 보장**: SL/TP 배치 3회 재시도 + 최종 실패 시 긴급 시장가 청산
|
||||
- **실시간 TP/SL 감지**: Binance User Data Stream으로 즉시 감지
|
||||
- **Graceful Shutdown**: SIGTERM/SIGINT 시 심볼별 오픈 주문 취소 후 정상 종료
|
||||
- **Discord 알림**: 진입·청산·킬스위치 발동·긴급 청산·오류 이벤트 실시간 웹훅 알림
|
||||
- **모니터링 대시보드**: 거래 내역, 수익 통계, 차트를 웹에서 조회
|
||||
- **주간 전략 리포트**: 자동 성능 측정, 추이 추적, 킬스위치 모니터링, ML 재학습 시점 판단
|
||||
- **종목 비교 분석**: 심볼별 파라미터 sweep + Robust Monte Carlo 포지션 사이징
|
||||
- **MTF Pullback Bot**: 1h MetaFilter(EMA50/200 + ADX) + 15m 3캔들 풀백 시퀀스 기반 Dry-run 봇 (OOS 검증용)
|
||||
|
||||
---
|
||||
|
||||
## 프로젝트 구조
|
||||
# 봇 사용 가이드
|
||||
|
||||
```
|
||||
cointrader/
|
||||
├── main.py # 진입점
|
||||
├── src/
|
||||
│ ├── bot.py # 메인 트레이딩 루프
|
||||
│ ├── config.py # 환경변수 기반 설정
|
||||
│ ├── exchange.py # Binance Futures API 클라이언트
|
||||
│ ├── data_stream.py # WebSocket 15분봉 멀티심볼 스트림 (XRP/BTC/ETH)
|
||||
│ ├── indicators.py # 기술 지표 계산 및 신호 생성
|
||||
│ ├── ml_filter.py # ML 필터 (ONNX 우선 / LightGBM 폴백 / 핫리로드)
|
||||
│ ├── ml_features.py # ML 피처 빌더 (23개 피처)
|
||||
│ ├── mlx_filter.py # MLX 신경망 필터 (Apple Silicon GPU 학습 + ONNX export)
|
||||
│ ├── label_builder.py # 학습 레이블 생성
|
||||
│ ├── dataset_builder.py # 벡터화 데이터셋 빌더 (학습용)
|
||||
│ ├── risk_manager.py # 리스크 관리 (일일 손실 한도, 동적 증거금 비율)
|
||||
│ ├── notifier.py # Discord 웹훅 알림
|
||||
│ └── logger_setup.py # Loguru 로거 설정
|
||||
├── scripts/
|
||||
│ ├── fetch_history.py # 과거 데이터 수집 (XRP/BTC/ETH + OI/펀딩비, Upsert 지원)
|
||||
│ ├── train_model.py # LightGBM 모델 학습 (CPU)
|
||||
│ ├── train_mlx_model.py # MLX 신경망 학습 (Apple Silicon GPU)
|
||||
│ ├── train_and_deploy.sh # 전체 파이프라인 (수집 → 학습 → LXC 배포)
|
||||
│ ├── tune_hyperparams.py # Optuna 하이퍼파라미터 자동 탐색 (수동 트리거)
|
||||
│ ├── deploy_model.sh # 모델 파일 LXC 서버 전송
|
||||
│ └── run_tests.sh # 전체 테스트 실행
|
||||
├── dashboard/
|
||||
│ ├── api/ # FastAPI 백엔드 (로그 파서 + REST API)
|
||||
│ └── ui/ # React 프론트엔드 (Vite + Recharts)
|
||||
├── models/ # 학습된 모델 저장 (.pkl / .onnx)
|
||||
├── data/ # 과거 데이터 캐시 (.parquet)
|
||||
├── logs/ # 로그 파일
|
||||
├── docs/plans/ # 설계 문서 및 구현 플랜
|
||||
├── tests/ # 테스트 코드
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── Jenkinsfile
|
||||
└── requirements.txt
|
||||
```
|
||||
봇을 설치하고 운영하려는 사용자를 위한 섹션입니다.
|
||||
|
||||
---
|
||||
## 요구사항
|
||||
|
||||
- Python 3.11+ (또는 Docker)
|
||||
- Binance Futures 계정 + API 키
|
||||
- (선택) Discord 웹훅 URL
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 1. 환경변수 설정
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd cointrader
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
`.env` 파일을 열어 아래 값을 채웁니다.
|
||||
`.env` 파일을 열어 아래 필수 값을 채웁니다.
|
||||
|
||||
```env
|
||||
# 필수
|
||||
BINANCE_API_KEY=your_api_key
|
||||
BINANCE_API_SECRET=your_api_secret
|
||||
SYMBOL=XRPUSDT
|
||||
LEVERAGE=10
|
||||
SYMBOLS=XRPUSDT # 거래할 심볼 (쉼표 구분, 멀티심볼 지원)
|
||||
|
||||
# 권장
|
||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||
LEVERAGE=10
|
||||
```
|
||||
|
||||
### 2. 로컬 실행
|
||||
> 처음 사용 시 Binance Testnet에서 먼저 테스트하는 것을 권장합니다. `BINANCE_TESTNET_API_KEY`와 `BINANCE_TESTNET_API_SECRET`을 설정하세요.
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 3. Docker Compose로 실행
|
||||
### 2-A. Docker로 실행 (권장)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
로그 확인:
|
||||
|
||||
```bash
|
||||
docker compose logs -f cointrader
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ML 모델 학습
|
||||
|
||||
봇은 모델 파일이 없으면 ML 필터 없이 동작합니다. 최초 실행 전 또는 수동 재학습 시 아래 순서로 진행합니다.
|
||||
|
||||
### 전체 파이프라인 (권장)
|
||||
|
||||
맥미니에서 데이터 수집 → 학습 → LXC 배포까지 한 번에 실행합니다.
|
||||
|
||||
> **자동 분기**: `data/combined_15m.parquet`가 없으면 1년치(365일) 전체 수집, 있으면 35일치 Upsert로 자동 전환합니다. 서버 이전이나 데이터 유실 시에도 사람의 개입 없이 자동 복구됩니다.
|
||||
### 2-B. 로컬 실행
|
||||
|
||||
```bash
|
||||
# LightGBM + Walk-Forward 5폴드 (기본값)
|
||||
bash scripts/train_and_deploy.sh
|
||||
|
||||
# MLX GPU 학습 + Walk-Forward 5폴드
|
||||
bash scripts/train_and_deploy.sh mlx
|
||||
|
||||
# LightGBM + Walk-Forward 3폴드
|
||||
bash scripts/train_and_deploy.sh lgbm 3
|
||||
|
||||
# 학습만 (배포 없이)
|
||||
bash scripts/train_and_deploy.sh lgbm 0
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 단계별 수동 실행
|
||||
### 3. 정상 동작 확인
|
||||
|
||||
```bash
|
||||
# 1. 과거 데이터 수집 (XRP/BTC/ETH 3심볼, 15분봉, 1년치 + OI/펀딩비)
|
||||
# 기본값: Upsert 활성화 — 기존 parquet의 oi_change/funding_rate=0 구간을 실제 값으로 채움
|
||||
python scripts/fetch_history.py \
|
||||
--symbols XRPUSDT BTCUSDT ETHUSDT \
|
||||
--interval 15m \
|
||||
--days 365 \
|
||||
--output data/combined_15m.parquet
|
||||
봇이 정상 실행되면 다음과 같은 로그가 출력됩니다:
|
||||
|
||||
# 기존 파일을 완전히 덮어쓰려면 --no-upsert 플래그 사용
|
||||
python scripts/fetch_history.py \
|
||||
--symbols XRPUSDT BTCUSDT ETHUSDT \
|
||||
--interval 15m \
|
||||
--days 365 \
|
||||
--output data/combined_15m.parquet \
|
||||
--no-upsert
|
||||
|
||||
# 2-A. LightGBM 모델 학습 (CPU)
|
||||
python scripts/train_model.py --data data/combined_15m.parquet
|
||||
|
||||
# 2-B. MLX 신경망 학습 (Apple Silicon GPU)
|
||||
python scripts/train_mlx_model.py --data data/combined_15m.parquet
|
||||
|
||||
# 3. LXC 서버에 모델 배포
|
||||
bash scripts/deploy_model.sh # LightGBM
|
||||
bash scripts/deploy_model.sh mlx # MLX (ONNX)
|
||||
```
|
||||
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 연결 완료
|
||||
```
|
||||
|
||||
학습된 모델은 `models/lgbm_filter.pkl` (LightGBM) 또는 `models/mlx_filter.weights.onnx` (MLX) 에 저장됩니다.
|
||||
|
||||
> **모델 핫리로드**: 봇이 실행 중일 때 모델 파일을 교체하면, 다음 캔들 마감 시 자동으로 감지해 리로드합니다. 봇 재시작이 필요 없습니다.
|
||||
|
||||
### 하이퍼파라미터 자동 튜닝 (Optuna)
|
||||
|
||||
봇 성능이 저하되거나 데이터가 충분히 축적되었을 때 Optuna로 최적 LightGBM 파라미터를 탐색합니다.
|
||||
결과를 확인하고 직접 승인한 후 재학습에 반영하는 **수동 트리거** 방식입니다.
|
||||
|
||||
```bash
|
||||
# 기본 실행 (50 trials, 5폴드 Walk-Forward, ~30분)
|
||||
python scripts/tune_hyperparams.py
|
||||
|
||||
# 빠른 테스트 (10 trials, 3폴드, ~5분)
|
||||
python scripts/tune_hyperparams.py --trials 10 --folds 3
|
||||
|
||||
# 베이스라인 측정 없이 탐색만
|
||||
python scripts/tune_hyperparams.py --no-baseline
|
||||
```
|
||||
|
||||
결과는 `models/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를 자동으로 활용합니다.
|
||||
Discord 웹훅을 설정했다면 진입/청산 시 실시간 알림을 받게 됩니다.
|
||||
|
||||
---
|
||||
|
||||
@@ -215,52 +109,105 @@ MLX로 학습한 모델은 ONNX 포맷으로 export되어 Linux 서버에서 `on
|
||||
| 볼린저 밴드 | 하단 이탈 | 상단 돌파 | 1 |
|
||||
| EMA 정배열 (9/21/50) | 정배열 | 역배열 | 1 |
|
||||
| Stochastic RSI | < 20 + K>D | > 80 + K<D | 1 |
|
||||
| 거래량 | 20MA × 1.5 이상 시 신호 강화 | — | 보조 |
|
||||
| 거래량 | 20MA × `VOL_MULTIPLIER` 이상 시 신호 강화 | — | 보조 |
|
||||
|
||||
**진입 조건**: 가중치 합계 ≥ 3 + (거래량 급증 또는 가중치 합계 ≥ 4)
|
||||
**손절/익절**: ATR × 1.5 / ATR × 3.0 (리스크:리워드 = 1:2)
|
||||
**ML 필터**: 예측 확률 ≥ 0.60 이어야 최종 진입
|
||||
**진입 조건**: 가중치 합계 ≥ `SIGNAL_THRESHOLD` + (거래량 급증 또는 가중치 합계 ≥ `SIGNAL_THRESHOLD` + 1)
|
||||
**ADX 필터**: ADX < `ADX_THRESHOLD` 시 횡보장으로 판단, 진입 차단
|
||||
**손절/익절**: ATR × `ATR_SL_MULT` / ATR × `ATR_TP_MULT`
|
||||
|
||||
### 반대 시그널 재진입
|
||||
### 전략 파라미터 조절
|
||||
|
||||
보유 포지션과 반대 방향 신호가 발생하면:
|
||||
1. 기존 포지션 즉시 청산 (미체결 SL/TP 주문 취소 포함)
|
||||
2. ML 필터 통과 시 반대 방향으로 즉시 재진입
|
||||
환경변수로 전략 파라미터를 조절할 수 있습니다. 기본값은 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만으로는 수수료를 이기는 알파를 만들 수 없었습니다. 오더북/청산 데이터 등 새로운 피처 소스에서 알파가 확인되면 재활성화 예정.
|
||||
|
||||
---
|
||||
|
||||
## CI/CD
|
||||
## 리스크 관리
|
||||
|
||||
`main` 브랜치에 푸시하면 Jenkins 파이프라인이 자동으로 실행됩니다.
|
||||
| 설정 | 기본값 | 설명 |
|
||||
|------|--------|------|
|
||||
| `LEVERAGE` | `10` | 레버리지 배수 |
|
||||
| `MAX_SAME_DIRECTION` | `2` | 동일 방향 최대 포지션 수 |
|
||||
| `MARGIN_MAX_RATIO` | `0.50` | 최대 증거금 비율 (잔고 대비) |
|
||||
| `MARGIN_MIN_RATIO` | `0.20` | 최소 증거금 비율 (잔고 대비) |
|
||||
| `MARGIN_DECAY_RATE` | `0.0006` | 잔고 증가 시 증거금 비율 감소 속도 |
|
||||
|
||||
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** — 빌드 서버 로컬 이미지 정리
|
||||
- **일일 손실 한도**: 기준 잔고의 5% 초과 시 당일 거래 중단 (단일 충격 방어)
|
||||
- **듀얼 레이어 킬스위치**: 구조적 엣지 소실에 의한 점진적 계좌 우하향(Slow Bleed) 방어
|
||||
- **동적 증거금**: 잔고가 늘어날수록 비율을 선형으로 줄여 과노출 방지
|
||||
- **포지션 복구**: 봇 재시작 시 기존 포지션 자동 감지 및 상태 복원
|
||||
|
||||
빌드 성공/실패 결과는 Discord로 자동 알림됩니다.
|
||||
### 킬스위치
|
||||
|
||||
일일 손실 한도는 단일 충격 방어용이지, 누적 승률 하락 방어용이 아닙니다. 매일 한도 근처까지 손실을 내고 멈추는 패턴이 반복되면 한 달 뒤 계좌의 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`로 설정 후 재시작하면 해당 심볼 킬스위치 해제. 해제 후 반드시 제거할 것 |
|
||||
|
||||
---
|
||||
|
||||
## 대시보드
|
||||
|
||||
봇 로그를 실시간으로 파싱하여 거래 내역, 수익 통계, 차트를 웹에서 조회할 수 있는 모니터링 대시보드입니다.
|
||||
봇 로그를 실시간으로 파싱하여 거래 내역, 수익 통계, 차트를 웹에서 조회할 수 있습니다.
|
||||
|
||||
### 기술 스택
|
||||
|
||||
- **프론트엔드**: React 18 + Vite + Recharts, Nginx 정적 서빙
|
||||
- **백엔드**: FastAPI + SQLite, 로그 파서(5초 주기 폴링)
|
||||
- **배포**: Docker Compose 3컨테이너 (`dashboard-ui`, `dashboard-api`, `cointrader`)
|
||||
|
||||
### 주요 화면
|
||||
```bash
|
||||
docker compose up -d
|
||||
# 접속: http://<서버IP>:8080
|
||||
```
|
||||
|
||||
| 탭 | 내용 |
|
||||
|----|------|
|
||||
| **Overview** | 총 수익, 승률, 거래 수, 최대 수익/손실 KPI + 일별 PnL 차트 + 누적 수익 곡선 |
|
||||
| **Trades** | 전체 거래 내역 — 진입/청산가, 방향, 레버리지, 기술 지표(RSI, MACD, ATR), SL/TP, 순익 상세 |
|
||||
| **Chart** | XRP/USDT 15분봉 가격 차트 + RSI 지표 + ADX 추세 강도 |
|
||||
| **Trades** | 전체 거래 내역 — 진입/청산가, 방향, 레버리지, 기술 지표, SL/TP, 순익 상세 |
|
||||
| **Chart** | 15분봉 가격 차트 + RSI 지표 + ADX 추세 강도 |
|
||||
|
||||
### API 엔드포인트
|
||||
|
||||
@@ -272,22 +219,118 @@ MLX로 학습한 모델은 ONNX 포맷으로 export되어 Linux 서버에서 `on
|
||||
| `GET /api/stats` | 전체 통계 (총 거래, 승률, 수수료 등) |
|
||||
| `GET /api/candles` | 최근 캔들 + 기술 지표 |
|
||||
| `GET /api/health` | 헬스 체크 |
|
||||
| `POST /api/reset` | DB 초기화 + 로그 파서 재시작 |
|
||||
|
||||
### 실행
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
대시보드는 `http://<서버IP>:8080`에서 접속할 수 있습니다. 봇 로그를 읽기 전용으로 마운트하여 봇 코드를 수정하지 않는 디커플드 설계입니다.
|
||||
|
||||
---
|
||||
|
||||
## 환경변수 전체 레퍼런스
|
||||
|
||||
| 변수 | 기본값 | 필수 | 설명 |
|
||||
|------|--------|:----:|------|
|
||||
| `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)를 참고하세요.
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
cointrader/
|
||||
├── main.py # 진입점 (심볼별 봇 인스턴스 생성 + asyncio.gather)
|
||||
├── src/
|
||||
│ ├── bot.py # 메인 트레이딩 루프 (심볼별 독립 인스턴스)
|
||||
│ ├── config.py # 환경변수 기반 설정 (symbols 리스트 지원)
|
||||
│ ├── exchange.py # Binance Futures API 클라이언트 (심볼별 독립)
|
||||
│ ├── data_stream.py # WebSocket 15분봉 멀티심볼 스트림
|
||||
│ ├── indicators.py # 기술 지표 계산 및 신호 생성
|
||||
│ ├── ml_filter.py # ML 필터 (ONNX 우선 / LightGBM 폴백 / 핫리로드)
|
||||
│ ├── ml_features.py # ML 피처 빌더 (26개 피처)
|
||||
│ ├── mlx_filter.py # MLX 신경망 필터 (Apple Silicon GPU 학습 + ONNX export)
|
||||
│ ├── label_builder.py # 학습 레이블 생성
|
||||
│ ├── dataset_builder.py # 벡터화 데이터셋 빌더 (학습용)
|
||||
│ ├── backtester.py # 백테스트 엔진 (단일 + Walk-Forward)
|
||||
│ ├── mtf_bot.py # MTF Pullback Bot (1h MetaFilter + 15m 3캔들 풀백 + Dry-run)
|
||||
│ ├── risk_manager.py # 공유 리스크 관리 (asyncio.Lock, 동일 방향 제한)
|
||||
│ ├── notifier.py # Discord 웹훅 알림
|
||||
│ └── logger_setup.py # Loguru 로거 설정
|
||||
├── scripts/
|
||||
│ ├── fetch_history.py # 과거 데이터 수집 (--symbol 단일 / --symbols 다중)
|
||||
│ ├── train_model.py # LightGBM 모델 학습 (--symbol 지원)
|
||||
│ ├── train_mlx_model.py # MLX 신경망 학습 (Apple Silicon GPU)
|
||||
│ ├── train_and_deploy.sh # 전체 파이프라인 (--symbol / --all 지원)
|
||||
│ ├── 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 지원)
|
||||
│ └── run_tests.sh # 전체 테스트 실행
|
||||
├── dashboard/
|
||||
│ ├── api/ # FastAPI 백엔드 (로그 파서 + REST API)
|
||||
│ └── ui/ # React 프론트엔드 (Vite + Recharts)
|
||||
├── models/ # 학습된 모델 저장 (심볼별 하위 디렉토리)
|
||||
├── data/ # 과거 데이터 캐시 (심볼별 하위 디렉토리)
|
||||
│ └── trade_history/ # 킬스위치용 실전 거래 이력 (심볼별 JSONL)
|
||||
├── results/
|
||||
│ └── weekly/ # 주간 리포트 JSON 저장
|
||||
├── logs/ # 로그 파일
|
||||
├── docs/plans/ # 설계 문서 및 구현 플랜
|
||||
├── tests/ # 테스트 코드 (15파일, 138개 케이스)
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── Jenkinsfile
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
## 개발 환경 설정
|
||||
|
||||
```bash
|
||||
# 가상환경 생성 및 활성화
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# 의존성 설치
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 환경변수 설정
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
## 테스트
|
||||
|
||||
```bash
|
||||
# 전체 테스트
|
||||
# 전체 테스트 (138개)
|
||||
bash scripts/run_tests.sh
|
||||
|
||||
# 특정 키워드 필터
|
||||
@@ -297,27 +340,134 @@ bash scripts/run_tests.sh -k bot
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
---
|
||||
모든 외부 API(Binance, Discord)는 `unittest.mock.AsyncMock`으로 대체되며, 비동기 테스트는 `@pytest.mark.asyncio`를 사용합니다.
|
||||
|
||||
## 환경변수 레퍼런스
|
||||
## ML 모델 학습
|
||||
|
||||
| 변수 | 기본값 | 설명 |
|
||||
|------|--------|------|
|
||||
| `BINANCE_API_KEY` | — | Binance API 키 |
|
||||
| `BINANCE_API_SECRET` | — | Binance API 시크릿 |
|
||||
| `SYMBOL` | `XRPUSDT` | 거래 심볼 |
|
||||
| `LEVERAGE` | `10` | 레버리지 배수 |
|
||||
| `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) |
|
||||
봇은 모델 파일이 없으면 ML 필터 없이 동작합니다. 모델을 학습하려면:
|
||||
|
||||
---
|
||||
### 전체 파이프라인 (권장)
|
||||
|
||||
## 주의사항
|
||||
```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
|
||||
RUN pip install --no-cache-dir fastapi uvicorn
|
||||
COPY log_parser.py .
|
||||
|
||||
@@ -1,48 +1,86 @@
|
||||
"""
|
||||
dashboard_api.py — 로그 파서가 채운 SQLite DB를 읽어서 대시보드 API 제공
|
||||
dashboard_api.py — 멀티심볼 대시보드 API
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
import signal
|
||||
from fastapi import FastAPI, Query
|
||||
from fastapi import FastAPI, Query, Header, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pathlib import Path
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
|
||||
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.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=CORS_ORIGINS,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.get("/api/position")
|
||||
def get_position():
|
||||
|
||||
@app.get("/api/symbols")
|
||||
def get_symbols():
|
||||
"""활성 심볼 목록 반환."""
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"SELECT * FROM trades WHERE status='OPEN' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
rows = db.execute(
|
||||
"SELECT DISTINCT key FROM bot_status WHERE key LIKE '%:%'"
|
||||
).fetchall()
|
||||
symbols = {r["key"].split(":")[0] for r in rows}
|
||||
return {"symbols": sorted(symbols)}
|
||||
|
||||
|
||||
@app.get("/api/position")
|
||||
def get_position(symbol: Optional[str] = None):
|
||||
with get_db() as db:
|
||||
if symbol:
|
||||
rows = db.execute(
|
||||
"SELECT * FROM trades WHERE status='OPEN' AND symbol=? ORDER BY id DESC",
|
||||
(symbol,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = db.execute(
|
||||
"SELECT * FROM trades WHERE status='OPEN' ORDER BY id DESC"
|
||||
).fetchall()
|
||||
status_rows = db.execute("SELECT key, value FROM bot_status").fetchall()
|
||||
bot = {r["key"]: r["value"] for r in status_rows}
|
||||
return {"position": dict(row) if row else None, "bot": bot}
|
||||
return {"positions": [dict(r) for r in rows], "bot": bot}
|
||||
|
||||
|
||||
@app.get("/api/trades")
|
||||
def get_trades(limit: int = Query(50, ge=1, le=500), offset: int = 0):
|
||||
def get_trades(
|
||||
symbol: Optional[str] = None,
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
with get_db() as db:
|
||||
if symbol:
|
||||
rows = db.execute(
|
||||
"SELECT * FROM trades WHERE status='CLOSED' AND symbol=? ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
(symbol, limit, offset),
|
||||
).fetchall()
|
||||
total = db.execute(
|
||||
"SELECT COUNT(*) as cnt FROM trades WHERE status='CLOSED' AND symbol=?",
|
||||
(symbol,),
|
||||
).fetchone()["cnt"]
|
||||
else:
|
||||
rows = db.execute(
|
||||
"SELECT * FROM trades WHERE status='CLOSED' ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
@@ -50,28 +88,51 @@ def get_trades(limit: int = Query(50, ge=1, le=500), offset: int = 0):
|
||||
total = db.execute("SELECT COUNT(*) as cnt FROM trades WHERE status='CLOSED'").fetchone()["cnt"]
|
||||
return {"trades": [dict(r) for r in rows], "total": total}
|
||||
|
||||
|
||||
@app.get("/api/daily")
|
||||
def get_daily(days: int = Query(30, ge=1, le=365)):
|
||||
def get_daily(symbol: Optional[str] = None, days: int = Query(30, ge=1, le=365)):
|
||||
with get_db() as db:
|
||||
if symbol:
|
||||
rows = db.execute("""
|
||||
SELECT
|
||||
date(exit_time) as date,
|
||||
COUNT(*) as total_trades,
|
||||
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,
|
||||
ROUND(SUM(net_pnl), 4) as net_pnl,
|
||||
ROUND(SUM(commission), 4) as total_fees
|
||||
FROM trades
|
||||
WHERE status='CLOSED' AND exit_time IS NOT NULL
|
||||
GROUP BY date(exit_time)
|
||||
ORDER BY date DESC
|
||||
LIMIT ?
|
||||
SELECT date,
|
||||
SUM(trade_count) as total_trades,
|
||||
SUM(wins) as wins,
|
||||
SUM(losses) as losses,
|
||||
ROUND(SUM(cumulative_pnl), 4) as net_pnl
|
||||
FROM daily_pnl
|
||||
WHERE symbol=?
|
||||
GROUP BY date ORDER BY date DESC LIMIT ?
|
||||
""", (symbol, days)).fetchall()
|
||||
else:
|
||||
rows = db.execute("""
|
||||
SELECT date,
|
||||
SUM(trade_count) as total_trades,
|
||||
SUM(wins) as wins,
|
||||
SUM(losses) as losses,
|
||||
ROUND(SUM(cumulative_pnl), 4) as net_pnl
|
||||
FROM daily_pnl
|
||||
GROUP BY date ORDER BY date DESC LIMIT ?
|
||||
""", (days,)).fetchall()
|
||||
return {"daily": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@app.get("/api/stats")
|
||||
def get_stats():
|
||||
def get_stats(symbol: Optional[str] = None):
|
||||
with get_db() as db:
|
||||
if symbol:
|
||||
row = db.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total_trades,
|
||||
COALESCE(SUM(CASE WHEN net_pnl > 0 THEN 1 ELSE 0 END), 0) as wins,
|
||||
COALESCE(SUM(CASE WHEN net_pnl <= 0 THEN 1 ELSE 0 END), 0) as losses,
|
||||
COALESCE(SUM(net_pnl), 0) as total_pnl,
|
||||
COALESCE(SUM(commission), 0) as total_fees,
|
||||
COALESCE(AVG(net_pnl), 0) as avg_pnl,
|
||||
COALESCE(MAX(net_pnl), 0) as best_trade,
|
||||
COALESCE(MIN(net_pnl), 0) as worst_trade
|
||||
FROM trades WHERE status='CLOSED' AND symbol=?
|
||||
""", (symbol,)).fetchone()
|
||||
else:
|
||||
row = db.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total_trades,
|
||||
@@ -87,50 +148,50 @@ def get_stats():
|
||||
status_rows = db.execute("SELECT key, value FROM bot_status").fetchall()
|
||||
bot = {r["key"]: r["value"] for r in status_rows}
|
||||
result = dict(row)
|
||||
result["current_price"] = bot.get("current_price")
|
||||
if symbol:
|
||||
result["current_price"] = bot.get(f"{symbol}:current_price")
|
||||
result["balance"] = bot.get("balance")
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/candles")
|
||||
def get_candles(limit: int = Query(96, ge=1, le=1000)):
|
||||
def get_candles(symbol: str = Query(...), limit: int = Query(96, ge=1, le=1000)):
|
||||
with get_db() as db:
|
||||
rows = db.execute("SELECT * FROM candles ORDER BY ts DESC LIMIT ?", (limit,)).fetchall()
|
||||
rows = db.execute(
|
||||
"SELECT * FROM candles WHERE symbol=? ORDER BY ts DESC LIMIT ?",
|
||||
(symbol, limit),
|
||||
).fetchall()
|
||||
return {"candles": [dict(r) for r in reversed(rows)]}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
try:
|
||||
with get_db() as db:
|
||||
cnt = db.execute("SELECT COUNT(*) as c FROM candles").fetchone()["c"]
|
||||
return {"status": "ok", "candles_count": cnt}
|
||||
except Exception as e:
|
||||
return {"status": "error", "detail": str(e)}
|
||||
except Exception:
|
||||
return {"status": "error", "detail": "database unavailable"}
|
||||
|
||||
|
||||
@app.post("/api/reset")
|
||||
def reset_db():
|
||||
"""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:
|
||||
for table in ["trades", "daily_pnl", "parse_state", "bot_status", "candles"]:
|
||||
db.execute(f"DELETE FROM {table}")
|
||||
db.commit()
|
||||
|
||||
# 파서 프로세스 재시작 (entrypoint.sh의 백그라운드 프로세스)
|
||||
import subprocess, os, signal
|
||||
# 기존 파서 종료 (pkill 대신 Python-native 방식)
|
||||
for pid_str in os.listdir("/proc") if os.path.isdir("/proc") else []:
|
||||
if not pid_str.isdigit():
|
||||
continue
|
||||
# C2: PID file + SIGHUP으로 파서에 재파싱 요청 (프로세스 재시작 불필요)
|
||||
try:
|
||||
with open(f"/proc/{pid_str}/cmdline", "r") as f:
|
||||
cmdline = f.read()
|
||||
if "log_parser.py" in cmdline and str(os.getpid()) != pid_str:
|
||||
os.kill(int(pid_str), signal.SIGTERM)
|
||||
except (FileNotFoundError, PermissionError, ProcessLookupError, OSError):
|
||||
with open(PARSER_PID_FILE) as f:
|
||||
pid = int(f.read().strip())
|
||||
os.kill(pid, signal.SIGHUP)
|
||||
except (FileNotFoundError, ValueError, ProcessLookupError, OSError):
|
||||
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}"
|
||||
|
||||
# 로그 파서를 백그라운드로 실행
|
||||
python log_parser.py &
|
||||
python -u log_parser.py &
|
||||
PARSER_PID=$!
|
||||
echo "Log parser started (PID: $PARSER_PID)"
|
||||
|
||||
# 파서가 기존 로그를 처리할 시간 부여
|
||||
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"
|
||||
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 os
|
||||
import json
|
||||
import threading
|
||||
import signal
|
||||
import sys
|
||||
from datetime import datetime, date
|
||||
from pathlib import Path
|
||||
|
||||
@@ -19,37 +20,33 @@ from pathlib import Path
|
||||
LOG_DIR = os.environ.get("LOG_DIR", "/app/logs")
|
||||
DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db")
|
||||
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "5")) # 초
|
||||
PID_FILE = os.environ.get("PARSER_PID_FILE", "/tmp/parser.pid")
|
||||
|
||||
# ── 정규식 패턴 (실제 봇 로그 형식 기준) ──────────────────────────
|
||||
# ── 정규식 패턴 (멀티심볼 [SYMBOL] 프리픽스 포함) ─────────────────
|
||||
PATTERNS = {
|
||||
# 신호: HOLD | 현재가: 1.3889 USDT
|
||||
"signal": re.compile(
|
||||
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||
r".*신호: (?P<signal>\w+) \| 현재가: (?P<price>[\d.]+) USDT"
|
||||
r".*\[(?P<symbol>\w+)\] 신호: (?P<signal>\w+) \|.*현재가: (?P<price>[\d.]+)"
|
||||
),
|
||||
|
||||
# ADX: 24.4
|
||||
"adx": re.compile(
|
||||
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||
r".*ADX: (?P<adx>[\d.]+)"
|
||||
r".*\[(?P<symbol>\w+)\] ADX: (?P<adx>[\d.]+)"
|
||||
),
|
||||
|
||||
# OI=261103765.6, OI변화율=0.000692, 펀딩비=0.000039
|
||||
"microstructure": re.compile(
|
||||
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||
r".*OI=(?P<oi>[\d.]+), OI변화율=(?P<oi_change>[-\d.]+), 펀딩비=(?P<funding>[-\d.]+)"
|
||||
r".*\[(?P<symbol>\w+)\] OI=(?P<oi>[\d.]+), OI변화율=(?P<oi_change>[-\d.]+), 펀딩비=(?P<funding>[-\d.]+)"
|
||||
),
|
||||
|
||||
# 기존 포지션 복구: SHORT | 진입가=1.4126 | 수량=86.8
|
||||
"position_recover": re.compile(
|
||||
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||
r".*기존 포지션 복구: (?P<direction>\w+) \| 진입가=(?P<entry_price>[\d.]+) \| 수량=(?P<qty>[\d.]+)"
|
||||
r".*\[(?P<symbol>\w+)\] 기존 포지션 복구: (?P<direction>\w+) \| 진입가=(?P<entry_price>[\d.]+) \| 수량=(?P<qty>[\d.]+)"
|
||||
),
|
||||
|
||||
# SHORT 진입: 가격=1.3940, 수량=86.8, SL=1.4040, TP=1.3840, RSI=42.31, MACD_H=-0.001234, ATR=0.005678
|
||||
"entry": re.compile(
|
||||
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||
r".*(?P<direction>SHORT|LONG) 진입: "
|
||||
r".*\[(?P<symbol>\w+)\] (?P<direction>SHORT|LONG) 진입: "
|
||||
r"가격=(?P<entry_price>[\d.]+), "
|
||||
r"수량=(?P<qty>[\d.]+), "
|
||||
r"SL=(?P<sl>[\d.]+), "
|
||||
@@ -59,35 +56,36 @@ PATTERNS = {
|
||||
r"(?:, ATR=(?P<atr>[\d.]+))?"
|
||||
),
|
||||
|
||||
# 청산 감지(MANUAL): exit=1.3782, rp=+2.9859, commission=0.0598, net_pnl=+2.9261
|
||||
"close_detect": re.compile(
|
||||
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||
r".*청산 감지\((?P<reason>\w+)\):\s*"
|
||||
r".*\[(?P<symbol>\w+)\] 청산 감지\((?P<reason>\w+)\):\s*"
|
||||
r"exit=(?P<exit_price>[\d.]+),\s*"
|
||||
r"rp=(?P<expected>[+\-\d.]+),\s*"
|
||||
r"commission=(?P<commission>[\d.]+),\s*"
|
||||
r"net_pnl=(?P<net_pnl>[+\-\d.]+)"
|
||||
),
|
||||
|
||||
# 오늘 누적 PnL: 2.9261 USDT
|
||||
"daily_pnl": re.compile(
|
||||
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||
r".*오늘 누적 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.]+)%\)"
|
||||
),
|
||||
|
||||
# 봇 시작: XRPUSDT, 레버리지 10x
|
||||
"bot_start": re.compile(
|
||||
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"
|
||||
),
|
||||
|
||||
# 기준 잔고 설정: 24.46 USDT
|
||||
"balance": re.compile(
|
||||
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||
r".*기준 잔고 설정: (?P<balance>[\d.]+) USDT"
|
||||
r".*\[(?P<symbol>\w+)\] 기준 잔고 설정: (?P<balance>[\d.]+) USDT"
|
||||
),
|
||||
|
||||
# ML 필터 로드
|
||||
"ml_filter": re.compile(
|
||||
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||
r".*ML 필터 로드.*임계값=(?P<threshold>[\d.]+)"
|
||||
@@ -98,23 +96,32 @@ PATTERNS = {
|
||||
class LogParser:
|
||||
def __init__(self):
|
||||
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.execute("PRAGMA journal_mode=WAL")
|
||||
self.conn.execute("PRAGMA busy_timeout=5000")
|
||||
self._init_db()
|
||||
|
||||
# 상태 추적
|
||||
self._file_positions = {} # {파일경로: 마지막 읽은 위치}
|
||||
self._current_position = None # 현재 열린 포지션 정보
|
||||
self._pending_candle = {} # 타임스탬프 기준으로 지표를 모아두기
|
||||
self._bot_config = {"symbol": "XRPUSDT", "leverage": 10}
|
||||
self._file_positions = {}
|
||||
self._current_positions = {} # {symbol: position_dict}
|
||||
self._pending_candles = {} # {symbol: {ts_key: {data}}}
|
||||
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):
|
||||
self.conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS trades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL DEFAULT 'XRPUSDT',
|
||||
symbol TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
entry_time TEXT NOT NULL,
|
||||
exit_time TEXT,
|
||||
@@ -134,27 +141,32 @@ class LogParser:
|
||||
net_pnl REAL,
|
||||
status TEXT NOT NULL DEFAULT 'OPEN',
|
||||
close_reason TEXT,
|
||||
extra TEXT
|
||||
extra TEXT,
|
||||
UNIQUE(symbol, entry_time, direction)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS candles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL UNIQUE,
|
||||
symbol TEXT NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
signal TEXT,
|
||||
adx REAL,
|
||||
oi REAL,
|
||||
oi_change REAL,
|
||||
funding_rate REAL
|
||||
funding_rate REAL,
|
||||
UNIQUE(symbol, ts)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_pnl (
|
||||
date TEXT PRIMARY KEY,
|
||||
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
|
||||
last_updated TEXT,
|
||||
PRIMARY KEY(symbol, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bot_status (
|
||||
@@ -168,23 +180,47 @@ class LogParser:
|
||||
position INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_candles_ts ON candles(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_candles_symbol_ts ON candles(symbol, ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status);
|
||||
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._migrate_deduplicate()
|
||||
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):
|
||||
"""이전 파싱 위치 복원"""
|
||||
rows = self.conn.execute("SELECT filepath, position FROM parse_state").fetchall()
|
||||
self._file_positions = {r["filepath"]: r["position"] for r in rows}
|
||||
|
||||
# 현재 열린 포지션 복원
|
||||
row = self.conn.execute(
|
||||
"SELECT * FROM trades WHERE status='OPEN' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if row:
|
||||
self._current_position = dict(row)
|
||||
# 심볼별 열린 포지션 복원
|
||||
open_trades = self.conn.execute(
|
||||
"SELECT * FROM trades WHERE status='OPEN' ORDER BY id DESC"
|
||||
).fetchall()
|
||||
for row in open_trades:
|
||||
sym = row["symbol"]
|
||||
if sym not in self._current_positions:
|
||||
self._current_positions[sym] = dict(row)
|
||||
|
||||
def _save_position(self, filepath, pos):
|
||||
self.conn.execute(
|
||||
@@ -192,7 +228,47 @@ class LogParser:
|
||||
"ON CONFLICT(filepath) DO UPDATE SET position=?",
|
||||
(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.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):
|
||||
now = datetime.now().isoformat()
|
||||
@@ -201,12 +277,12 @@ class LogParser:
|
||||
"ON CONFLICT(key) DO UPDATE SET value=?, updated_at=?",
|
||||
(key, str(value), now, str(value), now)
|
||||
)
|
||||
self.conn.commit()
|
||||
self._dirty = True
|
||||
|
||||
# ── 메인 루프 ────────────────────────────────────────────────
|
||||
def run(self):
|
||||
print(f"[LogParser] 시작 — LOG_DIR={LOG_DIR}, DB={DB_PATH}, 폴링={POLL_INTERVAL}s")
|
||||
while True:
|
||||
while not self._shutdown:
|
||||
try:
|
||||
self._scan_logs()
|
||||
except Exception as e:
|
||||
@@ -214,14 +290,11 @@ class LogParser:
|
||||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
def _scan_logs(self):
|
||||
"""로그 파일 목록을 가져와서 새 줄 파싱"""
|
||||
# 날짜 형식 (bot_2026-03-01.log) + 현재 형식 (bot.log) 모두 스캔
|
||||
log_files = sorted(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)
|
||||
log_files = sorted(set(glob.glob(os.path.join(LOG_DIR, "bot*.log"))))
|
||||
for filepath in log_files:
|
||||
self._parse_file(filepath)
|
||||
self._batch_commit()
|
||||
self._cleanup_pending_candles()
|
||||
|
||||
def _parse_file(self, filepath):
|
||||
last_pos = self._file_positions.get(filepath, 0)
|
||||
@@ -231,12 +304,11 @@ class LogParser:
|
||||
except OSError:
|
||||
return
|
||||
|
||||
# 파일이 줄었으면 (로테이션) 처음부터
|
||||
if file_size < last_pos:
|
||||
last_pos = 0
|
||||
|
||||
if file_size == last_pos:
|
||||
return # 새 내용 없음
|
||||
return
|
||||
|
||||
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
||||
f.seek(last_pos)
|
||||
@@ -257,11 +329,9 @@ class LogParser:
|
||||
# 봇 시작
|
||||
m = PATTERNS["bot_start"].search(line)
|
||||
if m:
|
||||
self._bot_config["symbol"] = m.group("symbol")
|
||||
self._bot_config["leverage"] = int(m.group("leverage"))
|
||||
self._set_status("symbol", m.group("symbol"))
|
||||
self._set_status("leverage", m.group("leverage"))
|
||||
self._set_status("last_start", m.group("ts"))
|
||||
symbol = m.group("symbol")
|
||||
self._set_status(f"{symbol}:leverage", m.group("leverage"))
|
||||
self._set_status(f"{symbol}:last_start", m.group("ts"))
|
||||
return
|
||||
|
||||
# 잔고
|
||||
@@ -277,11 +347,21 @@ class LogParser:
|
||||
self._set_status("ml_threshold", m.group("threshold"))
|
||||
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)
|
||||
if m:
|
||||
self._handle_entry(
|
||||
ts=m.group("ts"),
|
||||
symbol=m.group("symbol"),
|
||||
direction=m.group("direction"),
|
||||
entry_price=float(m.group("entry_price")),
|
||||
qty=float(m.group("qty")),
|
||||
@@ -289,11 +369,12 @@ class LogParser:
|
||||
)
|
||||
return
|
||||
|
||||
# 포지션 진입: SHORT 진입: 가격=X, 수량=Y, SL=Z, TP=W, RSI=R, MACD_H=M, ATR=A
|
||||
# 포지션 진입
|
||||
m = PATTERNS["entry"].search(line)
|
||||
if m:
|
||||
self._handle_entry(
|
||||
ts=m.group("ts"),
|
||||
symbol=m.group("symbol"),
|
||||
direction=m.group("direction"),
|
||||
entry_price=float(m.group("entry_price")),
|
||||
qty=float(m.group("qty")),
|
||||
@@ -308,10 +389,13 @@ class LogParser:
|
||||
# OI/펀딩비 (캔들 데이터에 합침)
|
||||
m = PATTERNS["microstructure"].search(line)
|
||||
if m:
|
||||
ts_key = m.group("ts")[:16] # 분 단위로 그룹
|
||||
if ts_key not in self._pending_candle:
|
||||
self._pending_candle[ts_key] = {}
|
||||
self._pending_candle[ts_key].update({
|
||||
symbol = m.group("symbol")
|
||||
ts_key = m.group("ts")[:16]
|
||||
if symbol not in self._pending_candles:
|
||||
self._pending_candles[symbol] = {}
|
||||
if ts_key not in self._pending_candles[symbol]:
|
||||
self._pending_candles[symbol][ts_key] = {}
|
||||
self._pending_candles[symbol][ts_key].update({
|
||||
"oi": float(m.group("oi")),
|
||||
"oi_change": float(m.group("oi_change")),
|
||||
"funding": float(m.group("funding")),
|
||||
@@ -321,37 +405,41 @@ class LogParser:
|
||||
# ADX
|
||||
m = PATTERNS["adx"].search(line)
|
||||
if m:
|
||||
symbol = m.group("symbol")
|
||||
ts_key = m.group("ts")[:16]
|
||||
if ts_key not in self._pending_candle:
|
||||
self._pending_candle[ts_key] = {}
|
||||
self._pending_candle[ts_key]["adx"] = float(m.group("adx"))
|
||||
if symbol not in self._pending_candles:
|
||||
self._pending_candles[symbol] = {}
|
||||
if ts_key not in self._pending_candles[symbol]:
|
||||
self._pending_candles[symbol][ts_key] = {}
|
||||
self._pending_candles[symbol][ts_key]["adx"] = float(m.group("adx"))
|
||||
return
|
||||
|
||||
# 신호 + 현재가 → 캔들 저장
|
||||
m = PATTERNS["signal"].search(line)
|
||||
if m:
|
||||
symbol = m.group("symbol")
|
||||
ts = m.group("ts")
|
||||
ts_key = ts[:16]
|
||||
price = float(m.group("price"))
|
||||
signal = m.group("signal")
|
||||
extra = self._pending_candle.pop(ts_key, {})
|
||||
extra = self._pending_candles.get(symbol, {}).pop(ts_key, {})
|
||||
|
||||
self._set_status("current_price", str(price))
|
||||
self._set_status("current_signal", signal)
|
||||
self._set_status("last_candle_time", ts)
|
||||
self._set_status(f"{symbol}:current_price", str(price))
|
||||
self._set_status(f"{symbol}:current_signal", signal)
|
||||
self._set_status(f"{symbol}:last_candle_time", ts)
|
||||
|
||||
try:
|
||||
self.conn.execute(
|
||||
"""INSERT INTO candles(ts, price, signal, adx, oi, oi_change, funding_rate)
|
||||
VALUES(?,?,?,?,?,?,?)
|
||||
ON CONFLICT(ts) DO UPDATE SET
|
||||
"""INSERT INTO candles(symbol, ts, price, signal, adx, oi, oi_change, funding_rate)
|
||||
VALUES(?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(symbol, ts) DO UPDATE SET
|
||||
price=?, signal=?, adx=?, oi=?, oi_change=?, funding_rate=?""",
|
||||
(ts, price, signal,
|
||||
(symbol, ts, price, signal,
|
||||
extra.get("adx"), extra.get("oi"), extra.get("oi_change"), extra.get("funding"),
|
||||
price, signal,
|
||||
extra.get("adx"), extra.get("oi"), extra.get("oi_change"), extra.get("funding")),
|
||||
)
|
||||
self.conn.commit()
|
||||
self._dirty = True
|
||||
except Exception as e:
|
||||
print(f"[LogParser] 캔들 저장 에러: {e}")
|
||||
return
|
||||
@@ -361,6 +449,7 @@ class LogParser:
|
||||
if m:
|
||||
self._handle_close(
|
||||
ts=m.group("ts"),
|
||||
symbol=m.group("symbol"),
|
||||
exit_price=float(m.group("exit_price")),
|
||||
expected_pnl=float(m.group("expected")),
|
||||
commission=float(m.group("commission")),
|
||||
@@ -372,37 +461,42 @@ class LogParser:
|
||||
# 일일 누적 PnL
|
||||
m = PATTERNS["daily_pnl"].search(line)
|
||||
if m:
|
||||
symbol = m.group("symbol")
|
||||
ts = m.group("ts")
|
||||
day = ts[:10]
|
||||
pnl = float(m.group("pnl"))
|
||||
self.conn.execute(
|
||||
"""INSERT INTO daily_pnl(date, cumulative_pnl, last_updated)
|
||||
VALUES(?,?,?)
|
||||
ON CONFLICT(date) DO UPDATE SET cumulative_pnl=?, last_updated=?""",
|
||||
(day, pnl, ts, pnl, ts)
|
||||
"""INSERT INTO daily_pnl(symbol, date, cumulative_pnl, last_updated)
|
||||
VALUES(?,?,?,?)
|
||||
ON CONFLICT(symbol, date) DO UPDATE SET cumulative_pnl=?, last_updated=?""",
|
||||
(symbol, day, pnl, ts, pnl, ts)
|
||||
)
|
||||
self.conn.commit()
|
||||
self._set_status("daily_pnl", str(pnl))
|
||||
self._dirty = True
|
||||
self._set_status(f"{symbol}:daily_pnl", str(pnl))
|
||||
return
|
||||
|
||||
# ── 포지션 진입 핸들러 ───────────────────────────────────────
|
||||
def _handle_entry(self, ts, direction, entry_price, qty,
|
||||
def _handle_entry(self, ts, symbol, direction, entry_price, qty,
|
||||
leverage=None, sl=None, tp=None, is_recovery=False,
|
||||
rsi=None, macd_hist=None, atr=None):
|
||||
if leverage is None:
|
||||
leverage = self._bot_config.get("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 포지션이 이미 있으면 스킵
|
||||
# (봇은 동시에 같은 방향 포지션을 2개 이상 열지 않음)
|
||||
if self._current_position and self._current_position.get("direction") == direction:
|
||||
# 중복 체크 — 같은 심볼+방향의 OPEN 포지션이 이미 있으면 스킵
|
||||
current = self._current_positions.get(symbol)
|
||||
if current and current.get("direction") == direction:
|
||||
return
|
||||
|
||||
existing = self.conn.execute(
|
||||
"SELECT id, entry_price FROM trades WHERE status='OPEN' AND direction=?",
|
||||
(direction,),
|
||||
"SELECT id, entry_price FROM trades WHERE status='OPEN' AND symbol=? AND direction=?",
|
||||
(symbol, direction),
|
||||
).fetchone()
|
||||
if existing:
|
||||
self._current_position = {
|
||||
self._current_positions[symbol] = {
|
||||
"id": existing["id"],
|
||||
"direction": direction,
|
||||
"entry_price": existing["entry_price"],
|
||||
@@ -411,38 +505,39 @@ class LogParser:
|
||||
return
|
||||
|
||||
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)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(self._bot_config.get("symbol", "XRPUSDT"), direction, ts,
|
||||
(symbol, direction, ts,
|
||||
entry_price, qty, leverage, sl, tp, "OPEN",
|
||||
json.dumps({"recovery": is_recovery}),
|
||||
rsi, macd_hist, atr),
|
||||
)
|
||||
self.conn.commit()
|
||||
self._current_position = {
|
||||
self._dirty = True
|
||||
self._current_positions[symbol] = {
|
||||
"id": cur.lastrowid,
|
||||
"direction": direction,
|
||||
"entry_price": entry_price,
|
||||
"entry_time": ts,
|
||||
}
|
||||
self._set_status("position_status", "OPEN")
|
||||
self._set_status("position_direction", direction)
|
||||
self._set_status("position_entry_price", str(entry_price))
|
||||
print(f"[LogParser] 포지션 진입: {direction} @ {entry_price} (recovery={is_recovery})")
|
||||
self._set_status(f"{symbol}:position_status", "OPEN")
|
||||
self._set_status(f"{symbol}:position_direction", direction)
|
||||
self._set_status(f"{symbol}:position_entry_price", str(entry_price))
|
||||
print(f"[LogParser] {symbol} 포지션 진입: {direction} @ {entry_price} (recovery={is_recovery})")
|
||||
|
||||
# ── 포지션 청산 핸들러 ───────────────────────────────────────
|
||||
def _handle_close(self, ts, exit_price, expected_pnl, commission, net_pnl, reason):
|
||||
# 모든 OPEN 거래를 닫음 (봇은 동시에 1개 포지션만 보유)
|
||||
def _handle_close(self, ts, symbol, exit_price, expected_pnl, commission, net_pnl, reason):
|
||||
# 해당 심볼의 OPEN 거래만 닫음
|
||||
open_trades = self.conn.execute(
|
||||
"SELECT id FROM trades WHERE status='OPEN' ORDER BY id DESC"
|
||||
"SELECT id FROM trades WHERE status='OPEN' AND symbol=? ORDER BY id DESC",
|
||||
(symbol,),
|
||||
).fetchall()
|
||||
|
||||
if not open_trades:
|
||||
print(f"[LogParser] 경고: 청산 감지했으나 열린 포지션 없음")
|
||||
print(f"[LogParser] 경고: {symbol} 청산 감지했으나 열린 포지션 없음")
|
||||
self._current_positions.pop(symbol, None)
|
||||
return
|
||||
|
||||
# 가장 최근 OPEN에 실제 PnL 기록
|
||||
primary_id = open_trades[0]["id"]
|
||||
self.conn.execute(
|
||||
"""UPDATE trades SET
|
||||
@@ -455,36 +550,46 @@ class LogParser:
|
||||
reason, primary_id)
|
||||
)
|
||||
|
||||
# 나머지 OPEN 거래는 중복이므로 삭제
|
||||
self._dirty = True
|
||||
|
||||
if len(open_trades) > 1:
|
||||
stale_ids = [r["id"] for r in open_trades[1:]]
|
||||
self.conn.execute(
|
||||
f"DELETE FROM trades WHERE id IN ({','.join('?' * len(stale_ids))})",
|
||||
stale_ids,
|
||||
)
|
||||
print(f"[LogParser] 중복 OPEN 거래 {len(stale_ids)}건 삭제")
|
||||
print(f"[LogParser] {symbol} 중복 OPEN 거래 {len(stale_ids)}건 삭제")
|
||||
|
||||
# 일별 요약 갱신
|
||||
# 심볼별 일별 요약 (trades 테이블에서 재계산 — idempotent)
|
||||
day = ts[:10]
|
||||
win = 1 if net_pnl > 0 else 0
|
||||
loss = 1 if net_pnl <= 0 else 0
|
||||
row = self.conn.execute(
|
||||
"""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(
|
||||
"""INSERT INTO daily_pnl(date, cumulative_pnl, trade_count, wins, losses, last_updated)
|
||||
VALUES(?, ?, 1, ?, ?, ?)
|
||||
ON CONFLICT(date) DO UPDATE SET
|
||||
trade_count = trade_count + 1,
|
||||
wins = wins + ?,
|
||||
losses = losses + ?,
|
||||
last_updated = ?""",
|
||||
(day, net_pnl, win, loss, ts, win, loss, ts)
|
||||
"""INSERT INTO daily_pnl(symbol, date, trade_count, wins, losses, last_updated)
|
||||
VALUES(?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(symbol, date) DO UPDATE SET
|
||||
trade_count=?, wins=?, losses=?, last_updated=?""",
|
||||
(symbol, day, row["cnt"], row["wins"], row["losses"], ts,
|
||||
row["cnt"], row["wins"], row["losses"], ts),
|
||||
)
|
||||
self.conn.commit()
|
||||
self._dirty = True
|
||||
|
||||
self._set_status("position_status", "NONE")
|
||||
print(f"[LogParser] 포지션 청산: {reason} @ {exit_price}, PnL={net_pnl}")
|
||||
self._current_position = None
|
||||
self._set_status(f"{symbol}:position_status", "NONE")
|
||||
print(f"[LogParser] {symbol} 포지션 청산: {reason} @ {exit_price}, PnL={net_pnl}")
|
||||
self._current_positions.pop(symbol, None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = LogParser()
|
||||
try:
|
||||
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
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
COPY package.json package-lock.json .
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
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 프록시 → 백엔드 컨테이너
|
||||
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 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
@@ -266,40 +266,57 @@ export default function App() {
|
||||
const [isLive, setIsLive] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState(null);
|
||||
|
||||
const [symbols, setSymbols] = useState([]);
|
||||
const symbolsRef = useRef([]);
|
||||
const [selectedSymbol, setSelectedSymbol] = useState(null); // null = ALL
|
||||
|
||||
const [stats, setStats] = useState({
|
||||
total_trades: 0, wins: 0, losses: 0,
|
||||
total_pnl: 0, total_fees: 0, avg_pnl: 0,
|
||||
best_trade: 0, worst_trade: 0,
|
||||
});
|
||||
const [position, setPosition] = useState(null);
|
||||
const [positions, setPositions] = useState([]);
|
||||
const [botStatus, setBotStatus] = useState({});
|
||||
const [trades, setTrades] = useState([]);
|
||||
const [tradesTotal, setTradesTotal] = useState(0);
|
||||
const [tradesPage, setTradesPage] = useState(0);
|
||||
const [daily, setDaily] = useState([]);
|
||||
const [candles, setCandles] = useState([]);
|
||||
|
||||
/* ── 데이터 폴링 ─────────────────────────────────────────── */
|
||||
const fetchAll = useCallback(async () => {
|
||||
const [sRes, pRes, tRes, dRes, cRes] = await Promise.all([
|
||||
api("/stats"),
|
||||
api("/position"),
|
||||
api("/trades?limit=50"),
|
||||
api("/daily?days=30"),
|
||||
api("/candles?limit=96"),
|
||||
const sym = selectedSymbol ? `?symbol=${selectedSymbol}` : "";
|
||||
const symRequired = selectedSymbol || symbolsRef.current[0] || "XRPUSDT";
|
||||
|
||||
const [symRes, sRes, pRes, tRes, dRes, cRes] = await Promise.all([
|
||||
api("/symbols"),
|
||||
api(`/stats${sym}`),
|
||||
api(`/position${sym}`),
|
||||
api(`/trades${sym}${sym ? "&" : "?"}limit=50&offset=${tradesPage * 50}`),
|
||||
api(`/daily${sym}`),
|
||||
api(`/candles?symbol=${symRequired}&limit=96`),
|
||||
]);
|
||||
|
||||
if (symRes?.symbols) {
|
||||
symbolsRef.current = symRes.symbols;
|
||||
setSymbols(symRes.symbols);
|
||||
}
|
||||
if (sRes && sRes.total_trades !== undefined) {
|
||||
setStats(sRes);
|
||||
setIsLive(true);
|
||||
setLastUpdate(new Date());
|
||||
}
|
||||
if (pRes) {
|
||||
setPosition(pRes.position);
|
||||
setPositions(pRes.positions || []);
|
||||
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 (cRes?.candles) setCandles(cRes.candles);
|
||||
}, []);
|
||||
}, [selectedSymbol, tradesPage]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
@@ -328,8 +345,9 @@ export default function App() {
|
||||
const candleLabels = candles.map((c) => fmtTime(c.ts));
|
||||
|
||||
/* ── 현재 가격 (봇 상태 또는 마지막 캔들) ──────────────────── */
|
||||
const currentPrice = botStatus.current_price
|
||||
|| (candles.length ? candles[candles.length - 1].price : null);
|
||||
const currentPrice = selectedSymbol
|
||||
? (botStatus[`${selectedSymbol}:current_price`] || (candles.length ? candles[candles.length - 1].price : null))
|
||||
: (candles.length ? candles[candles.length - 1].price : null);
|
||||
|
||||
/* ── 공통 차트 축 스타일 ─────────────────────────────────── */
|
||||
const axisStyle = {
|
||||
@@ -371,7 +389,10 @@ export default function App() {
|
||||
fontSize: 10, color: S.text3, letterSpacing: 2,
|
||||
textTransform: "uppercase", fontFamily: S.mono,
|
||||
}}>
|
||||
{isLive ? "Live" : "Connecting…"} · XRP/USDT
|
||||
{isLive ? "Live" : "Connecting…"}
|
||||
{selectedSymbol
|
||||
? ` · ${selectedSymbol.replace("USDT", "/USDT")}`
|
||||
: ` · ${symbols.length} symbols`}
|
||||
{currentPrice && (
|
||||
<span style={{ color: "rgba(255,255,255,0.5)", marginLeft: 8 }}>
|
||||
{fmt(currentPrice)}
|
||||
@@ -384,35 +405,86 @@ export default function App() {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* 오픈 포지션 */}
|
||||
{position && (
|
||||
<div style={{
|
||||
{/* 오픈 포지션 — 복수 표시 */}
|
||||
{positions.length > 0 && (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{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={{
|
||||
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,
|
||||
padding: "12px 18px",
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 9, color: S.text3, letterSpacing: 1.2,
|
||||
fontFamily: S.mono, marginBottom: 4,
|
||||
}}>OPEN POSITION</div>
|
||||
<div style={{ display: "flex", gap: 16, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Badge
|
||||
bg={position.direction === "SHORT" ? "rgba(239,68,68,0.12)" : "rgba(52,211,153,0.12)"}
|
||||
color={position.direction === "SHORT" ? S.red : S.green}
|
||||
>
|
||||
{position.direction} {position.leverage || 10}x
|
||||
</Badge>
|
||||
<span style={{ fontSize: 16, fontWeight: 700, fontFamily: S.mono }}>
|
||||
{fmt(position.entry_price)}
|
||||
</span>
|
||||
<span style={{ fontSize: 10, color: S.text3, fontFamily: S.mono }}>
|
||||
SL {fmt(position.sl)} · TP {fmt(position.tp)}
|
||||
</span>
|
||||
<div style={{ fontSize: 9, color: S.text3, letterSpacing: 1.2, fontFamily: S.mono, marginBottom: 4 }}>
|
||||
{(pos.symbol || "").replace("USDT", "/USDT")}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||
<Badge
|
||||
bg={isShort ? "rgba(239,68,68,0.12)" : "rgba(52,211,153,0.12)"}
|
||||
color={isShort ? S.red : S.green}
|
||||
>
|
||||
{pos.direction} {pos.leverage || 10}x
|
||||
</Badge>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, fontFamily: S.mono }}>
|
||||
{fmt(pos.entry_price)}
|
||||
</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 style={{
|
||||
display: "flex", gap: 4, marginBottom: 12,
|
||||
background: "rgba(255,255,255,0.02)", borderRadius: 12,
|
||||
padding: 4, width: "fit-content",
|
||||
}}>
|
||||
<button
|
||||
onClick={() => { setSelectedSymbol(null); setTradesPage(0); }}
|
||||
style={{
|
||||
background: selectedSymbol === null ? "rgba(99,102,241,0.15)" : "transparent",
|
||||
border: "none",
|
||||
color: selectedSymbol === null ? S.indigo : S.text3,
|
||||
padding: "6px 14px", borderRadius: 8, cursor: "pointer",
|
||||
fontSize: 11, fontWeight: 600, fontFamily: S.mono,
|
||||
}}
|
||||
>ALL</button>
|
||||
{symbols.map((sym) => (
|
||||
<button
|
||||
key={sym}
|
||||
onClick={() => { setSelectedSymbol(sym); setTradesPage(0); }}
|
||||
style={{
|
||||
background: selectedSymbol === sym ? "rgba(99,102,241,0.15)" : "transparent",
|
||||
border: "none",
|
||||
color: selectedSymbol === sym ? S.indigo : S.text3,
|
||||
padding: "6px 14px", borderRadius: 8, cursor: "pointer",
|
||||
fontSize: 11, fontWeight: 600, fontFamily: S.mono,
|
||||
}}
|
||||
>{sym.replace("USDT", "")}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ═══ 탭 ═════════════════════════════════════════════ */}
|
||||
<div style={{
|
||||
display: "flex", gap: 4, marginBottom: 24,
|
||||
@@ -527,7 +599,7 @@ export default function App() {
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
전체 {trades.length}건 보기 →
|
||||
전체 {tradesTotal}건 보기 →
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -540,7 +612,7 @@ export default function App() {
|
||||
fontSize: 10, color: S.text3, letterSpacing: 1.2,
|
||||
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 12,
|
||||
}}>
|
||||
전체 거래 내역 ({trades.length}건)
|
||||
전체 거래 내역 ({tradesTotal}건)
|
||||
</div>
|
||||
{trades.map((t) => (
|
||||
<TradeRow
|
||||
@@ -550,13 +622,45 @@ export default function App() {
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* ═══ CHART ══════════════════════════════════════════ */}
|
||||
{tab === "chart" && (
|
||||
<div>
|
||||
<ChartBox title="XRP/USDT 15m 가격">
|
||||
<ChartBox title={`${(selectedSymbol || symbols[0] || "XRP").replace("USDT", "")}/USDT 15m 가격`}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<AreaChart data={candles.map((c) => ({ ts: fmtTime(c.ts), price: c.price || c.close }))}>
|
||||
<defs>
|
||||
@@ -581,17 +685,22 @@ export default function App() {
|
||||
display: "grid", gridTemplateColumns: "1fr 1fr",
|
||||
gap: 10, marginTop: 12,
|
||||
}}>
|
||||
<ChartBox title="RSI">
|
||||
<ChartBox title="OI 변화율">
|
||||
<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)" />
|
||||
<XAxis dataKey="ts" {...axisStyle} interval="preserveStartEnd" />
|
||||
<YAxis domain={[0, 100]} {...axisStyle} />
|
||||
<YAxis {...axisStyle} />
|
||||
<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={() => 30} stroke="rgba(139,92,246,0.2)" strokeDasharray="4 4" dot={false} name="과매도" />
|
||||
<Line type="monotone" dataKey="rsi" name="RSI" stroke={S.amber} strokeWidth={1.5} dot={false} />
|
||||
</LineChart>
|
||||
<Line type="monotone" dataKey={() => 0} stroke="rgba(255,255,255,0.1)" 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} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartBox>
|
||||
|
||||
@@ -630,10 +739,16 @@ export default function App() {
|
||||
</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const key = prompt("Reset API Key를 입력하세요:");
|
||||
if (!key) return;
|
||||
if (!confirm("DB를 초기화하고 로그를 처음부터 다시 파싱합니다. 계속할까요?")) return;
|
||||
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(); }
|
||||
else if (r.status === 403) alert("API Key가 올바르지 않습니다.");
|
||||
else alert("초기화 실패: " + r.statusText);
|
||||
} 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.
0
data/dogeusdt/.gitkeep
Normal file
0
data/dogeusdt/.gitkeep
Normal file
BIN
data/dogeusdt/combined_15m.parquet
Normal file
BIN
data/dogeusdt/combined_15m.parquet
Normal file
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.
0
data/trxusdt/.gitkeep
Normal file
0
data/trxusdt/.gitkeep
Normal file
BIN
data/trxusdt/combined_15m.parquet
Normal file
BIN
data/trxusdt/combined_15m.parquet
Normal file
Binary file not shown.
0
data/xrpusdt/.gitkeep
Normal file
0
data/xrpusdt/.gitkeep
Normal file
BIN
data/xrpusdt/combined_15m.parquet
Normal file
BIN
data/xrpusdt/combined_15m.parquet
Normal file
Binary file not shown.
@@ -23,6 +23,7 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
- PYTHONUNBUFFERED=1
|
||||
- LOG_DIR=/app/logs
|
||||
- DB_PATH=/app/data/dashboard.db
|
||||
- POLL_INTERVAL=5
|
||||
@@ -51,5 +52,40 @@ services:
|
||||
max-size: "10m"
|
||||
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:
|
||||
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` |
|
||||
209
docs/plans/2026-03-05-multi-symbol-trading-design.md
Normal file
209
docs/plans/2026-03-05-multi-symbol-trading-design.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# Multi-Symbol Trading Design
|
||||
|
||||
## 개요
|
||||
|
||||
현재 XRP 단일 심볼 선물 거래 봇을 TRX, DOGE 등 다중 심볼 동시 거래로 확장한다.
|
||||
|
||||
## 요구사항
|
||||
|
||||
- **거래 심볼**: XRPUSDT, TRXUSDT, DOGEUSDT (3개, 추후 확장 가능)
|
||||
- **상관관계 심볼**: BTCUSDT, ETHUSDT (기존과 동일)
|
||||
- **ML 모델**: 심볼별 개별 학습·배포
|
||||
- **포지션**: 심볼별 동시 포지션 허용 (최대 3개)
|
||||
- **리스크**: 심볼별 독립 운영 + 글로벌 한도 (일일 손실 5%)
|
||||
- **동일 방향 제한**: 같은 방향(LONG/SHORT) 최대 2개까지 (BTC 급락 시 3배 손실 방지)
|
||||
|
||||
## 접근법: 심볼별 독립 TradingBot 인스턴스 + 공유 RiskManager
|
||||
|
||||
기존 TradingBot의 단일 포지션 상태 머신을 유지하면서, 각 심볼마다 독립 인스턴스를 생성하고 `asyncio.gather()`로 병렬 실행한다. RiskManager만 싱글턴으로 공유하여 글로벌 리스크를 관리한다.
|
||||
|
||||
### 선택 이유
|
||||
|
||||
- 기존 TradingBot 상태 머신 수정 최소화
|
||||
- 심볼 간 완전 격리 — 한 심볼의 에러가 다른 심볼에 영향 없음
|
||||
- 점진적 확장 용이 (새 심볼 = 새 인스턴스 추가)
|
||||
- 각 단계마다 기존 XRP 단일 모드로 테스트 가능
|
||||
|
||||
### 기각된 대안: 단일 Bot + 심볼 라우팅
|
||||
|
||||
하나의 TradingBot에서 `Dict[str, PositionState]`로 관리하는 방식. WebSocket 효율적이나 상태 머신 대규모 리팩토링 필요, 한 심볼 에러가 전체에 영향, 복잡도 대폭 증가.
|
||||
|
||||
## 설계 상세
|
||||
|
||||
### 1. Config 변경
|
||||
|
||||
```python
|
||||
# .env
|
||||
SYMBOLS=XRPUSDT,TRXUSDT,DOGEUSDT
|
||||
CORRELATION_SYMBOLS=BTCUSDT,ETHUSDT
|
||||
MAX_SAME_DIRECTION=2
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
symbols: list[str] # ["XRPUSDT", "TRXUSDT", "DOGEUSDT"]
|
||||
correlation_symbols: list[str] # ["BTCUSDT", "ETHUSDT"]
|
||||
max_same_direction: int # 같은 방향 최대 수 (기본 2)
|
||||
# symbol: str 필드 제거
|
||||
```
|
||||
|
||||
- 기존 `SYMBOL` 환경변수 제거, `SYMBOLS`로 통일
|
||||
- `config.symbol` 참조하는 코드 모두 → 각 봇 인스턴스의 `self.symbol`로 전환
|
||||
- 하위호환: `SYMBOLS` 미설정 시 기존 `SYMBOL` 값을 1개짜리 리스트로 변환
|
||||
|
||||
### 2. 실행 구조 (main.py)
|
||||
|
||||
```python
|
||||
async def main():
|
||||
config = Config()
|
||||
risk = RiskManager(config) # 공유 싱글턴
|
||||
|
||||
bots = []
|
||||
for symbol in config.symbols:
|
||||
bot = TradingBot(config, symbol=symbol, risk=risk)
|
||||
bots.append(bot)
|
||||
|
||||
await asyncio.gather(*[bot.run() for bot in bots])
|
||||
```
|
||||
|
||||
- 각 봇은 독립적인 MultiSymbolStream, Exchange, UserDataStream 보유
|
||||
- RiskManager만 공유
|
||||
|
||||
### 3. TradingBot 생성자 변경
|
||||
|
||||
```python
|
||||
class TradingBot:
|
||||
def __init__(self, config: Config, symbol: str, risk: RiskManager):
|
||||
self.symbol = symbol
|
||||
self.config = config
|
||||
self.exchange = BinanceFuturesClient(config, symbol=symbol)
|
||||
self.risk = risk # 외부에서 주입 (공유)
|
||||
self.ml_filter = MLFilter(model_dir=f"models/{symbol.lower()}")
|
||||
...
|
||||
```
|
||||
|
||||
- `config.symbol` 의존 완전 제거
|
||||
- 각 봇이 자기 심볼을 직접 소유
|
||||
|
||||
### 4. Exchange 심볼 분리
|
||||
|
||||
```python
|
||||
class BinanceFuturesClient:
|
||||
def __init__(self, config: Config, symbol: str):
|
||||
self.symbol = symbol # config.symbol → self.symbol
|
||||
```
|
||||
|
||||
- 모든 API 호출에서 `self.config.symbol` → `self.symbol`
|
||||
|
||||
### 5. RiskManager 공유 설계
|
||||
|
||||
```python
|
||||
class RiskManager:
|
||||
def __init__(self, config):
|
||||
self.daily_pnl = 0.0
|
||||
self.open_positions: dict[str, str] = {} # {symbol: side}
|
||||
self.max_positions = config.max_positions
|
||||
self.max_same_direction = config.max_same_direction # 기본 2
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def can_open_new_position(self, symbol: str, side: str) -> bool:
|
||||
async with self._lock:
|
||||
if len(self.open_positions) >= self.max_positions:
|
||||
return False
|
||||
if symbol in self.open_positions:
|
||||
return False
|
||||
same_dir = sum(1 for s in self.open_positions.values() if s == side)
|
||||
if same_dir >= self.max_same_direction:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def register_position(self, symbol: str, side: str):
|
||||
async with self._lock:
|
||||
self.open_positions[symbol] = side
|
||||
|
||||
async def close_position(self, symbol: str, pnl: float):
|
||||
async with self._lock:
|
||||
self.open_positions.pop(symbol, None)
|
||||
self.daily_pnl += pnl
|
||||
|
||||
def is_trading_allowed(self) -> bool:
|
||||
# 글로벌 일일 손실 한도 체크 (기존과 동일)
|
||||
```
|
||||
|
||||
- `asyncio.Lock()`으로 동시 접근 보호
|
||||
- 동일 방향 2개 제한으로 BTC 급락 시 3배 손실 방지
|
||||
- 마진은 심볼 수(N)로 균등 배분
|
||||
|
||||
### 6. 데이터 스트림
|
||||
|
||||
각 TradingBot이 자기만의 MultiSymbolStream 인스턴스를 가짐:
|
||||
|
||||
```
|
||||
XRP Bot: [XRPUSDT, BTCUSDT, ETHUSDT]
|
||||
TRX Bot: [TRXUSDT, BTCUSDT, ETHUSDT]
|
||||
DOGE Bot: [DOGEUSDT, BTCUSDT, ETHUSDT]
|
||||
```
|
||||
|
||||
- BTC/ETH 데이터 중복 수신되지만 격리성 확보
|
||||
- 각 stream의 primary_symbol이 달라 candle close 콜백 독립적
|
||||
|
||||
### 7. 모델 & 데이터 디렉토리 분리
|
||||
|
||||
```
|
||||
models/
|
||||
├── xrpusdt/
|
||||
│ ├── lgbm_filter.pkl
|
||||
│ └── mlx_filter.weights.onnx
|
||||
├── trxusdt/
|
||||
│ └── ...
|
||||
└── dogeusdt/
|
||||
└── ...
|
||||
|
||||
data/
|
||||
├── xrpusdt/
|
||||
│ └── combined_15m.parquet
|
||||
├── trxusdt/
|
||||
│ └── combined_15m.parquet
|
||||
└── dogeusdt/
|
||||
└── combined_15m.parquet
|
||||
```
|
||||
|
||||
- 각 parquet: 해당 심볼이 primary + BTC/ETH가 correlation
|
||||
- feature 구조 동일 (26 features)
|
||||
|
||||
### 8. 학습 파이프라인 CLI 통일
|
||||
|
||||
모든 스크립트에 `--symbol`과 `--all` 패턴 적용:
|
||||
|
||||
```bash
|
||||
# 단일 심볼
|
||||
bash scripts/train_and_deploy.sh --symbol TRXUSDT
|
||||
python scripts/fetch_history.py --symbol DOGEUSDT
|
||||
python scripts/train_model.py --symbol TRXUSDT
|
||||
python scripts/tune_hyperparams.py --symbol DOGEUSDT
|
||||
|
||||
# 전체 심볼
|
||||
bash scripts/train_and_deploy.sh --all
|
||||
bash scripts/train_and_deploy.sh # 인자 없으면 --all 동일
|
||||
|
||||
# MLX + 단일 심볼
|
||||
bash scripts/train_and_deploy.sh mlx --symbol DOGEUSDT
|
||||
```
|
||||
|
||||
## 구현 순서
|
||||
|
||||
각 단계마다 기존 XRP 단일 모드로 테스트 가능하도록 점진적 전환:
|
||||
|
||||
1. **Config** — `symbols` 리스트, `max_same_direction` 추가
|
||||
2. **RiskManager** — 공유 싱글턴, asyncio.Lock, 동일 방향 제한
|
||||
3. **exchange.py** — `config.symbol` → `self.symbol` 분리
|
||||
4. **bot.py** — 생성자에 `symbol`, `risk` 파라미터 추가, `config.symbol` 제거
|
||||
5. **main.py** — 심볼별 봇 인스턴스 생성 + `asyncio.gather()`
|
||||
6. **학습 스크립트** — `--symbol`/`--all` CLI, 디렉토리 분리
|
||||
|
||||
## 변경 불필요한 컴포넌트
|
||||
|
||||
- `src/indicators.py` — 이미 심볼에 독립적
|
||||
- `src/notifier.py` — 이미 symbol 파라미터 수용
|
||||
- `src/user_data_stream.py` — 이미 심볼별 필터링 지원
|
||||
- `src/ml_features.py` — 이미 primary + auxiliary 구조
|
||||
- `src/label_builder.py` — 이미 범용적
|
||||
920
docs/plans/2026-03-05-multi-symbol-trading-plan.md
Normal file
920
docs/plans/2026-03-05-multi-symbol-trading-plan.md
Normal file
@@ -0,0 +1,920 @@
|
||||
# Multi-Symbol Trading Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** XRP 단일 심볼 거래 봇을 TRX·DOGE 등 다중 심볼 동시 거래로 확장한다.
|
||||
|
||||
**Architecture:** 심볼별 독립 TradingBot 인스턴스를 `asyncio.gather()`로 병렬 실행. RiskManager만 공유 싱글턴으로 글로벌 리스크(일일 손실 한도, 동일 방향 제한)를 관리한다. 각 봇은 자기 심볼을 직접 소유하고, `config.symbol` 의존을 완전 제거한다.
|
||||
|
||||
**Tech Stack:** Python asyncio, LightGBM, ONNX, Binance Futures API
|
||||
|
||||
**Design Doc:** `docs/plans/2026-03-05-multi-symbol-trading-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Config — `symbols` 리스트 추가, `symbol` 필드 유지(하위호환)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/config.py`
|
||||
- Modify: `tests/test_config.py`
|
||||
- Modify: `.env.example`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
`tests/test_config.py`에 다음 테스트를 추가:
|
||||
|
||||
```python
|
||||
def test_config_loads_symbols_list():
|
||||
"""SYMBOLS 환경변수로 쉼표 구분 리스트를 로드한다."""
|
||||
os.environ["SYMBOLS"] = "XRPUSDT,TRXUSDT,DOGEUSDT"
|
||||
os.environ.pop("SYMBOL", None)
|
||||
cfg = Config()
|
||||
assert cfg.symbols == ["XRPUSDT", "TRXUSDT", "DOGEUSDT"]
|
||||
|
||||
|
||||
def test_config_fallback_to_symbol():
|
||||
"""SYMBOLS 미설정 시 SYMBOL에서 1개짜리 리스트로 변환한다."""
|
||||
os.environ.pop("SYMBOLS", None)
|
||||
os.environ["SYMBOL"] = "XRPUSDT"
|
||||
cfg = Config()
|
||||
assert cfg.symbols == ["XRPUSDT"]
|
||||
|
||||
|
||||
def test_config_correlation_symbols():
|
||||
"""상관관계 심볼 로드."""
|
||||
os.environ["CORRELATION_SYMBOLS"] = "BTCUSDT,ETHUSDT"
|
||||
cfg = Config()
|
||||
assert cfg.correlation_symbols == ["BTCUSDT", "ETHUSDT"]
|
||||
|
||||
|
||||
def test_config_max_same_direction_default():
|
||||
"""동일 방향 최대 수 기본값 2."""
|
||||
cfg = Config()
|
||||
assert cfg.max_same_direction == 2
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pytest tests/test_config.py -v`
|
||||
Expected: FAIL — `Config` has no `symbols`, `correlation_symbols`, `max_same_direction` attributes
|
||||
|
||||
**Step 3: Implement Config changes**
|
||||
|
||||
`src/config.py`를 수정:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class Config:
|
||||
api_key: str = ""
|
||||
api_secret: str = ""
|
||||
symbol: str = "XRPUSDT"
|
||||
symbols: list = None # NEW
|
||||
correlation_symbols: list = None # NEW
|
||||
leverage: int = 10
|
||||
max_positions: int = 3
|
||||
max_same_direction: int = 2 # NEW
|
||||
stop_loss_pct: float = 0.015
|
||||
take_profit_pct: float = 0.045
|
||||
trailing_stop_pct: float = 0.01
|
||||
discord_webhook_url: str = ""
|
||||
margin_max_ratio: float = 0.50
|
||||
margin_min_ratio: float = 0.20
|
||||
margin_decay_rate: float = 0.0006
|
||||
ml_threshold: float = 0.55
|
||||
|
||||
def __post_init__(self):
|
||||
self.api_key = os.getenv("BINANCE_API_KEY", "")
|
||||
self.api_secret = os.getenv("BINANCE_API_SECRET", "")
|
||||
self.symbol = os.getenv("SYMBOL", "XRPUSDT")
|
||||
self.leverage = int(os.getenv("LEVERAGE", "10"))
|
||||
self.discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL", "")
|
||||
self.margin_max_ratio = float(os.getenv("MARGIN_MAX_RATIO", "0.50"))
|
||||
self.margin_min_ratio = float(os.getenv("MARGIN_MIN_RATIO", "0.20"))
|
||||
self.margin_decay_rate = float(os.getenv("MARGIN_DECAY_RATE", "0.0006"))
|
||||
self.ml_threshold = float(os.getenv("ML_THRESHOLD", "0.55"))
|
||||
self.max_same_direction = int(os.getenv("MAX_SAME_DIRECTION", "2"))
|
||||
|
||||
# symbols: SYMBOLS 환경변수 우선, 없으면 SYMBOL에서 변환
|
||||
symbols_env = os.getenv("SYMBOLS", "")
|
||||
if symbols_env:
|
||||
self.symbols = [s.strip() for s in symbols_env.split(",") if s.strip()]
|
||||
else:
|
||||
self.symbols = [self.symbol]
|
||||
|
||||
# correlation_symbols
|
||||
corr_env = os.getenv("CORRELATION_SYMBOLS", "BTCUSDT,ETHUSDT")
|
||||
self.correlation_symbols = [s.strip() for s in corr_env.split(",") if s.strip()]
|
||||
```
|
||||
|
||||
`.env.example`에 추가:
|
||||
|
||||
```
|
||||
SYMBOLS=XRPUSDT
|
||||
CORRELATION_SYMBOLS=BTCUSDT,ETHUSDT
|
||||
MAX_SAME_DIRECTION=2
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_config.py -v`
|
||||
Expected: ALL PASS
|
||||
|
||||
**Step 5: Run full test suite to verify no regressions**
|
||||
|
||||
Run: `bash scripts/run_tests.sh`
|
||||
Expected: ALL PASS (기존 코드는 `config.symbol`을 여전히 사용 가능하므로 깨지지 않음)
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/config.py tests/test_config.py .env.example
|
||||
git commit -m "feat: add multi-symbol config (symbols list, correlation_symbols, max_same_direction)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: RiskManager — 공유 싱글턴, asyncio.Lock, 동일 방향 제한
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/risk_manager.py`
|
||||
- Modify: `tests/test_risk_manager.py`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
`tests/test_risk_manager.py`에 추가:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shared_risk(config):
|
||||
config.max_same_direction = 2
|
||||
return RiskManager(config)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_can_open_new_position_async(shared_risk):
|
||||
"""비동기 포지션 오픈 허용 체크."""
|
||||
assert await shared_risk.can_open_new_position("XRPUSDT", "LONG") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_and_close_position(shared_risk):
|
||||
"""포지션 등록 후 닫기."""
|
||||
await shared_risk.register_position("XRPUSDT", "LONG")
|
||||
assert "XRPUSDT" in shared_risk.open_positions
|
||||
await shared_risk.close_position("XRPUSDT", pnl=1.5)
|
||||
assert "XRPUSDT" not in shared_risk.open_positions
|
||||
assert shared_risk.daily_pnl == 1.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_symbol_blocked(shared_risk):
|
||||
"""같은 심볼 중복 진입 차단."""
|
||||
await shared_risk.register_position("XRPUSDT", "LONG")
|
||||
assert await shared_risk.can_open_new_position("XRPUSDT", "SHORT") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_same_direction_limit(shared_risk):
|
||||
"""같은 방향 2개 초과 차단."""
|
||||
await shared_risk.register_position("XRPUSDT", "LONG")
|
||||
await shared_risk.register_position("TRXUSDT", "LONG")
|
||||
# 3번째 LONG 차단
|
||||
assert await shared_risk.can_open_new_position("DOGEUSDT", "LONG") is False
|
||||
# SHORT은 허용
|
||||
assert await shared_risk.can_open_new_position("DOGEUSDT", "SHORT") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_positions_global_limit(shared_risk):
|
||||
"""전체 포지션 수 한도 초과 차단."""
|
||||
shared_risk.config.max_positions = 2
|
||||
await shared_risk.register_position("XRPUSDT", "LONG")
|
||||
await shared_risk.register_position("TRXUSDT", "SHORT")
|
||||
assert await shared_risk.can_open_new_position("DOGEUSDT", "LONG") is False
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pytest tests/test_risk_manager.py -v -k "async or register or same_direction"`
|
||||
Expected: FAIL — `can_open_new_position`이 sync이고 파라미터가 없음
|
||||
|
||||
**Step 3: Implement RiskManager changes**
|
||||
|
||||
`src/risk_manager.py` 전체 교체:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from loguru import logger
|
||||
from src.config import Config
|
||||
|
||||
|
||||
class RiskManager:
|
||||
def __init__(self, config: Config, max_daily_loss_pct: float = 0.05):
|
||||
self.config = config
|
||||
self.max_daily_loss_pct = max_daily_loss_pct
|
||||
self.daily_pnl: float = 0.0
|
||||
self.initial_balance: float = 0.0
|
||||
self.open_positions: dict[str, str] = {} # {symbol: side}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def is_trading_allowed(self) -> bool:
|
||||
"""일일 최대 손실 초과 시 거래 중단"""
|
||||
if self.initial_balance <= 0:
|
||||
return True
|
||||
loss_pct = abs(self.daily_pnl) / self.initial_balance
|
||||
if self.daily_pnl < 0 and loss_pct >= self.max_daily_loss_pct:
|
||||
logger.warning(
|
||||
f"일일 손실 한도 초과: {loss_pct:.2%} >= {self.max_daily_loss_pct:.2%}"
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
async def can_open_new_position(self, symbol: str, side: str) -> bool:
|
||||
"""포지션 오픈 가능 여부 (전체 한도 + 중복 진입 + 동일 방향 제한)"""
|
||||
async with self._lock:
|
||||
if len(self.open_positions) >= self.config.max_positions:
|
||||
logger.info(f"최대 포지션 수 도달: {len(self.open_positions)}/{self.config.max_positions}")
|
||||
return False
|
||||
if symbol in self.open_positions:
|
||||
logger.info(f"{symbol} 이미 포지션 보유 중")
|
||||
return False
|
||||
same_dir = sum(1 for s in self.open_positions.values() if s == side)
|
||||
if same_dir >= self.config.max_same_direction:
|
||||
logger.info(f"동일 방향({side}) 한도 도달: {same_dir}/{self.config.max_same_direction}")
|
||||
return False
|
||||
return True
|
||||
|
||||
async def register_position(self, symbol: str, side: str):
|
||||
"""포지션 등록"""
|
||||
async with self._lock:
|
||||
self.open_positions[symbol] = side
|
||||
logger.info(f"포지션 등록: {symbol} {side} (현재 {len(self.open_positions)}개)")
|
||||
|
||||
async def close_position(self, symbol: str, pnl: float):
|
||||
"""포지션 닫기 + PnL 기록"""
|
||||
async with self._lock:
|
||||
self.open_positions.pop(symbol, None)
|
||||
self.daily_pnl += pnl
|
||||
logger.info(f"포지션 종료: {symbol}, PnL={pnl:+.4f}, 누적={self.daily_pnl:+.4f}")
|
||||
|
||||
def record_pnl(self, pnl: float):
|
||||
self.daily_pnl += pnl
|
||||
logger.info(f"오늘 누적 PnL: {self.daily_pnl:.4f} USDT")
|
||||
|
||||
def reset_daily(self):
|
||||
"""매일 자정 초기화"""
|
||||
self.daily_pnl = 0.0
|
||||
logger.info("일일 PnL 초기화")
|
||||
|
||||
def set_base_balance(self, balance: float) -> None:
|
||||
"""봇 시작 시 기준 잔고 설정"""
|
||||
self.initial_balance = balance
|
||||
|
||||
def get_dynamic_margin_ratio(self, balance: float) -> float:
|
||||
"""잔고에 따라 선형 감소하는 증거금 비율 반환"""
|
||||
ratio = self.config.margin_max_ratio - (
|
||||
(balance - self.initial_balance) * self.config.margin_decay_rate
|
||||
)
|
||||
return max(self.config.margin_min_ratio, min(self.config.margin_max_ratio, ratio))
|
||||
```
|
||||
|
||||
주요 변경:
|
||||
- `open_positions: list` → `dict[str, str]` (심볼→방향 매핑)
|
||||
- `can_open_new_position()` → `async` + `symbol`, `side` 파라미터
|
||||
- `register_position()`, `close_position()` 새 메서드 추가
|
||||
- `asyncio.Lock()` 동시성 보호
|
||||
|
||||
**Step 4: Fix existing tests that break**
|
||||
|
||||
기존 테스트에서 `can_open_new_position()` 호출 방식이 바뀌었으므로 수정:
|
||||
|
||||
`tests/test_risk_manager.py`의 `test_position_size_capped`를 수정:
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_position_size_capped(config):
|
||||
rm = RiskManager(config, max_daily_loss_pct=0.05)
|
||||
await rm.register_position("XRPUSDT", "LONG")
|
||||
await rm.register_position("TRXUSDT", "SHORT")
|
||||
await rm.register_position("DOGEUSDT", "LONG")
|
||||
assert await rm.can_open_new_position("SOLUSDT", "SHORT") is False
|
||||
```
|
||||
|
||||
**Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_risk_manager.py -v`
|
||||
Expected: ALL PASS
|
||||
|
||||
**Step 6: Run full test suite**
|
||||
|
||||
Run: `bash scripts/run_tests.sh`
|
||||
Expected: `test_bot.py`에서 `can_open_new_position()` 호출이 깨질 수 있음 — Task 4에서 수정할 것이므로 지금은 bot 테스트 실패 허용
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/risk_manager.py tests/test_risk_manager.py
|
||||
git commit -m "feat: shared RiskManager with async lock, same-direction limit, per-symbol tracking"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Exchange — `config.symbol` → `self.symbol` 분리
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/exchange.py`
|
||||
- Modify: `tests/test_exchange.py`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
`tests/test_exchange.py`에 추가:
|
||||
|
||||
```python
|
||||
def test_exchange_uses_own_symbol():
|
||||
"""Exchange 클라이언트가 config.symbol 대신 생성자의 symbol을 사용한다."""
|
||||
os.environ.update({
|
||||
"BINANCE_API_KEY": "test_key",
|
||||
"BINANCE_API_SECRET": "test_secret",
|
||||
"SYMBOL": "XRPUSDT",
|
||||
})
|
||||
config = Config()
|
||||
with patch("src.exchange.Client"):
|
||||
client = BinanceFuturesClient(config, symbol="TRXUSDT")
|
||||
assert client.symbol == "TRXUSDT"
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_exchange.py::test_exchange_uses_own_symbol -v`
|
||||
Expected: FAIL — `__init__` doesn't accept `symbol` parameter
|
||||
|
||||
**Step 3: Implement Exchange changes**
|
||||
|
||||
`src/exchange.py` 생성자 변경:
|
||||
|
||||
```python
|
||||
class BinanceFuturesClient:
|
||||
def __init__(self, config: Config, symbol: str = None):
|
||||
self.config = config
|
||||
self.symbol = symbol or config.symbol
|
||||
self.client = Client(
|
||||
api_key=config.api_key,
|
||||
api_secret=config.api_secret,
|
||||
)
|
||||
```
|
||||
|
||||
모든 `self.config.symbol` 참조를 `self.symbol`로 교체 (9곳):
|
||||
- Line 34: `set_leverage` → `symbol=self.symbol`
|
||||
- Line 71: `place_order` params → `symbol=self.symbol`
|
||||
- Line 101: `_place_algo_order` params → `symbol=self.symbol`
|
||||
- Line 123: `get_position` → `symbol=self.symbol`
|
||||
- Line 137: `cancel_all_orders` 일반 → `symbol=self.symbol`
|
||||
- Line 144: `cancel_all_orders` algo → `symbol=self.symbol`
|
||||
- Line 156: `get_open_interest` → `symbol=self.symbol`
|
||||
- Line 169: `get_funding_rate` → `symbol=self.symbol`
|
||||
- Line 183: `get_oi_history` → `symbol=self.symbol`
|
||||
|
||||
**Step 4: Fix existing test fixtures**
|
||||
|
||||
기존 `exchange` 픽스처에서 `BinanceFuturesClient.__new__`를 사용하는 곳에 `c.symbol` 설정 추가:
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def client():
|
||||
config = Config()
|
||||
config.leverage = 10
|
||||
c = BinanceFuturesClient.__new__(BinanceFuturesClient)
|
||||
c.config = config
|
||||
c.symbol = config.symbol # NEW
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def exchange():
|
||||
os.environ.update({
|
||||
"BINANCE_API_KEY": "test_key",
|
||||
"BINANCE_API_SECRET": "test_secret",
|
||||
"SYMBOL": "XRPUSDT",
|
||||
"LEVERAGE": "10",
|
||||
})
|
||||
config = Config()
|
||||
c = BinanceFuturesClient.__new__(BinanceFuturesClient)
|
||||
c.config = config
|
||||
c.symbol = config.symbol # NEW
|
||||
c.client = MagicMock()
|
||||
return c
|
||||
```
|
||||
|
||||
**Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_exchange.py -v`
|
||||
Expected: ALL PASS
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/exchange.py tests/test_exchange.py
|
||||
git commit -m "feat: exchange client accepts explicit symbol parameter, removes config.symbol dependency"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: TradingBot — 생성자에 `symbol`, `risk` 주입
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/bot.py`
|
||||
- Modify: `tests/test_bot.py`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
`tests/test_bot.py`에 추가:
|
||||
|
||||
```python
|
||||
def test_bot_accepts_symbol_and_risk(config):
|
||||
"""TradingBot이 symbol과 risk를 외부에서 주입받을 수 있다."""
|
||||
from src.risk_manager import RiskManager
|
||||
risk = RiskManager(config)
|
||||
with patch("src.bot.BinanceFuturesClient"):
|
||||
bot = TradingBot(config, symbol="TRXUSDT", risk=risk)
|
||||
assert bot.symbol == "TRXUSDT"
|
||||
assert bot.risk is risk
|
||||
|
||||
|
||||
def test_bot_stream_uses_injected_symbol(config):
|
||||
"""봇의 stream이 주입된 심볼을 primary로 사용한다."""
|
||||
from src.risk_manager import RiskManager
|
||||
risk = RiskManager(config)
|
||||
with patch("src.bot.BinanceFuturesClient"):
|
||||
bot = TradingBot(config, symbol="DOGEUSDT", risk=risk)
|
||||
assert "dogeusdt" in bot.stream.buffers
|
||||
|
||||
|
||||
def test_bot_ml_filter_uses_symbol_model_dir(config):
|
||||
"""봇의 MLFilter가 심볼별 모델 디렉토리를 사용한다."""
|
||||
from src.risk_manager import RiskManager
|
||||
risk = RiskManager(config)
|
||||
with patch("src.bot.BinanceFuturesClient"):
|
||||
bot = TradingBot(config, symbol="TRXUSDT", risk=risk)
|
||||
assert "trxusdt" in str(bot.ml_filter._onnx_path)
|
||||
assert "trxusdt" in str(bot.ml_filter._lgbm_path)
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pytest tests/test_bot.py -v -k "accepts_symbol or injected_symbol or symbol_model_dir"`
|
||||
Expected: FAIL — `TradingBot.__init__` doesn't accept `symbol` or `risk`
|
||||
|
||||
**Step 3: Implement TradingBot changes**
|
||||
|
||||
`src/bot.py`의 `__init__` 변경:
|
||||
|
||||
```python
|
||||
class TradingBot:
|
||||
def __init__(self, config: Config, symbol: str = None, risk: RiskManager = None):
|
||||
self.config = config
|
||||
self.symbol = symbol or config.symbol
|
||||
self.exchange = BinanceFuturesClient(config, symbol=self.symbol)
|
||||
self.notifier = DiscordNotifier(config.discord_webhook_url)
|
||||
self.risk = risk or RiskManager(config)
|
||||
self.ml_filter = MLFilter(
|
||||
onnx_path=f"models/{self.symbol.lower()}/mlx_filter.weights.onnx",
|
||||
lgbm_path=f"models/{self.symbol.lower()}/lgbm_filter.pkl",
|
||||
threshold=config.ml_threshold,
|
||||
)
|
||||
self.current_trade_side: str | None = None
|
||||
self._entry_price: float | None = None
|
||||
self._entry_quantity: float | None = None
|
||||
self._is_reentering: bool = False
|
||||
self._prev_oi: float | None = None
|
||||
self._oi_history: deque = deque(maxlen=5)
|
||||
self._latest_ret_1: float = 0.0
|
||||
self.stream = MultiSymbolStream(
|
||||
symbols=[self.symbol] + config.correlation_symbols,
|
||||
interval="15m",
|
||||
on_candle=self._on_candle_closed,
|
||||
)
|
||||
```
|
||||
|
||||
`_on_candle_closed` 변경:
|
||||
|
||||
```python
|
||||
async def _on_candle_closed(self, candle: dict):
|
||||
primary_df = self.stream.get_dataframe(self.symbol)
|
||||
btc_df = self.stream.get_dataframe("BTCUSDT")
|
||||
eth_df = self.stream.get_dataframe("ETHUSDT")
|
||||
if primary_df is not None:
|
||||
await self.process_candle(primary_df, btc_df=btc_df, eth_df=eth_df)
|
||||
```
|
||||
|
||||
`process_candle`에서 `can_open_new_position` 호출 변경 (2곳):
|
||||
|
||||
```python
|
||||
# Line ~138 (신규 진입):
|
||||
if not await self.risk.can_open_new_position(self.symbol, raw_signal):
|
||||
logger.info(f"[{self.symbol}] 포지션 오픈 불가")
|
||||
return
|
||||
|
||||
# Line ~322 (_close_and_reenter 내):
|
||||
if not await self.risk.can_open_new_position(self.symbol, signal):
|
||||
logger.info(f"[{self.symbol}] 최대 포지션 수 도달 — 재진입 건너뜀")
|
||||
return
|
||||
```
|
||||
|
||||
`_open_position`에서 `register_position` 호출 추가:
|
||||
|
||||
```python
|
||||
async def _open_position(self, signal: str, df):
|
||||
balance = await self.exchange.get_balance()
|
||||
# 심볼 수로 마진 균등 배분
|
||||
num_symbols = len(self.config.symbols)
|
||||
per_symbol_balance = balance / num_symbols
|
||||
price = df["close"].iloc[-1]
|
||||
margin_ratio = self.risk.get_dynamic_margin_ratio(balance)
|
||||
quantity = self.exchange.calculate_quantity(
|
||||
balance=per_symbol_balance, price=price,
|
||||
leverage=self.config.leverage, margin_ratio=margin_ratio,
|
||||
)
|
||||
# ... 기존 로직 ...
|
||||
|
||||
# 포지션 등록
|
||||
await self.risk.register_position(self.symbol, signal)
|
||||
self.current_trade_side = signal
|
||||
# ... 나머지 동일 ...
|
||||
```
|
||||
|
||||
`_on_position_closed`에서 `close_position` 호출:
|
||||
|
||||
```python
|
||||
async def _on_position_closed(self, net_pnl, close_reason, exit_price):
|
||||
# ... 기존 PnL 계산 로직 ...
|
||||
await self.risk.close_position(self.symbol, net_pnl)
|
||||
# record_pnl 제거 (close_position 내에서 처리)
|
||||
# ... 나머지 동일 ...
|
||||
```
|
||||
|
||||
모든 `self.config.symbol` 참조를 `self.symbol`로 교체 (6곳):
|
||||
- Line 31 → `self.symbol` (stream symbols)
|
||||
- Line 37 → `self.symbol` (get_dataframe)
|
||||
- Line 197 → `self.symbol` (notify_open)
|
||||
- Line 251 → `self.symbol` (notify_close)
|
||||
- Line 340 → `self.symbol` (run 로그)
|
||||
- Line 348 → `self.symbol` (UserDataStream)
|
||||
|
||||
`run()` 메서드의 로그도 변경:
|
||||
|
||||
```python
|
||||
async def run(self):
|
||||
logger.info(f"[{self.symbol}] 봇 시작, 레버리지 {self.config.leverage}x")
|
||||
# ... 나머지 동일, self.config.symbol → self.symbol ...
|
||||
```
|
||||
|
||||
**Step 4: Fix existing bot tests**
|
||||
|
||||
기존 `tests/test_bot.py`의 모든 `TradingBot(config)` 호출은 하위호환되므로 그대로 동작.
|
||||
단, `risk.can_open_new_position` 호출이 async로 바뀌었으므로 mock 수정 필요:
|
||||
|
||||
`test_close_and_reenter_calls_open_when_ml_passes`:
|
||||
```python
|
||||
bot.risk = MagicMock()
|
||||
bot.risk.can_open_new_position = AsyncMock(return_value=True)
|
||||
```
|
||||
|
||||
`test_close_and_reenter_skips_open_when_max_positions_reached`:
|
||||
```python
|
||||
bot.risk = MagicMock()
|
||||
bot.risk.can_open_new_position = AsyncMock(return_value=False)
|
||||
```
|
||||
|
||||
`test_bot_processes_signal`에서 `bot.risk`도 mock:
|
||||
```python
|
||||
bot.risk = MagicMock()
|
||||
bot.risk.is_trading_allowed.return_value = True
|
||||
bot.risk.can_open_new_position = AsyncMock(return_value=True)
|
||||
bot.risk.register_position = AsyncMock()
|
||||
bot.risk.get_dynamic_margin_ratio.return_value = 0.50
|
||||
```
|
||||
|
||||
**Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_bot.py -v`
|
||||
Expected: ALL PASS
|
||||
|
||||
**Step 6: Run full test suite**
|
||||
|
||||
Run: `bash scripts/run_tests.sh`
|
||||
Expected: ALL PASS
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/bot.py tests/test_bot.py
|
||||
git commit -m "feat: TradingBot accepts symbol and shared RiskManager, removes config.symbol dependency"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: main.py — 심볼별 봇 인스턴스 생성 + asyncio.gather
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py`
|
||||
|
||||
**Step 1: Implement main.py changes**
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from dotenv import load_dotenv
|
||||
from src.config import Config
|
||||
from src.bot import TradingBot
|
||||
from src.risk_manager import RiskManager
|
||||
from src.logger_setup import setup_logger
|
||||
from loguru import logger
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
async def main():
|
||||
setup_logger(log_level="INFO")
|
||||
config = Config()
|
||||
risk = RiskManager(config)
|
||||
|
||||
bots = []
|
||||
for symbol in config.symbols:
|
||||
bot = TradingBot(config, symbol=symbol, risk=risk)
|
||||
bots.append(bot)
|
||||
|
||||
logger.info(f"멀티심볼 봇 시작: {config.symbols} ({len(bots)}개 인스턴스)")
|
||||
await asyncio.gather(*[bot.run() for bot in bots])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
**Step 2: Run full test suite to verify no regressions**
|
||||
|
||||
Run: `bash scripts/run_tests.sh`
|
||||
Expected: ALL PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add main.py
|
||||
git commit -m "feat: main.py spawns per-symbol TradingBot instances with shared RiskManager"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: MLFilter — 심볼별 모델 디렉토리 폴백
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ml_filter.py`
|
||||
|
||||
**Step 1: Implement MLFilter path fallback**
|
||||
|
||||
MLFilter는 이미 `onnx_path`/`lgbm_path`를 생성자에서 받으므로, bot.py에서 심볼별 경로를 주입하면 된다 (Task 4에서 완료).
|
||||
|
||||
다만 기존 `models/lgbm_filter.pkl` 경로에 모델이 있는 경우(단일 심볼 환경)에도 동작하도록, 심볼별 디렉토리에 모델이 없으면 루트 `models/`에서 폴백하는 로직을 `bot.py`에 추가:
|
||||
|
||||
`src/bot.py`의 `__init__`에서:
|
||||
|
||||
```python
|
||||
# 심볼별 모델 디렉토리. 없으면 기존 models/ 루트로 폴백
|
||||
symbol_model_dir = Path(f"models/{self.symbol.lower()}")
|
||||
if symbol_model_dir.exists():
|
||||
onnx_path = str(symbol_model_dir / "mlx_filter.weights.onnx")
|
||||
lgbm_path = str(symbol_model_dir / "lgbm_filter.pkl")
|
||||
else:
|
||||
onnx_path = "models/mlx_filter.weights.onnx"
|
||||
lgbm_path = "models/lgbm_filter.pkl"
|
||||
self.ml_filter = MLFilter(
|
||||
onnx_path=onnx_path,
|
||||
lgbm_path=lgbm_path,
|
||||
threshold=config.ml_threshold,
|
||||
)
|
||||
```
|
||||
|
||||
**Step 2: Run full test suite**
|
||||
|
||||
Run: `bash scripts/run_tests.sh`
|
||||
Expected: ALL PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/bot.py
|
||||
git commit -m "feat: MLFilter falls back to models/ root if symbol-specific dir not found"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 학습 스크립트 — `--symbol` / `--all` CLI 통일
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/fetch_history.py`
|
||||
- Modify: `scripts/train_model.py`
|
||||
- Modify: `scripts/tune_hyperparams.py`
|
||||
- Modify: `scripts/train_and_deploy.sh`
|
||||
- Modify: `scripts/deploy_model.sh`
|
||||
|
||||
### Step 1: fetch_history.py — `--symbol` 단일 심볼 + 출력 경로 자동 결정
|
||||
|
||||
`scripts/fetch_history.py`의 argparse에 `--symbol` 추가 및 `--output` 자동 결정:
|
||||
|
||||
현재 사용법: `--symbols XRPUSDT BTCUSDT ETHUSDT --output data/combined_15m.parquet`
|
||||
|
||||
변경 후:
|
||||
```python
|
||||
parser.add_argument("--symbol", type=str, default=None,
|
||||
help="단일 거래 심볼 (예: TRXUSDT). 상관관계 심볼 자동 추가")
|
||||
```
|
||||
|
||||
`--symbol TRXUSDT` 지정 시:
|
||||
- `symbols = ["TRXUSDT", "BTCUSDT", "ETHUSDT"]`
|
||||
- `output = "data/trxusdt/combined_15m.parquet"` (자동)
|
||||
|
||||
`--symbols XRPUSDT BTCUSDT ETHUSDT` (기존 방식)도 유지.
|
||||
|
||||
### Step 2: train_model.py — `--symbol` 추가
|
||||
|
||||
```python
|
||||
parser.add_argument("--symbol", type=str, default=None,
|
||||
help="학습 대상 심볼 (예: TRXUSDT). data/{symbol}/ 에서 데이터 로드, models/{symbol}/ 에 저장")
|
||||
```
|
||||
|
||||
`--symbol TRXUSDT` 지정 시:
|
||||
- 데이터: `data/trxusdt/combined_15m.parquet`
|
||||
- 모델: `models/trxusdt/lgbm_filter.pkl`
|
||||
- 로그: `models/trxusdt/training_log.json`
|
||||
|
||||
`--data` 옵션이 명시되면 그것을 우선.
|
||||
|
||||
### Step 3: tune_hyperparams.py — `--symbol` 추가
|
||||
|
||||
train_model.py와 동일한 패턴. `--symbol`이 지정되면:
|
||||
- 데이터: `data/{symbol}/combined_15m.parquet`
|
||||
- 결과: `models/{symbol}/tune_results_*.json`
|
||||
- active params: `models/{symbol}/active_lgbm_params.json`
|
||||
|
||||
### Step 4: train_and_deploy.sh — `--symbol` / `--all` 지원
|
||||
|
||||
```bash
|
||||
# 사용법:
|
||||
# bash scripts/train_and_deploy.sh [mlx|lgbm] [--symbol TRXUSDT]
|
||||
# bash scripts/train_and_deploy.sh [mlx|lgbm] --all
|
||||
# bash scripts/train_and_deploy.sh # --all과 동일 (기본값)
|
||||
```
|
||||
|
||||
`--symbol` 지정 시: 해당 심볼만 fetch → train → deploy
|
||||
`--all` 또는 인자 없음: `SYMBOLS` 환경변수의 모든 심볼 순차 처리
|
||||
|
||||
핵심 로직:
|
||||
```bash
|
||||
if [ -n "$SYMBOL_ARG" ]; then
|
||||
TARGETS=("$SYMBOL_ARG")
|
||||
else
|
||||
# .env에서 SYMBOLS 로드
|
||||
TARGETS=($(python -c "from src.config import Config; c=Config(); print(' '.join(c.symbols))"))
|
||||
fi
|
||||
|
||||
for SYM in "${TARGETS[@]}"; do
|
||||
SYM_LOWER=$(echo "$SYM" | tr '[:upper:]' '[:lower:]')
|
||||
mkdir -p "data/$SYM_LOWER" "models/$SYM_LOWER"
|
||||
|
||||
# fetch
|
||||
python scripts/fetch_history.py --symbol "$SYM" ...
|
||||
|
||||
# train
|
||||
python scripts/train_model.py --symbol "$SYM" ...
|
||||
|
||||
# deploy
|
||||
bash scripts/deploy_model.sh "$BACKEND" --symbol "$SYM"
|
||||
done
|
||||
```
|
||||
|
||||
### Step 5: deploy_model.sh — `--symbol` 지원
|
||||
|
||||
```bash
|
||||
# 사용법: bash scripts/deploy_model.sh [lgbm|mlx] [--symbol TRXUSDT]
|
||||
```
|
||||
|
||||
`--symbol` 지정 시:
|
||||
- 로컬: `models/{symbol}/lgbm_filter.pkl`
|
||||
- 원격: `$LXC_MODELS_PATH/{symbol}/lgbm_filter.pkl`
|
||||
|
||||
### Step 6: Run full test suite
|
||||
|
||||
Run: `bash scripts/run_tests.sh`
|
||||
Expected: ALL PASS (스크립트 변경은 unit test에 영향 없음)
|
||||
|
||||
### Step 7: Smoke test 스크립트
|
||||
|
||||
```bash
|
||||
# fetch만 소량 테스트
|
||||
python scripts/fetch_history.py --symbol TRXUSDT --interval 15m --days 1
|
||||
ls data/trxusdt/combined_15m.parquet # 파일 존재 확인
|
||||
```
|
||||
|
||||
### Step 8: Commit
|
||||
|
||||
```bash
|
||||
git add scripts/fetch_history.py scripts/train_model.py scripts/tune_hyperparams.py scripts/train_and_deploy.sh scripts/deploy_model.sh
|
||||
git commit -m "feat: add --symbol/--all CLI to all training scripts for per-symbol pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 디렉토리 구조 생성 + .env.example 업데이트
|
||||
|
||||
**Files:**
|
||||
- Create: `models/xrpusdt/.gitkeep`
|
||||
- Create: `models/trxusdt/.gitkeep`
|
||||
- Create: `models/dogeusdt/.gitkeep`
|
||||
- Create: `data/xrpusdt/.gitkeep`
|
||||
- Create: `data/trxusdt/.gitkeep`
|
||||
- Create: `data/dogeusdt/.gitkeep`
|
||||
- Modify: `.env.example`
|
||||
|
||||
**Step 1: Create directory structure**
|
||||
|
||||
```bash
|
||||
mkdir -p models/{xrpusdt,trxusdt,dogeusdt}
|
||||
mkdir -p data/{xrpusdt,trxusdt,dogeusdt}
|
||||
touch models/{xrpusdt,trxusdt,dogeusdt}/.gitkeep
|
||||
touch data/{xrpusdt,trxusdt,dogeusdt}/.gitkeep
|
||||
```
|
||||
|
||||
**Step 2: Update .env.example**
|
||||
|
||||
```
|
||||
BINANCE_API_KEY=
|
||||
BINANCE_API_SECRET=
|
||||
SYMBOLS=XRPUSDT
|
||||
CORRELATION_SYMBOLS=BTCUSDT,ETHUSDT
|
||||
LEVERAGE=10
|
||||
RISK_PER_TRADE=0.02
|
||||
DISCORD_WEBHOOK_URL=
|
||||
ML_THRESHOLD=0.55
|
||||
MAX_SAME_DIRECTION=2
|
||||
BINANCE_TESTNET_API_KEY=
|
||||
BINANCE_TESTNET_API_SECRET=
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add models/ data/ .env.example
|
||||
git commit -m "feat: add per-symbol model/data directories and update .env.example"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: 기존 모델 마이그레이션 안내 + 문서 업데이트
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
**Step 1: Update CLAUDE.md**
|
||||
|
||||
Architecture 섹션에 멀티심볼 관련 내용 추가:
|
||||
|
||||
- `main.py` → `Config` → 심볼별 `TradingBot` 인스턴스 → `asyncio.gather()`
|
||||
- `RiskManager` 공유 싱글턴 (글로벌 일일 손실 + 동일 방향 제한)
|
||||
- 모델/데이터 디렉토리: `models/{symbol}/`, `data/{symbol}/`
|
||||
|
||||
Common Commands 섹션 업데이트:
|
||||
|
||||
```bash
|
||||
# 단일 심볼 학습
|
||||
bash scripts/train_and_deploy.sh --symbol TRXUSDT
|
||||
|
||||
# 전체 심볼 학습
|
||||
bash scripts/train_and_deploy.sh --all
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: update architecture and commands for multi-symbol trading"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 순서 요약
|
||||
|
||||
| Task | 내용 | 의존성 |
|
||||
|------|------|--------|
|
||||
| 1 | Config: `symbols`, `correlation_symbols`, `max_same_direction` | 없음 |
|
||||
| 2 | RiskManager: 공유 싱글턴, async Lock, 동일 방향 제한 | Task 1 |
|
||||
| 3 | Exchange: `self.symbol` 분리 | 없음 (Task 1과 병렬 가능) |
|
||||
| 4 | TradingBot: `symbol`, `risk` 주입, `config.symbol` 제거 | Task 1, 2, 3 |
|
||||
| 5 | main.py: 심볼별 봇 생성 + gather | Task 4 |
|
||||
| 6 | MLFilter: 심볼별 모델 디렉토리 폴백 | Task 4 |
|
||||
| 7 | 학습 스크립트: `--symbol` / `--all` CLI | Task 1 |
|
||||
| 8 | 디렉토리 구조 + .env.example | 없음 |
|
||||
| 9 | 문서 업데이트 | 전체 완료 후 |
|
||||
|
||||
각 태스크 완료 후 기존 XRP 단일 모드에서 전체 테스트를 통과해야 한다.
|
||||
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
85
docs/plans/2026-03-06-strategy-parameter-sweep-plan.md
Normal file
85
docs/plans/2026-03-06-strategy-parameter-sweep-plan.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 전략 파라미터 스윕 계획
|
||||
|
||||
**날짜**: 2026-03-06
|
||||
**상태**: 완료
|
||||
|
||||
## 목표
|
||||
|
||||
Walk-Forward 백테스트를 활용하여 기본 기술 지표 전략(ML OFF)의 수익성 높은 파라미터 조합을 탐색하고, PF >= 1.0을 ML 재설계의 기반으로 확보한다.
|
||||
|
||||
## 배경
|
||||
|
||||
Walk-Forward 백테스트 결과 현재 XRP 전략이 비수익적(PF 0.71, -641 PnL)으로 확인되었다. 전략 파라미터 스윕은 5개 파라미터의 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 | 거래량 급증 감지 배수 |
|
||||
|
||||
|
||||
총 조합: 3 x 3 x 3 x 4 x 3 = **324**
|
||||
|
||||
## 구현
|
||||
|
||||
### 수정된 파일
|
||||
|
||||
- `src/indicators.py` — `get_signal()`에 `signal_threshold`, `adx_threshold`, `volume_multiplier` 파라미터 추가
|
||||
- `src/dataset_builder.py` — `_calc_signals()`에 동일 파라미터를 받아 벡터화 계산에 적용
|
||||
- `src/backtester.py` — `BacktestConfig`에 전략 파라미터 포함; `WalkForwardBacktester`가 테스트 폴드에 전파
|
||||
|
||||
### 신규 생성 파일
|
||||
|
||||
- `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 |
|
||||
| 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 |
|
||||
| 4 | 1.0 | 4.0 | 3 | 30 | 2.5 | 19 | 36.8% | 1.80 | 7.7% | +248 | 37.1 |
|
||||
| 5 | 1.5 | 3.0 | 3 | 30 | 2.5 | 19 | 52.6% | 1.76 | 10.1% | +258 | 40.9 |
|
||||
| 6 | 1.5 | 4.0 | 3 | 25 | 2.5 | 28 | 42.9% | 1.75 | 13.1% | +381 | 36.8 |
|
||||
| 7 | 2.0 | 4.0 | 3 | 30 | 1.5 | 39 | 48.7% | 1.67 | 16.9% | +572 | 35.3 |
|
||||
| 8 | 1.0 | 2.0 | 3 | 25 | 2.5 | 28 | 50.0% | 1.64 | 5.8% | +205 | 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 |
|
||||
|
||||
|
||||
### 현재 프로덕션 (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. **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%).
|
||||
|
||||
## 권장 다음 단계
|
||||
|
||||
1. **프로덕션 기본값 업데이트**: ADX=25, volume_multiplier=2.0을 보수적 선택으로 적용 (ADX=30보다 더 많은 거래 확보)
|
||||
2. **TRXUSDT, DOGEUSDT에서 검증**: ADX 필터가 XRP에만 특화된 것이 아닌지 확인
|
||||
3. **ML 모델 재학습**: 업데이트된 전략 파라미터로 — ML 필터가 수익성 있는 기반 위에서 개선 가능
|
||||
4. **수익 구간 주변 세밀 스윕**: 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 signal
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
from src.config import Config
|
||||
@@ -9,18 +11,82 @@ from src.logger_setup import setup_logger
|
||||
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():
|
||||
setup_logger(log_level="INFO")
|
||||
config = 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 = []
|
||||
for symbol in config.symbols:
|
||||
bot = TradingBot(config, symbol=symbol, risk=risk)
|
||||
bots.append(bot)
|
||||
|
||||
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__":
|
||||
|
||||
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())
|
||||
0
models/dogeusdt/.gitkeep
Normal file
0
models/dogeusdt/.gitkeep
Normal file
BIN
models/dogeusdt/lgbm_filter.pkl
Normal file
BIN
models/dogeusdt/lgbm_filter.pkl
Normal file
Binary file not shown.
BIN
models/dogeusdt/lgbm_filter_prev.pkl
Normal file
BIN
models/dogeusdt/lgbm_filter_prev.pkl
Normal file
Binary file not shown.
102
models/dogeusdt/training_log.json
Normal file
102
models/dogeusdt/training_log.json
Normal file
@@ -0,0 +1,102 @@
|
||||
[
|
||||
{
|
||||
"date": "2026-03-05T23:54:51.517734",
|
||||
"backend": "lgbm",
|
||||
"auc": 0.9565,
|
||||
"best_threshold": 0.3318,
|
||||
"best_precision": 0.548,
|
||||
"best_recall": 0.489,
|
||||
"samples": 3330,
|
||||
"features": 26,
|
||||
"time_weight_decay": 2.0,
|
||||
"model_path": "models/dogeusdt/lgbm_filter.pkl",
|
||||
"tuned_params_path": null,
|
||||
"lgbm_params": {
|
||||
"n_estimators": 434,
|
||||
"learning_rate": 0.123659,
|
||||
"max_depth": 6,
|
||||
"num_leaves": 14,
|
||||
"min_child_samples": 10,
|
||||
"subsample": 0.929062,
|
||||
"colsample_bytree": 0.94633,
|
||||
"reg_alpha": 0.573971,
|
||||
"reg_lambda": 0.000157
|
||||
},
|
||||
"weight_scale": 1.783105
|
||||
},
|
||||
{
|
||||
"date": "2026-03-06T02:00:56.287381",
|
||||
"backend": "lgbm",
|
||||
"auc": 0.9555,
|
||||
"best_threshold": 0.4012,
|
||||
"best_precision": 0.577,
|
||||
"best_recall": 0.319,
|
||||
"samples": 3330,
|
||||
"features": 26,
|
||||
"time_weight_decay": 2.0,
|
||||
"model_path": "models/dogeusdt/lgbm_filter.pkl",
|
||||
"tuned_params_path": null,
|
||||
"lgbm_params": {
|
||||
"n_estimators": 434,
|
||||
"learning_rate": 0.123659,
|
||||
"max_depth": 6,
|
||||
"num_leaves": 14,
|
||||
"min_child_samples": 10,
|
||||
"subsample": 0.929062,
|
||||
"colsample_bytree": 0.94633,
|
||||
"reg_alpha": 0.573971,
|
||||
"reg_lambda": 0.000157
|
||||
},
|
||||
"weight_scale": 1.783105
|
||||
},
|
||||
{
|
||||
"date": "2026-03-06T22:37:26.751875",
|
||||
"backend": "lgbm",
|
||||
"auc": 0.9565,
|
||||
"best_threshold": 0.4047,
|
||||
"best_precision": 0.65,
|
||||
"best_recall": 0.277,
|
||||
"samples": 3336,
|
||||
"features": 26,
|
||||
"time_weight_decay": 2.0,
|
||||
"model_path": "models/dogeusdt/lgbm_filter.pkl",
|
||||
"tuned_params_path": null,
|
||||
"lgbm_params": {
|
||||
"n_estimators": 434,
|
||||
"learning_rate": 0.123659,
|
||||
"max_depth": 6,
|
||||
"num_leaves": 14,
|
||||
"min_child_samples": 10,
|
||||
"subsample": 0.929062,
|
||||
"colsample_bytree": 0.94633,
|
||||
"reg_alpha": 0.573971,
|
||||
"reg_lambda": 0.000157
|
||||
},
|
||||
"weight_scale": 1.783105
|
||||
},
|
||||
{
|
||||
"date": "2026-03-06T23:35:19.306197",
|
||||
"backend": "lgbm",
|
||||
"auc": 0.9552,
|
||||
"best_threshold": 0.8009,
|
||||
"best_precision": 0.75,
|
||||
"best_recall": 0.2,
|
||||
"samples": 744,
|
||||
"features": 26,
|
||||
"time_weight_decay": 2.0,
|
||||
"model_path": "models/dogeusdt/lgbm_filter.pkl",
|
||||
"tuned_params_path": null,
|
||||
"lgbm_params": {
|
||||
"n_estimators": 434,
|
||||
"learning_rate": 0.123659,
|
||||
"max_depth": 6,
|
||||
"num_leaves": 14,
|
||||
"min_child_samples": 10,
|
||||
"subsample": 0.929062,
|
||||
"colsample_bytree": 0.94633,
|
||||
"reg_alpha": 0.573971,
|
||||
"reg_lambda": 0.000157
|
||||
},
|
||||
"weight_scale": 1.783105
|
||||
}
|
||||
]
|
||||
0
models/trxusdt/.gitkeep
Normal file
0
models/trxusdt/.gitkeep
Normal file
BIN
models/trxusdt/lgbm_filter.pkl
Normal file
BIN
models/trxusdt/lgbm_filter.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.
102
models/trxusdt/training_log.json
Normal file
102
models/trxusdt/training_log.json
Normal file
@@ -0,0 +1,102 @@
|
||||
[
|
||||
{
|
||||
"date": "2026-03-05T23:54:05.625978",
|
||||
"backend": "lgbm",
|
||||
"auc": 0.947,
|
||||
"best_threshold": 0.2822,
|
||||
"best_precision": 0.446,
|
||||
"best_recall": 0.763,
|
||||
"samples": 2940,
|
||||
"features": 26,
|
||||
"time_weight_decay": 2.0,
|
||||
"model_path": "models/trxusdt/lgbm_filter.pkl",
|
||||
"tuned_params_path": null,
|
||||
"lgbm_params": {
|
||||
"n_estimators": 434,
|
||||
"learning_rate": 0.123659,
|
||||
"max_depth": 6,
|
||||
"num_leaves": 14,
|
||||
"min_child_samples": 10,
|
||||
"subsample": 0.929062,
|
||||
"colsample_bytree": 0.94633,
|
||||
"reg_alpha": 0.573971,
|
||||
"reg_lambda": 0.000157
|
||||
},
|
||||
"weight_scale": 1.783105
|
||||
},
|
||||
{
|
||||
"date": "2026-03-06T02:00:40.471987",
|
||||
"backend": "lgbm",
|
||||
"auc": 0.9433,
|
||||
"best_threshold": 0.2433,
|
||||
"best_precision": 0.439,
|
||||
"best_recall": 0.947,
|
||||
"samples": 2940,
|
||||
"features": 26,
|
||||
"time_weight_decay": 2.0,
|
||||
"model_path": "models/trxusdt/lgbm_filter.pkl",
|
||||
"tuned_params_path": null,
|
||||
"lgbm_params": {
|
||||
"n_estimators": 434,
|
||||
"learning_rate": 0.123659,
|
||||
"max_depth": 6,
|
||||
"num_leaves": 14,
|
||||
"min_child_samples": 10,
|
||||
"subsample": 0.929062,
|
||||
"colsample_bytree": 0.94633,
|
||||
"reg_alpha": 0.573971,
|
||||
"reg_lambda": 0.000157
|
||||
},
|
||||
"weight_scale": 1.783105
|
||||
},
|
||||
{
|
||||
"date": "2026-03-06T22:37:17.762061",
|
||||
"backend": "lgbm",
|
||||
"auc": 0.9493,
|
||||
"best_threshold": 0.2613,
|
||||
"best_precision": 0.448,
|
||||
"best_recall": 0.975,
|
||||
"samples": 2952,
|
||||
"features": 26,
|
||||
"time_weight_decay": 2.0,
|
||||
"model_path": "models/trxusdt/lgbm_filter.pkl",
|
||||
"tuned_params_path": null,
|
||||
"lgbm_params": {
|
||||
"n_estimators": 434,
|
||||
"learning_rate": 0.123659,
|
||||
"max_depth": 6,
|
||||
"num_leaves": 14,
|
||||
"min_child_samples": 10,
|
||||
"subsample": 0.929062,
|
||||
"colsample_bytree": 0.94633,
|
||||
"reg_alpha": 0.573971,
|
||||
"reg_lambda": 0.000157
|
||||
},
|
||||
"weight_scale": 1.783105
|
||||
},
|
||||
{
|
||||
"date": "2026-03-06T23:35:11.188338",
|
||||
"backend": "lgbm",
|
||||
"auc": 0.96,
|
||||
"best_threshold": 0.6121,
|
||||
"best_precision": 0.75,
|
||||
"best_recall": 0.6,
|
||||
"samples": 648,
|
||||
"features": 26,
|
||||
"time_weight_decay": 2.0,
|
||||
"model_path": "models/trxusdt/lgbm_filter.pkl",
|
||||
"tuned_params_path": null,
|
||||
"lgbm_params": {
|
||||
"n_estimators": 434,
|
||||
"learning_rate": 0.123659,
|
||||
"max_depth": 6,
|
||||
"num_leaves": 14,
|
||||
"min_child_samples": 10,
|
||||
"subsample": 0.929062,
|
||||
"colsample_bytree": 0.94633,
|
||||
"reg_alpha": 0.573971,
|
||||
"reg_lambda": 0.000157
|
||||
},
|
||||
"weight_scale": 1.783105
|
||||
}
|
||||
]
|
||||
0
models/xrpusdt/.gitkeep
Normal file
0
models/xrpusdt/.gitkeep
Normal file
BIN
models/xrpusdt/lgbm_filter.pkl
Normal file
BIN
models/xrpusdt/lgbm_filter.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.
102
models/xrpusdt/training_log.json
Normal file
102
models/xrpusdt/training_log.json
Normal file
@@ -0,0 +1,102 @@
|
||||
[
|
||||
{
|
||||
"date": "2026-03-05T23:53:20.451588",
|
||||
"backend": "lgbm",
|
||||
"auc": 0.9428,
|
||||
"best_threshold": 0.8486,
|
||||
"best_precision": 0.583,
|
||||
"best_recall": 0.171,
|
||||
"samples": 3222,
|
||||
"features": 26,
|
||||
"time_weight_decay": 2.0,
|
||||
"model_path": "models/xrpusdt/lgbm_filter.pkl",
|
||||
"tuned_params_path": null,
|
||||
"lgbm_params": {
|
||||
"n_estimators": 434,
|
||||
"learning_rate": 0.123659,
|
||||
"max_depth": 6,
|
||||
"num_leaves": 14,
|
||||
"min_child_samples": 10,
|
||||
"subsample": 0.929062,
|
||||
"colsample_bytree": 0.94633,
|
||||
"reg_alpha": 0.573971,
|
||||
"reg_lambda": 0.000157
|
||||
},
|
||||
"weight_scale": 1.783105
|
||||
},
|
||||
{
|
||||
"date": "2026-03-06T02:00:24.712465",
|
||||
"backend": "lgbm",
|
||||
"auc": 0.9456,
|
||||
"best_threshold": 0.7213,
|
||||
"best_precision": 0.6,
|
||||
"best_recall": 0.22,
|
||||
"samples": 3222,
|
||||
"features": 26,
|
||||
"time_weight_decay": 2.0,
|
||||
"model_path": "models/xrpusdt/lgbm_filter.pkl",
|
||||
"tuned_params_path": null,
|
||||
"lgbm_params": {
|
||||
"n_estimators": 434,
|
||||
"learning_rate": 0.123659,
|
||||
"max_depth": 6,
|
||||
"num_leaves": 14,
|
||||
"min_child_samples": 10,
|
||||
"subsample": 0.929062,
|
||||
"colsample_bytree": 0.94633,
|
||||
"reg_alpha": 0.573971,
|
||||
"reg_lambda": 0.000157
|
||||
},
|
||||
"weight_scale": 1.783105
|
||||
},
|
||||
{
|
||||
"date": "2026-03-06T22:37:08.529734",
|
||||
"backend": "lgbm",
|
||||
"auc": 0.9448,
|
||||
"best_threshold": 0.7881,
|
||||
"best_precision": 0.538,
|
||||
"best_recall": 0.167,
|
||||
"samples": 3234,
|
||||
"features": 26,
|
||||
"time_weight_decay": 2.0,
|
||||
"model_path": "models/xrpusdt/lgbm_filter.pkl",
|
||||
"tuned_params_path": null,
|
||||
"lgbm_params": {
|
||||
"n_estimators": 434,
|
||||
"learning_rate": 0.123659,
|
||||
"max_depth": 6,
|
||||
"num_leaves": 14,
|
||||
"min_child_samples": 10,
|
||||
"subsample": 0.929062,
|
||||
"colsample_bytree": 0.94633,
|
||||
"reg_alpha": 0.573971,
|
||||
"reg_lambda": 0.000157
|
||||
},
|
||||
"weight_scale": 1.783105
|
||||
},
|
||||
{
|
||||
"date": "2026-03-06T23:35:02.930027",
|
||||
"backend": "lgbm",
|
||||
"auc": 0.9598,
|
||||
"best_threshold": 0.4674,
|
||||
"best_precision": 1.0,
|
||||
"best_recall": 0.182,
|
||||
"samples": 618,
|
||||
"features": 26,
|
||||
"time_weight_decay": 2.0,
|
||||
"model_path": "models/xrpusdt/lgbm_filter.pkl",
|
||||
"tuned_params_path": null,
|
||||
"lgbm_params": {
|
||||
"n_estimators": 434,
|
||||
"learning_rate": 0.123659,
|
||||
"max_depth": 6,
|
||||
"num_leaves": 14,
|
||||
"min_child_samples": 10,
|
||||
"subsample": 0.929062,
|
||||
"colsample_bytree": 0.94633,
|
||||
"reg_alpha": 0.573971,
|
||||
"reg_lambda": 0.000157
|
||||
},
|
||||
"weight_scale": 1.783105
|
||||
}
|
||||
]
|
||||
@@ -5,7 +5,7 @@ python-dotenv==1.0.0
|
||||
httpx>=0.27.0
|
||||
pytest>=8.1.0
|
||||
pytest-asyncio>=0.24.0
|
||||
aiohttp==3.9.3
|
||||
aiohttp>=3.10.11
|
||||
websockets==12.0
|
||||
loguru==0.7.2
|
||||
lightgbm>=4.3.0
|
||||
@@ -14,3 +14,5 @@ joblib>=1.3.0
|
||||
pyarrow>=15.0.0
|
||||
onnxruntime>=1.18.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": "트레이드 없음 (검증 스킵)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user