Compare commits
77 Commits
6a6740d708
...
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 |
10
.env.example
10
.env.example
@@ -2,16 +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=
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -16,3 +16,5 @@ data/*.parquet
|
||||
|
||||
.worktrees/
|
||||
.venv
|
||||
dashboard/ui/node_modules/
|
||||
dashboard/ui/dist/
|
||||
599
ARCHITECTURE.md
599
ARCHITECTURE.md
@@ -1,40 +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차 필터로 사용하여 다중 심볼(XRP, TRX, DOGE 등) 선물 포지션을 동시에 자동 진입·청산합니다.
|
||||
CoinTrader는 **Binance Futures 자동매매 봇**입니다.
|
||||
|
||||
### 멀티심볼 아키텍처
|
||||
**한 줄 요약**: 15분마다 기술 지표로 매매 신호를 생성하고, ML 모델로 한 번 더 검증한 뒤, 조건을 충족하면 자동으로 주문을 넣습니다.
|
||||
|
||||
### 1.1 전체 흐름 (간략)
|
||||
|
||||
```
|
||||
15분봉 마감 → 기술 지표 계산 → 매매 신호 생성 → ML 필터 검증 → 리스크 체크 → 주문 실행 → Discord 알림
|
||||
```
|
||||
|
||||
### 1.2 멀티심볼 아키텍처
|
||||
|
||||
여러 심볼을 동시에 거래합니다. 각 심볼은 독립된 봇 인스턴스로 실행되며, 리스크 관리만 공유합니다.
|
||||
|
||||
```
|
||||
main.py
|
||||
└─ Config (SYMBOLS=XRPUSDT,TRXUSDT,DOGEUSDT)
|
||||
└─ Config (SYMBOLS=XRPUSDT) # 멀티심볼 지원, 현재 XRP만 운영
|
||||
└─ RiskManager (공유 싱글턴, asyncio.Lock)
|
||||
└─ asyncio.gather(
|
||||
TradingBot(symbol="XRPUSDT", risk=shared_risk),
|
||||
TradingBot(symbol="TRXUSDT", risk=shared_risk),
|
||||
TradingBot(symbol="DOGEUSDT", risk=shared_risk),
|
||||
)
|
||||
```
|
||||
|
||||
각 봇은 독립적인 `Exchange`, `MLFilter`, `DataStream`을 소유합니다. `RiskManager`만 공유 싱글턴으로 글로벌 리스크(일일 손실 한도, 동일 방향 제한, 최대 포지션 수)를 관리합니다.
|
||||
> **운영 이력**: SOL/DOGE/TRX는 파라미터 스윕에서 모든 조합에서 PF < 1.0으로 제외 (2026-03-21).
|
||||
|
||||
### 전체 데이터 파이프라인 흐름도
|
||||
- **독립**: 각 봇은 자체 `Exchange`, `MLFilter`, `DataStream`, `SymbolStrategyParams`를 소유
|
||||
- **공유**: `RiskManager`만 싱글턴으로 글로벌 리스크(일일 손실 한도, 동일 방향 제한) 관리
|
||||
- **병렬**: `asyncio.gather()`로 동시 실행, 서로 간섭 없음
|
||||
- **심볼별 전략**: `config.get_symbol_params(symbol)`로 SL/TP/ADX 등을 심볼별 독립 설정 (`ATR_SL_MULT_XRPUSDT` 등 환경변수)
|
||||
|
||||
### 1.3 기술 스택
|
||||
|
||||
| 분류 | 기술 |
|
||||
|------|------|
|
||||
| 언어 | Python 3.11+ |
|
||||
| 비동기 런타임 | `asyncio` + `python-binance` WebSocket |
|
||||
| 기술 지표 | `pandas-ta` (RSI, MACD, BB, EMA, StochRSI, ATR, ADX) |
|
||||
| ML 프레임워크 | `LightGBM` (CPU) / `MLX` (Apple Silicon GPU) |
|
||||
| 모델 서빙 | `onnxruntime` (ONNX 우선) / `joblib` (LightGBM 폴백) |
|
||||
| 하이퍼파라미터 탐색 | `Optuna` (TPE Sampler + MedianPruner) |
|
||||
| 데이터 저장 | `Parquet` (pyarrow) |
|
||||
| 로깅 | `Loguru` |
|
||||
| 알림 | Discord Webhook (`httpx`) |
|
||||
| 컨테이너화 | Docker + Docker Compose |
|
||||
| CI/CD | Jenkins + Gitea Container Registry |
|
||||
|
||||
### 1.4 데이터 파이프라인 전체 흐름도
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -47,21 +79,21 @@ flowchart TD
|
||||
subgraph 실시간봇["실시간 봇 (bot.py — asyncio)"]
|
||||
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 시 진입 허용"]
|
||||
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
|
||||
@@ -84,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개의 레이어로 구성됩니다. 각 레이어는 단일 책임을 가지며, 위에서 아래로 데이터가 흐릅니다.
|
||||
|
||||
@@ -134,7 +199,7 @@ flowchart TD
|
||||
|
||||
**파일:** `src/data_stream.py`
|
||||
|
||||
각 봇 인스턴스가 시작되면 가장 먼저 실행되는 레이어입니다. Binance Combined WebSocket 단일 연결로 주 거래 심볼 + 상관관계 심볼(BTC/ETH)의 15분봉 캔들을 동시에 수신합니다.
|
||||
Binance Combined WebSocket 단일 연결로 주 거래 심볼 + 상관관계 심볼(BTC/ETH)의 15분봉 캔들을 동시에 수신합니다.
|
||||
|
||||
**핵심 동작:**
|
||||
|
||||
@@ -170,7 +235,7 @@ flowchart TD
|
||||
| ADX | length=14 | 추세 강도 측정 → 횡보장 필터 (ADX < 25 시 진입 차단) |
|
||||
| Volume MA | length=20 | 거래량 급증 감지 |
|
||||
|
||||
**신호 생성 로직 (ADX 필터 + 가중치 합산):**
|
||||
**신호 생성 로직:**
|
||||
|
||||
```
|
||||
[1단계] ADX 횡보장 필터:
|
||||
@@ -183,9 +248,13 @@ flowchart TD
|
||||
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/거래량배수 모두 환경변수로 설정 가능 (심볼별 오버라이드 지원)
|
||||
```
|
||||
|
||||
숏 신호는 롱의 대칭 조건으로 계산됩니다.
|
||||
@@ -196,7 +265,7 @@ TP = 진입가 + ATR × 3.0 (리스크:리워드 = 1:2)
|
||||
|
||||
**파일:** `src/ml_filter.py`, `src/ml_features.py`
|
||||
|
||||
기술 지표 신호가 발생해도 ML 모델이 "이 타점은 실패 확률이 높다"고 판단하면 진입을 차단합니다. 오진입(억까 타점)을 줄이는 2차 게이트키퍼입니다.
|
||||
기술 지표 신호가 발생해도 ML 모델이 "이 타점은 실패 확률이 높다"고 판단하면 진입을 차단합니다. 오진입을 줄이는 2차 게이트키퍼입니다.
|
||||
|
||||
**모델 우선순위:**
|
||||
|
||||
@@ -206,7 +275,7 @@ ONNX (MLX 신경망) → LightGBM → 폴백(항상 허용)
|
||||
|
||||
모델 파일이 없으면 모든 신호를 허용합니다. 봇 재시작 없이 모델 파일을 교체하면 다음 캔들 마감 시 자동으로 핫리로드됩니다(`mtime` 감지).
|
||||
|
||||
**23개 ML 피처:**
|
||||
**26개 ML 피처:**
|
||||
|
||||
```
|
||||
XRP 기술 지표 (13개):
|
||||
@@ -222,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`으로 폴백하여 봇이 멈추지 않습니다.
|
||||
@@ -230,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 환경변수로 조절)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -254,28 +330,33 @@ ML 필터를 통과한 신호를 실제 주문으로 변환하고, 리스크 한
|
||||
**주문 흐름:**
|
||||
|
||||
```
|
||||
1. set_leverage(10x)
|
||||
2. place_order(MARKET) ← 진입
|
||||
3. place_order(STOP_MARKET) ← SL 설정
|
||||
4. place_order(TAKE_PROFIT_MARKET) ← TP 설정
|
||||
1. set_leverage(20x)
|
||||
2. place_order(MARKET) ← 진입
|
||||
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개 (전체 심볼 합산) |
|
||||
| 동일 방향 제한 | 2개 (LONG 2개면 3번째 LONG 차단) |
|
||||
| 같은 심볼 중복 | 차단 (1심볼 1포지션) |
|
||||
| 최소 명목금액 | $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
|
||||
@@ -307,7 +388,7 @@ Binance `ORDER_TRADE_UPDATE` 웹소켓 이벤트를 구독하여 TP/SL 체결을
|
||||
net_pnl = realized_pnl - commission
|
||||
```
|
||||
|
||||
**Discord 알림 포맷:**
|
||||
**Discord 알림 예시:**
|
||||
|
||||
진입 시:
|
||||
```
|
||||
@@ -319,7 +400,7 @@ RSI: 32.50 | MACD Hist: -0.000123 | ATR: 0.023400
|
||||
|
||||
청산 시:
|
||||
```
|
||||
✅ [XRPUSDT] LONG TP 청산
|
||||
[XRPUSDT] LONG TP 청산
|
||||
청산가: 2.4150
|
||||
예상 수익: +7.0000 USDT
|
||||
실제 순수익: +6.7800 USDT
|
||||
@@ -328,20 +409,20 @@ RSI: 32.50 | MACD Hist: -0.000123 | ATR: 0.023400
|
||||
|
||||
---
|
||||
|
||||
## 3. MLOps 파이프라인 — 자가 진화 시스템
|
||||
## 4. MLOps 파이프라인
|
||||
|
||||
봇의 ML 모델은 고정된 것이 아니라 주기적으로 재학습·개선됩니다. 전체 라이프사이클은 다음과 같습니다.
|
||||
봇의 ML 모델은 고정된 것이 아니라 주기적으로 재학습·개선됩니다.
|
||||
|
||||
### 3.1 전체 라이프사이클
|
||||
### 4.1 전체 라이프사이클
|
||||
|
||||
```mermaid
|
||||
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
|
||||
@@ -354,7 +435,7 @@ flowchart LR
|
||||
G --> A
|
||||
```
|
||||
|
||||
### 3.2 단계별 상세 설명
|
||||
### 4.2 단계별 상세
|
||||
|
||||
#### Step 1: Optuna 하이퍼파라미터 탐색
|
||||
|
||||
@@ -404,7 +485,7 @@ Optuna가 찾은 파라미터는 **자동으로 적용되지 않습니다.** 사
|
||||
|
||||
> **주의**: Optuna 결과는 과적합 위험이 있습니다. 폴드별 AUC 분산이 크거나 (std > 0.05), 개선폭이 미미하면 (< 0.01) 적용하지 않는 것을 권장합니다.
|
||||
|
||||
#### Step 3: 자동 학습 및 배포 (크론탭)
|
||||
#### Step 3: 자동 학습 및 배포
|
||||
|
||||
`scripts/train_and_deploy.sh`는 3단계를 자동으로 실행합니다:
|
||||
|
||||
@@ -421,8 +502,8 @@ Optuna가 찾은 파라미터는 **자동으로 적용되지 않습니다.** 사
|
||||
- Walk-Forward 5폴드 검증 후 최종 모델 저장
|
||||
- 학습 로그: models/{symbol}/training_log.json
|
||||
|
||||
[3/3] LXC 배포 (deploy_model.sh --symbol {SYM})
|
||||
- rsync로 models/{symbol}/lgbm_filter.pkl → LXC 서버 전송
|
||||
[3/3] 운영 서버 배포 (deploy_model.sh --symbol {SYM})
|
||||
- rsync로 models/{symbol}/lgbm_filter.pkl → 운영 서버 전송
|
||||
- 기존 모델 자동 백업 (lgbm_filter_prev.pkl)
|
||||
- ONNX 파일 충돌 방지 (우선순위 보장)
|
||||
```
|
||||
@@ -444,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 (실패)
|
||||
@@ -463,11 +564,11 @@ if onnx_changed or lgbm_changed:
|
||||
|
||||
---
|
||||
|
||||
## 4. 핵심 동작 시나리오
|
||||
## 5. 핵심 동작 시나리오
|
||||
|
||||
### 시나리오 1: 15분 캔들 마감 시 봇의 동작 흐름
|
||||
### 시나리오 1: 15분 캔들 마감 → 진입 판단
|
||||
|
||||
> "XRP 15분봉이 마감되면 봇은 무엇을 하는가?"
|
||||
> "15분봉이 마감되면 봇은 무엇을 하는가?"
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -496,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)
|
||||
@@ -520,7 +621,7 @@ sequenceDiagram
|
||||
|
||||
---
|
||||
|
||||
### 시나리오 2: TP/SL 체결 시 봇의 동작 흐름
|
||||
### 시나리오 2: TP/SL 체결 → 포지션 종료
|
||||
|
||||
> "거래소에서 TP가 작동하면 봇은 어떻게 반응하는가?"
|
||||
|
||||
@@ -550,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
|
||||
@@ -558,112 +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/` 폴더에 14개 테스트 파일, 총 **99개의 테스트 케이스**가 작성되어 있습니다.
|
||||
**파일:** `src/mtf_bot.py`
|
||||
|
||||
### 왜 MTF 봇을 만들었는가
|
||||
|
||||
메인 봇의 기술 지표 기반 접근(RSI+MACD+BB+EMA+StochRSI)은 PF 0.89로 수익성이 부족했습니다. 이를 개선하기 위해 여러 방향을 시도했으나 모두 실패했습니다:
|
||||
|
||||
| 시도 | 결과 | 판정 |
|
||||
|------|------|------|
|
||||
| ML 필터 (LightGBM 26피처) | ML OFF > ML ON | 폐기 — 피처 알파 부족 |
|
||||
| 멀티심볼 확장 (SOL/DOGE/TRX) | 전 심볼 PF < 1.0 | 폐기 — XRP 단독 운영 |
|
||||
| L/S Ratio 시그널 | 전 조합 PF < 1.0 | 폐기 — edge 없음 |
|
||||
| FR × OI 변화율 | SHORT PF=1.88 / LONG PF=0.50 | 폐기 — 대칭성 실패 |
|
||||
| Taker Buy/Sell Ratio | PF 0.93 | 폐기 — 거래비용 커버 불가 |
|
||||
|
||||
Binance 공개 API 피처 전수 테스트(2026-03-30) 결과, **단독 edge를 가진 피처가 없음**이 확정되었습니다. 핵심 교훈은 "r < 0.15인 시그널은 거래비용(0.08%) 커버 불가"라는 것이었습니다.
|
||||
|
||||
이에 **피처 추가가 아닌 접근 방식 자체를 전환**했습니다:
|
||||
|
||||
- **기존**: 15분봉 단일 타임프레임 + 지표 가중치 합산 → 피처 알파 부족
|
||||
- **전환**: 멀티타임프레임 정보 비대칭 활용 → 1h 추세 확인 후 15m 풀백 패턴 진입
|
||||
|
||||
MTF 접근은 동일 Binance 데이터로도 **"언제 진입하느냐"를 바꿈으로써** edge를 확보하려는 시도입니다. 1h 추세 필터가 횡보장 거래를 제거하고, 3캔들 풀백 시퀀스가 노이즈 진입을 줄여 거래 품질을 높입니다.
|
||||
|
||||
현재 4월 OOS Dry-run으로 실전 검증 중이며, 50건 이상 누적 후 PF를 기준으로 LIVE 전환 여부를 판단합니다.
|
||||
|
||||
### 전략 핵심 아이디어
|
||||
|
||||
> **"1시간봉으로 추세를 확인하고, 15분봉에서 일시적 이탈(풀백) 후 복귀하는 순간에 추세 방향으로 진입한다."**
|
||||
|
||||
메인 봇(`bot.py`)이 RSI·MACD·BB 등 기술 지표 가중치 합산으로 신호를 만드는 것과 달리, MTF 봇은 **타임프레임 간 정보 비대칭**을 활용합니다. 상위 프레임(1h)의 거시 추세가 확인된 상태에서, 하위 프레임(15m)의 일시적 역행을 노이즈로 간주하고 추세 복귀 시점에 진입합니다.
|
||||
|
||||
### 아키텍처 (4개 모듈)
|
||||
|
||||
```
|
||||
Module 1: TimeframeSync + DataFetcher
|
||||
│ REST 폴링(30초 주기), deque(maxlen=250)으로 15m/1h 캔들 관리
|
||||
│ Look-ahead bias 차단: _remove_incomplete_candle()로 미완성 봉 제외
|
||||
▼
|
||||
Module 2: MetaFilter (1h 거시 추세 판독)
|
||||
│ EMA50 vs EMA200 + ADX > 20 → LONG_ALLOWED / SHORT_ALLOWED / WAIT
|
||||
│ WAIT 상태에서는 모든 진입을 차단 (횡보장 방어)
|
||||
▼
|
||||
Module 3: TriggerStrategy (15m 풀백 패턴 인식)
|
||||
│ 3캔들 시퀀스: t-2(기준) → t-1(풀백: EMA 이탈 + 거래량 고갈) → t(돌파: EMA 복귀)
|
||||
│ Volume-backed 확인: vol_t-1 < vol_sma20 × 0.50
|
||||
▼
|
||||
Module 4: ExecutionManager (Dry-run 가상 주문)
|
||||
│ 가상 포지션 진입/청산, ATR 기반 SL/TP 관리
|
||||
│ 듀얼 레이어 킬스위치: Fast Kill (8연패) + Slow Kill (15거래 PF<0.75)
|
||||
└→ Discord 알림 + JSONL 거래 기록
|
||||
```
|
||||
|
||||
### 작동 원리 상세
|
||||
|
||||
#### Module 1: TimeframeSync + DataFetcher
|
||||
|
||||
**TimeframeSync** — 현재 시각이 캔들 마감 직후인지 판별합니다.
|
||||
|
||||
- 15분 캔들: 분(minute)이 `{0, 15, 30, 45}` 이고 초(second)가 2~5초 사이
|
||||
- 1시간 캔들: 분이 `0`이고 초가 2~5초 사이
|
||||
- 2~5초 윈도우는 Binance 서버가 캔들을 확정하는 딜레이를 고려한 것
|
||||
|
||||
**DataFetcher** — ccxt를 통해 Binance Futures REST API로 OHLCV 데이터를 관리합니다.
|
||||
|
||||
- 초기화 시 15m/1h 각각 250개 캔들을 `deque(maxlen=250)`에 적재
|
||||
- 30초마다 최근 3개 캔들을 폴링하여 새 캔들만 추가 (timestamp 비교로 중복 방지)
|
||||
- `_remove_incomplete_candle()`: 현재 진행 중인 캔들의 open timestamp를 계산하여, 마지막 캔들이 미완성이면 제거 → Look-ahead bias 원천 차단
|
||||
- WebSocket 대신 REST 폴링을 선택한 이유: 연결 끊김 리스크 제거, 30초 주기면 15분봉 매매에 충분
|
||||
|
||||
#### Module 2: MetaFilter (1h 거시 추세 판독)
|
||||
|
||||
완성된 1h 캔들로 거시 시장 상태를 3가지로 분류합니다.
|
||||
|
||||
```
|
||||
입력: 1h OHLCV (완성 캔들만)
|
||||
↓
|
||||
EMA50 = EMA(close, 50) ← 중기 이동평균
|
||||
EMA200 = EMA(close, 200) ← 장기 이동평균
|
||||
ADX = ADX(14) ← 추세 강도 (0~100)
|
||||
ATR = ATR(14) ← 변동성 (SL/TP 계산용)
|
||||
↓
|
||||
판정:
|
||||
EMA50 > EMA200 AND ADX > 20 → LONG_ALLOWED (상승 추세 확인)
|
||||
EMA50 < EMA200 AND ADX > 20 → SHORT_ALLOWED (하락 추세 확인)
|
||||
그 외 → WAIT (횡보장, 진입 차단)
|
||||
```
|
||||
|
||||
- **ADX 20 기준**: ADX가 20 미만이면 추세가 약하다고 판단, EMA 크로스만으로 진입하지 않음
|
||||
- **캔들 단위 캐싱**: 동일 1h 캔들 timestamp에 대해 지표를 재계산하지 않음 (`_cache_timestamp` 비교)
|
||||
- MetaFilter가 `WAIT`를 반환하면 Module 3(TriggerStrategy)는 아예 호출되지 않음
|
||||
|
||||
#### Module 3: TriggerStrategy (15m 풀백 패턴 인식)
|
||||
|
||||
MetaFilter가 추세를 확인한 후, 15분봉에서 **3캔들 시퀀스** 풀백 패턴을 인식합니다.
|
||||
|
||||
```
|
||||
LONG 시나리오 (meta_state = LONG_ALLOWED):
|
||||
|
||||
t-2 ────── 기준 캔들 (Vol_SMA20 산출용)
|
||||
t-1 ────── 풀백 캔들: ① close < EMA15 (이탈) AND ② volume < Vol_SMA20 × 0.50 (거래량 고갈)
|
||||
t ────── 돌파 캔들: close > EMA15 (복귀) → EXECUTE_LONG 신호
|
||||
|
||||
SHORT 시나리오 (meta_state = SHORT_ALLOWED):
|
||||
|
||||
t-2 ────── 기준 캔들
|
||||
t-1 ────── 풀백 캔들: ① close > EMA15 (이탈) AND ② volume < Vol_SMA20 × 0.50 (거래량 고갈)
|
||||
t ────── 돌파 캔들: close < EMA15 (복귀) → EXECUTE_SHORT 신호
|
||||
```
|
||||
|
||||
**3가지 조건이 모두 충족**되어야 진입 신호가 발생합니다:
|
||||
|
||||
1. **EMA 이탈** (t-1): 추세 반대 방향으로 일시 이탈 → 풀백 확인
|
||||
2. **거래량 고갈** (t-1): `vol_t-1 / vol_sma20_t-2 < 0.50` → 이탈이 거래량 없는 가짜 움직임인지 확인
|
||||
3. **EMA 복귀** (t): 추세 방향으로 다시 돌아옴 → 풀백 종료, 추세 재개 확인
|
||||
|
||||
하나라도 불충족이면 `HOLD`를 반환하며, 불충족 사유를 `_last_info`에 기록합니다.
|
||||
|
||||
#### Module 4: ExecutionManager (가상 주문 + SL/TP + 킬스위치)
|
||||
|
||||
**진입**: TriggerStrategy의 신호 + MetaFilter의 1h ATR 값으로 SL/TP를 설정합니다.
|
||||
|
||||
| 항목 | LONG | SHORT |
|
||||
|------|------|-------|
|
||||
| SL | entry - ATR × 1.5 | entry + ATR × 1.5 |
|
||||
| TP | entry + ATR × 2.3 | entry - ATR × 2.3 |
|
||||
| R:R | 1 : 1.53 | 1 : 1.53 |
|
||||
|
||||
- 중복 진입 차단: 이미 포지션이 있으면 새 신호 무시
|
||||
- ATR이 None/0/NaN이면 주문 차단
|
||||
|
||||
**SL/TP 모니터링**: 매 루프(1초)마다 보유 포지션의 SL/TP 도달을 15m 캔들 high/low로 확인합니다.
|
||||
|
||||
- LONG: `low ≤ SL` → SL 청산, `high ≥ TP` → TP 청산
|
||||
- SHORT: `high ≥ SL` → SL 청산, `low ≤ TP` → TP 청산
|
||||
- SL+TP 동시 히트 시: **SL 우선** (보수적 접근)
|
||||
- PnL은 bps(basis points) 단위로 계산: `(exit - entry) / entry × 10000`
|
||||
|
||||
**거래 기록**: 모든 청산은 `data/trade_history/mtf_{symbol}.jsonl`에 JSONL로 저장됩니다. 기록 항목: symbol, side, entry/exit price·ts, sl/tp price, atr, pnl_bps, reason.
|
||||
|
||||
**듀얼 킬스위치**:
|
||||
|
||||
| 종류 | 조건 | 설명 |
|
||||
|------|------|------|
|
||||
| Fast Kill | 최근 8거래 **연속** 손실 (pnl_bps < 0) | 급격한 손실 시 즉시 중단 |
|
||||
| Slow Kill | 최근 15거래 PF < 0.75 | 만성적 손실 시 중단 |
|
||||
|
||||
- 부팅 시 JSONL에서 최근 N건 복원 → 소급 검증 (재시작해도 킬스위치 상태 유지)
|
||||
- 킬스위치 발동 시: 신규 진입만 차단, 기존 포지션의 SL/TP 청산은 정상 작동
|
||||
- 수동 해제: `RESET_KILL_SWITCH_MTF_{SYMBOL}=True` 환경변수 + 재시작
|
||||
|
||||
### 메인 루프 (MTFPullbackBot)
|
||||
|
||||
```
|
||||
초기화: DataFetcher.initialize() → 250개 캔들 로드 → 초기 Meta 상태 출력 → Discord 알림
|
||||
↓
|
||||
while True (1초 주기):
|
||||
├─ 30초마다: _poll_and_update() → 15m/1h 최신 캔들 추가
|
||||
├─ 15m 캔들 마감 감지 (TimeframeSync):
|
||||
│ ├─ Heartbeat 로그 (Meta, ADX, EMA50/200, ATR, Close, Position)
|
||||
│ ├─ TriggerStrategy.generate_signal(df_15m, meta_state)
|
||||
│ ├─ 신호 ≠ HOLD → ExecutionManager.execute() → Discord 진입 알림
|
||||
│ └─ 신호 = HOLD → 사유 로그
|
||||
└─ 포지션 보유 중: _check_sl_tp() → SL/TP 도달 시 청산 + Discord 알림
|
||||
```
|
||||
|
||||
- 1초 루프인 이유: TimeframeSync의 2~5초 윈도우를 놓치지 않기 위함
|
||||
- 15m 중복 체크 방지: `_last_15m_check_ts`로 1분 이내 같은 캔들 이중 처리 차단
|
||||
- 캔들 마감 감지 시 즉시 `_poll_and_update()` 한 번 더 호출하여 최신 데이터 보장
|
||||
|
||||
### 메인 봇과의 차이점
|
||||
|
||||
| 항목 | 메인 봇 (`bot.py`) | MTF 봇 (`mtf_bot.py`) |
|
||||
|------|-------------------|----------------------|
|
||||
| 데이터 소스 | WebSocket (실시간 스트림) | REST 폴링 (30초 주기) |
|
||||
| 타임프레임 | 15분봉 단일 | 1h (추세) + 15m (진입) |
|
||||
| 신호 방식 | RSI·MACD·BB·EMA·StochRSI 가중치 합산 | 3캔들 풀백 시퀀스 패턴 |
|
||||
| ML 필터 | LightGBM/ONNX (26 피처) | 없음 (패턴 자체가 필터) |
|
||||
| 상관관계 | BTC/ETH 피처 사용 | 사용 안 함 |
|
||||
| SL/TP 계산 | 15m ATR 기반 | 1h ATR 기반 |
|
||||
| 반대 시그널 재진입 | 지원 (close → 역방향 open) | 미지원 (포지션 중 신호 무시) |
|
||||
| 실행 모드 | Live (실주문) | Dry-run (가상 주문) |
|
||||
| 프로세스 | 메인 프로세스 내 asyncio.gather | 별도 프로세스/Docker 서비스 |
|
||||
|
||||
### 설계 원칙
|
||||
|
||||
- **Look-ahead bias 원천 차단**: `_remove_incomplete_candle()`이 현재 진행 중인 캔들을 조건부 제거. 버퍼 250개 → 미완성 봉 제외 → EMA 200 정상 계산
|
||||
- **REST 폴링 안정성**: WebSocket 대신 30초 주기 REST 폴링으로 연결 끊김 리스크 제거
|
||||
- **Binance 서버 딜레이 고려**: 캔들 마감 판별 시 2~5초 윈도우 적용
|
||||
- **메인 봇과 독립**: `bot.py`와 별도 프로세스, 별도 Docker 서비스로 배포
|
||||
- **듀얼 킬스위치**: `ExecutionManager`에 내장. Fast Kill(8연패) + Slow Kill(15거래 PF<0.75, bps 기반). 부팅 시 JSONL에서 이력 복원 + 소급 검증. 수동 해제: `RESET_KILL_SWITCH_MTF_{SYMBOL}=True`
|
||||
|
||||
---
|
||||
|
||||
## 6. 테스트 커버리지
|
||||
|
||||
### 6.1 테스트 실행
|
||||
|
||||
```bash
|
||||
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` | 13 | 일일 손실 한도 초과 차단, 최대 포지션 수 제한, 동일 방향 제한, 심볼 중복 차단, 비동기 포지션 등록/해제, 동적 증거금 비율 상한/하한 클램핑 |
|
||||
| `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` | 6 | 환경변수 로드, 동적 증거금 파라미터 로드, `symbols` 리스트, `correlation_symbols`, `max_same_direction`, SYMBOL→symbols 폴백 |
|
||||
### 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_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` | — | 진입점. 심볼별 `TradingBot` 생성 + 공유 `RiskManager` + `asyncio.gather()` |
|
||||
| `src/bot.py` | 오케스트레이터 | 심볼별 독립 트레이딩 루프 (symbol, risk 주입) |
|
||||
| `src/config.py` | — | 환경변수 기반 설정 (`symbols` 리스트, `correlation_symbols`) |
|
||||
| `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/exchange.py` | Execution | Binance Futures REST API 클라이언트 |
|
||||
| `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/펀딩비 수집 (`--symbol` 지원) |
|
||||
| `scripts/train_model.py` | MLOps | LightGBM 모델 학습 (`--symbol` 지원) |
|
||||
| `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 하이퍼파라미터 탐색 (`--symbol` 지원) |
|
||||
| `scripts/train_and_deploy.sh` | MLOps | 전체 파이프라인 (`--symbol` / `--all` 지원) |
|
||||
| `scripts/deploy_model.sh` | MLOps | 모델 파일 LXC 서버 전송 (`--symbol` 지원) |
|
||||
| `models/{symbol}/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 파라미터 |
|
||||
|
||||
21
CLAUDE.md
21
CLAUDE.md
@@ -65,6 +65,8 @@ bash scripts/deploy_model.sh --symbol XRPUSDT
|
||||
4. `src/exchange.py` + `src/risk_manager.py` — Dynamic margin, MARKET orders with SL/TP, daily loss limit (5%), same-direction limit
|
||||
5. `src/user_data_stream.py` + `src/notifier.py` — Real-time TP/SL detection via WebSocket, Discord webhooks
|
||||
|
||||
**Dual-layer kill switch** (per-symbol, in `src/bot.py`): Fast Kill (8 consecutive net losses) + Slow Kill (last 15 trades PF < 0.75). Trade history persisted to `data/trade_history/{symbol}.jsonl`. Blocks new entries only; existing SL/TP exits work normally. Manual reset via `RESET_KILL_SWITCH_{SYMBOL}=True` env var + restart.
|
||||
|
||||
**Parallel execution**: Per-symbol bots run independently via `asyncio.gather()`. Each bot's `user_data_stream` also runs in parallel.
|
||||
|
||||
**Model/data directories**: `models/{symbol}/` and `data/{symbol}/` for per-symbol models. Falls back to `models/` root if symbol dir doesn't exist.
|
||||
@@ -88,9 +90,9 @@ bash scripts/deploy_model.sh --symbol XRPUSDT
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables via `.env` file (see `.env.example`). Key vars: `BINANCE_API_KEY`, `BINANCE_API_SECRET`, `SYMBOLS` (comma-separated, e.g. `XRPUSDT,TRXUSDT`), `CORRELATION_SYMBOLS` (default `BTCUSDT,ETHUSDT`), `LEVERAGE`, `DISCORD_WEBHOOK_URL`, `MARGIN_MAX_RATIO`, `MARGIN_MIN_RATIO`, `MAX_SAME_DIRECTION` (default 2), `NO_ML_FILTER`.
|
||||
Environment variables via `.env` file (see `.env.example`). Key vars: `BINANCE_API_KEY`, `BINANCE_API_SECRET`, `SYMBOLS` (comma-separated, currently `XRPUSDT` only — SOL/DOGE/TRX removed due to PF < 1.0), `CORRELATION_SYMBOLS` (default `BTCUSDT,ETHUSDT`), `LEVERAGE`, `DISCORD_WEBHOOK_URL`, `MARGIN_MAX_RATIO`, `MARGIN_MIN_RATIO`, `MAX_SAME_DIRECTION` (default 2), `NO_ML_FILTER` (default `true` — ML disabled due to insufficient feature alpha).
|
||||
|
||||
`src/config.py` uses `@dataclass` with `__post_init__` to load and validate all env vars.
|
||||
`src/config.py` uses `@dataclass` with `__post_init__` to load and validate all env vars. Per-symbol strategy params supported via `SymbolStrategyParams` — override with `ATR_SL_MULT_{SYMBOL}`, `ATR_TP_MULT_{SYMBOL}`, `SIGNAL_THRESHOLD_{SYMBOL}`, `ADX_THRESHOLD_{SYMBOL}`, `VOL_MULTIPLIER_{SYMBOL}`. Access via `config.get_symbol_params(symbol)`.
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -138,3 +140,18 @@ All design documents and implementation plans are stored in `docs/plans/` with t
|
||||
| 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 검증 진행 중 |
|
||||
|
||||
25
Jenkinsfile
vendored
25
Jenkinsfile
vendored
@@ -47,10 +47,15 @@ pipeline {
|
||||
if (changes == 'ALL') {
|
||||
// 첫 빌드이거나 diff 실패 시 전체 빌드
|
||||
env.BOT_CHANGED = 'true'
|
||||
env.MTF_CHANGED = 'true'
|
||||
env.DASH_API_CHANGED = 'true'
|
||||
env.DASH_UI_CHANGED = 'true'
|
||||
} else {
|
||||
env.BOT_CHANGED = (changes =~ /(?m)^(src\/|main\.py|requirements\.txt|Dockerfile)/).find() ? 'true' : 'false'
|
||||
// mtf_bot.py 변경 감지 (mtf-bot 서비스만 재시작)
|
||||
env.MTF_CHANGED = (changes =~ /(?m)^src\/mtf_bot\.py/).find() ? 'true' : 'false'
|
||||
// src/ 변경 중 mtf_bot.py만 바뀐 경우 메인 봇은 재시작 불필요
|
||||
def botFiles = changes.split('\n').findAll { it =~ /^(src\/(?!mtf_bot\.py)|scripts\/|main\.py|requirements\.txt|Dockerfile)/ }
|
||||
env.BOT_CHANGED = botFiles.size() > 0 ? 'true' : 'false'
|
||||
env.DASH_API_CHANGED = (changes =~ /(?m)^dashboard\/api\//).find() ? 'true' : 'false'
|
||||
env.DASH_UI_CHANGED = (changes =~ /(?m)^dashboard\/ui\//).find() ? 'true' : 'false'
|
||||
}
|
||||
@@ -62,7 +67,7 @@ pipeline {
|
||||
env.COMPOSE_CHANGED = 'false'
|
||||
}
|
||||
|
||||
echo "BOT_CHANGED=${env.BOT_CHANGED}, DASH_API_CHANGED=${env.DASH_API_CHANGED}, DASH_UI_CHANGED=${env.DASH_UI_CHANGED}, COMPOSE_CHANGED=${env.COMPOSE_CHANGED}"
|
||||
echo "BOT_CHANGED=${env.BOT_CHANGED}, MTF_CHANGED=${env.MTF_CHANGED}, DASH_API_CHANGED=${env.DASH_API_CHANGED}, DASH_UI_CHANGED=${env.DASH_UI_CHANGED}, COMPOSE_CHANGED=${env.COMPOSE_CHANGED}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,7 +75,7 @@ pipeline {
|
||||
stage('Build Docker Images') {
|
||||
parallel {
|
||||
stage('Bot') {
|
||||
when { expression { env.BOT_CHANGED == 'true' } }
|
||||
when { expression { env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true' } }
|
||||
steps {
|
||||
sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ."
|
||||
}
|
||||
@@ -95,7 +100,7 @@ pipeline {
|
||||
withCredentials([usernamePassword(credentialsId: 'gitea-registry-cred', passwordVariable: 'GITEA_TOKEN', usernameVariable: 'GITEA_USER')]) {
|
||||
sh "echo \$GITEA_TOKEN | docker login ${REGISTRY} -u \$GITEA_USER --password-stdin"
|
||||
script {
|
||||
if (env.BOT_CHANGED == 'true') {
|
||||
if (env.BOT_CHANGED == 'true' || env.MTF_CHANGED == 'true') {
|
||||
sh "docker push ${FULL_IMAGE}"
|
||||
sh "docker push ${LATEST_IMAGE}"
|
||||
}
|
||||
@@ -123,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')
|
||||
|
||||
@@ -141,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"
|
||||
}
|
||||
@@ -164,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}
|
||||
"""
|
||||
}
|
||||
|
||||
660
README.md
660
README.md
@@ -1,36 +1,267 @@
|
||||
# CoinTrader
|
||||
|
||||
Binance Futures 자동매매 봇. 복합 기술 지표와 ML 필터(LightGBM / MLX 신경망)를 결합하여 다중 심볼(XRP, TRX, DOGE 등) 선물 포지션을 동시에 자동 진입·청산하며, Discord로 실시간 알림을 전송합니다.
|
||||
Binance Futures 자동매매 봇. 복합 기술 지표와 킬스위치로 XRPUSDT 선물 포지션을 자동 진입·청산하며, Discord로 실시간 알림을 전송합니다. 멀티심볼 아키텍처를 지원하지만, 현재 XRP만 운영 중입니다.
|
||||
|
||||
> **아키텍처 문서**: 코드 구조, 레이어별 역할, MLOps 파이프라인, 동작 시나리오를 상세히 설명한 [ARCHITECTURE.md](./ARCHITECTURE.md)를 참고하세요.
|
||||
> **심볼 운영 이력**: SOL, DOGE, TRX는 파라미터 스윕에서 모든 ADX 수준에서 PF < 1.0으로, 현재 전략으로는 수익을 낼 수 없어 제외되었습니다 (2026-03-21). ML 필터도 기술 지표 기반 피처의 예측력 한계로 비활성화 상태 (`NO_ML_FILTER=true`).
|
||||
|
||||
> **이 봇은 실제 자산을 거래합니다.** 운영 전 반드시 Binance Testnet에서 충분히 검증하세요.
|
||||
> 과거 수익이 미래 수익을 보장하지 않습니다. 투자 손실에 대한 책임은 사용자 본인에게 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 주요 기능
|
||||
|
||||
- **복합 기술 지표 신호**: RSI, MACD 크로스, 볼린저 밴드, EMA 정/역배열, Stochastic RSI, 거래량 급증 — 가중치 합계 ≥ 3 시 진입
|
||||
- **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 필터 통과 시 반대 방향 재진입
|
||||
- **멀티심볼 동시 거래**: 심볼별 독립 봇 인스턴스를 `asyncio.gather()`로 병렬 실행. 공유 RiskManager로 글로벌 리스크 관리
|
||||
- **리스크 관리**: 트레이드당 리스크 비율, 최대 포지션 수, 동일 방향 포지션 제한(기본 2개), 일일 손실 한도(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 검증용)
|
||||
|
||||
---
|
||||
|
||||
# 봇 사용 가이드
|
||||
|
||||
봇을 설치하고 운영하려는 사용자를 위한 섹션입니다.
|
||||
|
||||
## 요구사항
|
||||
|
||||
- Python 3.11+ (또는 Docker)
|
||||
- Binance Futures 계정 + API 키
|
||||
- (선택) Discord 웹훅 URL
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 1. 환경변수 설정
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd cointrader
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
`.env` 파일을 열어 아래 필수 값을 채웁니다.
|
||||
|
||||
```env
|
||||
# 필수
|
||||
BINANCE_API_KEY=your_api_key
|
||||
BINANCE_API_SECRET=your_api_secret
|
||||
SYMBOLS=XRPUSDT # 거래할 심볼 (쉼표 구분, 멀티심볼 지원)
|
||||
|
||||
# 권장
|
||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||
LEVERAGE=10
|
||||
```
|
||||
|
||||
> 처음 사용 시 Binance Testnet에서 먼저 테스트하는 것을 권장합니다. `BINANCE_TESTNET_API_KEY`와 `BINANCE_TESTNET_API_SECRET`을 설정하세요.
|
||||
|
||||
### 2-A. Docker로 실행 (권장)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
로그 확인:
|
||||
```bash
|
||||
docker compose logs -f cointrader
|
||||
```
|
||||
|
||||
### 2-B. 로컬 실행
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 3. 정상 동작 확인
|
||||
|
||||
봇이 정상 실행되면 다음과 같은 로그가 출력됩니다:
|
||||
|
||||
```
|
||||
INFO | 기준 잔고 설정: 1000.00 USDT
|
||||
INFO | [XRPUSDT] 봇 시작, 레버리지 10x | SL=2.0x TP=2.0x Signal≥3 ADX≥25.0 Vol≥2.5x
|
||||
INFO | [XRPUSDT] 기존 포지션 없음 - 신규 진입 대기
|
||||
INFO | [XRPUSDT] OI 히스토리 초기화: 5개
|
||||
INFO | Kline WebSocket 연결 완료
|
||||
```
|
||||
|
||||
Discord 웹훅을 설정했다면 진입/청산 시 실시간 알림을 받게 됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 매매 전략
|
||||
|
||||
### 기술 지표 신호 (15분봉)
|
||||
|
||||
| 지표 | 롱 조건 | 숏 조건 | 가중치 |
|
||||
|------|---------|---------|--------|
|
||||
| RSI (14) | < 35 | > 65 | 1 |
|
||||
| MACD 크로스 | 골든크로스 | 데드크로스 | 2 |
|
||||
| 볼린저 밴드 | 하단 이탈 | 상단 돌파 | 1 |
|
||||
| EMA 정배열 (9/21/50) | 정배열 | 역배열 | 1 |
|
||||
| Stochastic RSI | < 20 + K>D | > 80 + K<D | 1 |
|
||||
| 거래량 | 20MA × `VOL_MULTIPLIER` 이상 시 신호 강화 | — | 보조 |
|
||||
|
||||
**진입 조건**: 가중치 합계 ≥ `SIGNAL_THRESHOLD` + (거래량 급증 또는 가중치 합계 ≥ `SIGNAL_THRESHOLD` + 1)
|
||||
**ADX 필터**: ADX < `ADX_THRESHOLD` 시 횡보장으로 판단, 진입 차단
|
||||
**손절/익절**: ATR × `ATR_SL_MULT` / ATR × `ATR_TP_MULT`
|
||||
|
||||
### 전략 파라미터 조절
|
||||
|
||||
환경변수로 전략 파라미터를 조절할 수 있습니다. 기본값은 Walk-Forward 백테스트 스윕 결과에서 선정된 값입니다.
|
||||
|
||||
**전역 기본값** (심볼별 오버라이드 없을 때 적용):
|
||||
|
||||
| 환경변수 | 기본값 | 설명 |
|
||||
|---------|--------|------|
|
||||
| `ATR_SL_MULT` | `2.0` | 손절 ATR 배수 |
|
||||
| `ATR_TP_MULT` | `2.0` | 익절 ATR 배수 |
|
||||
| `SIGNAL_THRESHOLD` | `3` | 진입을 위한 최소 가중치 점수 |
|
||||
| `ADX_THRESHOLD` | `25` | ADX 횡보장 필터 (0=비활성) |
|
||||
| `VOL_MULTIPLIER` | `2.5` | 거래량 급증 감지 배수 |
|
||||
|
||||
**심볼별 오버라이드**: `{환경변수}_{심볼}` 형태로 심볼마다 독립 설정 가능. 미설정 시 전역 기본값 사용.
|
||||
|
||||
```env
|
||||
# 현재 운영 설정 (2026-03-21)
|
||||
ATR_SL_MULT_XRPUSDT=1.5
|
||||
ATR_TP_MULT_XRPUSDT=4.0
|
||||
ADX_THRESHOLD_XRPUSDT=25
|
||||
```
|
||||
|
||||
> **제외된 심볼**: SOLUSDT(PF 0.00~0.83), DOGEUSDT(PF 0.70~0.83), TRXUSDT(PF 0.08) — 모든 파라미터 조합에서 PF < 1.0.
|
||||
|
||||
### ML 필터
|
||||
|
||||
ML 필터는 기술 지표 신호를 한 번 더 검증하여 오진입을 차단합니다. 기본적으로 **비활성화** 상태입니다.
|
||||
|
||||
- `NO_ML_FILTER=true` (기본값) — ML 없이 기술 지표만으로 운영
|
||||
- `NO_ML_FILTER=false` — ML 필터 활성화 (모델 파일 필요)
|
||||
|
||||
> **비활성화 이유 (2026-03-21)**: Walk-Forward 백테스트에서 ML ON이 ML OFF보다 오히려 PF가 낮았습니다 (XRP: ML OFF PF 1.16 vs ML ON PF 0.71). Feature ablation 분석 결과, 모델 예측력의 대부분이 signal_strength/side 피처에 의존하며 (A→C AUC 드롭 0.08~0.09), 기술 지표 z-score만으로는 수수료를 이기는 알파를 만들 수 없었습니다. 오더북/청산 데이터 등 새로운 피처 소스에서 알파가 확인되면 재활성화 예정.
|
||||
|
||||
---
|
||||
|
||||
## 리스크 관리
|
||||
|
||||
| 설정 | 기본값 | 설명 |
|
||||
|------|--------|------|
|
||||
| `LEVERAGE` | `10` | 레버리지 배수 |
|
||||
| `MAX_SAME_DIRECTION` | `2` | 동일 방향 최대 포지션 수 |
|
||||
| `MARGIN_MAX_RATIO` | `0.50` | 최대 증거금 비율 (잔고 대비) |
|
||||
| `MARGIN_MIN_RATIO` | `0.20` | 최소 증거금 비율 (잔고 대비) |
|
||||
| `MARGIN_DECAY_RATE` | `0.0006` | 잔고 증가 시 증거금 비율 감소 속도 |
|
||||
|
||||
- **일일 손실 한도**: 기준 잔고의 5% 초과 시 당일 거래 중단 (단일 충격 방어)
|
||||
- **듀얼 레이어 킬스위치**: 구조적 엣지 소실에 의한 점진적 계좌 우하향(Slow Bleed) 방어
|
||||
- **동적 증거금**: 잔고가 늘어날수록 비율을 선형으로 줄여 과노출 방지
|
||||
- **포지션 복구**: 봇 재시작 시 기존 포지션 자동 감지 및 상태 복원
|
||||
|
||||
### 킬스위치
|
||||
|
||||
일일 손실 한도는 단일 충격 방어용이지, 누적 승률 하락 방어용이 아닙니다. 매일 한도 근처까지 손실을 내고 멈추는 패턴이 반복되면 한 달 뒤 계좌의 30~40%가 조용히 증발합니다. 킬스위치는 이 Slow Bleed를 자동으로 차단합니다.
|
||||
|
||||
| 레이어 | 조건 | 방어 대상 |
|
||||
|--------|------|-----------|
|
||||
| **Fast Kill** | 8연속 순손실 (net_pnl, 수수료 포함) | 급격한 전략 붕괴 |
|
||||
| **Slow Kill** | 최근 15거래 Profit Factor < 0.75 | 점진적 엣지 소실 |
|
||||
|
||||
**동작 방식:**
|
||||
- 심볼별 독립 제어: 한 심볼이 킬되어도 다른 심볼은 정상 운영
|
||||
- 진입만 차단: 기존 포지션의 SL/TP 청산은 정상 작동 (물린 상태 방치 방지)
|
||||
- 거래 이력 persist: `data/trade_history/{symbol}.jsonl`에 매 청산마다 기록
|
||||
- 봇 재시작 시 소급 검증: 이력 파일에서 마지막 15건을 읽어 킬스위치 상태 복원
|
||||
- 수동 해제: `.env`에 `RESET_KILL_SWITCH_{SYMBOL}=True` 추가 후 봇 재시작
|
||||
|
||||
**주간 리포트 모니터링:**
|
||||
```
|
||||
[킬스위치 모니터링]
|
||||
XRP: 연속손실 2/8 | 15거래PF 1.42
|
||||
```
|
||||
|
||||
| 환경변수 | 설명 |
|
||||
|---------|------|
|
||||
| `RESET_KILL_SWITCH_{SYMBOL}` | `True`로 설정 후 재시작하면 해당 심볼 킬스위치 해제. 해제 후 반드시 제거할 것 |
|
||||
|
||||
---
|
||||
|
||||
## 대시보드
|
||||
|
||||
봇 로그를 실시간으로 파싱하여 거래 내역, 수익 통계, 차트를 웹에서 조회할 수 있습니다.
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
# 접속: http://<서버IP>:8080
|
||||
```
|
||||
|
||||
| 탭 | 내용 |
|
||||
|----|------|
|
||||
| **Overview** | 총 수익, 승률, 거래 수, 최대 수익/손실 KPI + 일별 PnL 차트 + 누적 수익 곡선 |
|
||||
| **Trades** | 전체 거래 내역 — 진입/청산가, 방향, 레버리지, 기술 지표, SL/TP, 순익 상세 |
|
||||
| **Chart** | 15분봉 가격 차트 + RSI 지표 + ADX 추세 강도 |
|
||||
|
||||
### API 엔드포인트
|
||||
|
||||
| 엔드포인트 | 설명 |
|
||||
|-----------|------|
|
||||
| `GET /api/position` | 현재 포지션 + 봇 상태 |
|
||||
| `GET /api/trades` | 청산 거래 내역 (페이지네이션) |
|
||||
| `GET /api/daily` | 일별 PnL 집계 |
|
||||
| `GET /api/stats` | 전체 통계 (총 거래, 승률, 수수료 등) |
|
||||
| `GET /api/candles` | 최근 캔들 + 기술 지표 |
|
||||
| `GET /api/health` | 헬스 체크 |
|
||||
|
||||
---
|
||||
|
||||
## 환경변수 전체 레퍼런스
|
||||
|
||||
| 변수 | 기본값 | 필수 | 설명 |
|
||||
|------|--------|:----:|------|
|
||||
| `BINANCE_API_KEY` | — | ✅ | Binance API 키 |
|
||||
| `BINANCE_API_SECRET` | — | ✅ | Binance API 시크릿 |
|
||||
| `SYMBOLS` | `XRPUSDT` | | 거래 심볼 목록 (쉼표 구분) |
|
||||
| `CORRELATION_SYMBOLS` | `BTCUSDT,ETHUSDT` | | 상관관계 심볼 (BTC/ETH 피처용) |
|
||||
| `LEVERAGE` | `10` | | 레버리지 배수 |
|
||||
| `MAX_SAME_DIRECTION` | `2` | | 동일 방향 최대 포지션 수 |
|
||||
| `DISCORD_WEBHOOK_URL` | — | | Discord 웹훅 URL |
|
||||
| `MARGIN_MAX_RATIO` | `0.50` | | 최대 증거금 비율 |
|
||||
| `MARGIN_MIN_RATIO` | `0.20` | | 최소 증거금 비율 |
|
||||
| `MARGIN_DECAY_RATE` | `0.0006` | | 잔고 증가 시 감소 속도 |
|
||||
| `NO_ML_FILTER` | `true` | | ML 필터 비활성화 |
|
||||
| `ML_THRESHOLD` | `0.55` | | ML 예측 확률 임계값 |
|
||||
| `ATR_SL_MULT` | `2.0` | | 손절 ATR 배수 (전역 기본값) |
|
||||
| `ATR_TP_MULT` | `2.0` | | 익절 ATR 배수 (전역 기본값) |
|
||||
| `SIGNAL_THRESHOLD` | `3` | | 최소 가중치 점수 (전역 기본값) |
|
||||
| `ADX_THRESHOLD` | `25` | | ADX 횡보장 필터 (전역 기본값, 0=비활성) |
|
||||
| `VOL_MULTIPLIER` | `2.5` | | 거래량 급증 배수 (전역 기본값) |
|
||||
| `ATR_SL_MULT_{SYMBOL}` | — | | 심볼별 손절 ATR 배수 오버라이드 |
|
||||
| `ATR_TP_MULT_{SYMBOL}` | — | | 심볼별 익절 ATR 배수 오버라이드 |
|
||||
| `SIGNAL_THRESHOLD_{SYMBOL}` | — | | 심볼별 최소 가중치 점수 오버라이드 |
|
||||
| `ADX_THRESHOLD_{SYMBOL}` | — | | 심볼별 ADX 필터 오버라이드 |
|
||||
| `VOL_MULTIPLIER_{SYMBOL}` | — | | 심볼별 거래량 배수 오버라이드 |
|
||||
| `DASHBOARD_API_URL` | `http://10.1.10.24:8000` | | 대시보드 API 주소 (주간 리포트용) |
|
||||
| `MARGIN_MAX_RATIO_{SYMBOL}` | — | | 심볼별 최대 증거금 비율 오버라이드 |
|
||||
| `RESET_KILL_SWITCH_{SYMBOL}` | — | | `True`로 설정 후 재시작하면 킬스위치 해제 (해제 후 반드시 제거) |
|
||||
| `BINANCE_TESTNET_API_KEY` | — | | Testnet API 키 |
|
||||
| `BINANCE_TESTNET_API_SECRET` | — | | Testnet API 시크릿 |
|
||||
|
||||
---
|
||||
|
||||
# 개발 가이드
|
||||
|
||||
코드를 수정하거나 기능을 추가하려는 개발자를 위한 섹션입니다.
|
||||
|
||||
> **아키텍처 문서**: 5-레이어 구조, 데이터 흐름, MLOps 파이프라인, 동작 시나리오를 상세히 설명한 [ARCHITECTURE.md](./ARCHITECTURE.md)를 참고하세요.
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
@@ -47,6 +278,8 @@ cointrader/
|
||||
│ ├── mlx_filter.py # MLX 신경망 필터 (Apple Silicon GPU 학습 + ONNX export)
|
||||
│ ├── 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 로거 설정
|
||||
@@ -56,246 +289,48 @@ cointrader/
|
||||
│ ├── 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/ # 학습된 모델 저장 (심볼별 하위 디렉토리)
|
||||
│ ├── xrpusdt/ # models/xrpusdt/lgbm_filter.pkl
|
||||
│ ├── trxusdt/ # models/trxusdt/lgbm_filter.pkl
|
||||
│ └── dogeusdt/ # models/dogeusdt/lgbm_filter.pkl
|
||||
├── data/ # 과거 데이터 캐시 (심볼별 하위 디렉토리)
|
||||
│ ├── xrpusdt/ # data/xrpusdt/combined_15m.parquet
|
||||
│ ├── trxusdt/ # data/trxusdt/combined_15m.parquet
|
||||
│ └── dogeusdt/ # data/dogeusdt/combined_15m.parquet
|
||||
│ └── trade_history/ # 킬스위치용 실전 거래 이력 (심볼별 JSONL)
|
||||
├── results/
|
||||
│ └── weekly/ # 주간 리포트 JSON 저장
|
||||
├── logs/ # 로그 파일
|
||||
├── docs/plans/ # 설계 문서 및 구현 플랜
|
||||
├── tests/ # 테스트 코드
|
||||
├── tests/ # 테스트 코드 (15파일, 138개 케이스)
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── Jenkinsfile
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 1. 환경변수 설정
|
||||
## 개발 환경 설정
|
||||
|
||||
```bash
|
||||
# 가상환경 생성 및 활성화
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# 의존성 설치
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 환경변수 설정
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
`.env` 파일을 열어 아래 값을 채웁니다.
|
||||
|
||||
```env
|
||||
BINANCE_API_KEY=your_api_key
|
||||
BINANCE_API_SECRET=your_api_secret
|
||||
SYMBOLS=XRPUSDT,TRXUSDT,DOGEUSDT
|
||||
CORRELATION_SYMBOLS=BTCUSDT,ETHUSDT
|
||||
LEVERAGE=10
|
||||
MAX_SAME_DIRECTION=2
|
||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||
```
|
||||
|
||||
### 2. 로컬 실행
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 3. Docker Compose로 실행
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
로그 확인:
|
||||
|
||||
```bash
|
||||
docker compose logs -f cointrader
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ML 모델 학습
|
||||
|
||||
봇은 모델 파일이 없으면 ML 필터 없이 동작합니다. 최초 실행 전 또는 수동 재학습 시 아래 순서로 진행합니다.
|
||||
|
||||
### 전체 파이프라인 (권장)
|
||||
|
||||
맥미니에서 데이터 수집 → 학습 → LXC 배포까지 한 번에 실행합니다.
|
||||
|
||||
> **자동 분기**: `data/{symbol}/combined_15m.parquet`가 없으면 1년치(365일) 전체 수집, 있으면 35일치 Upsert로 자동 전환합니다. 서버 이전이나 데이터 유실 시에도 사람의 개입 없이 자동 복구됩니다.
|
||||
|
||||
```bash
|
||||
# 전체 심볼 학습 + 배포 (SYMBOLS 환경변수의 모든 심볼)
|
||||
bash scripts/train_and_deploy.sh
|
||||
|
||||
# 단일 심볼만 학습 + 배포
|
||||
bash scripts/train_and_deploy.sh --symbol TRXUSDT
|
||||
|
||||
# MLX GPU 학습 (단일 심볼)
|
||||
bash scripts/train_and_deploy.sh mlx --symbol XRPUSDT
|
||||
|
||||
# LightGBM + Walk-Forward 3폴드
|
||||
bash scripts/train_and_deploy.sh lgbm 3
|
||||
|
||||
# 학습만 (배포 없이)
|
||||
bash scripts/train_and_deploy.sh lgbm 0
|
||||
```
|
||||
|
||||
### 단계별 수동 실행
|
||||
|
||||
```bash
|
||||
# 1. 과거 데이터 수집 (단일 심볼 — 상관관계 심볼 자동 추가)
|
||||
python scripts/fetch_history.py --symbol TRXUSDT --interval 15m --days 365
|
||||
# → data/trxusdt/combined_15m.parquet 에 저장
|
||||
|
||||
# 1-alt. 명시적 심볼 지정 (기존 방식도 지원)
|
||||
python scripts/fetch_history.py \
|
||||
--symbols XRPUSDT BTCUSDT ETHUSDT \
|
||||
--interval 15m \
|
||||
--days 365 \
|
||||
--output data/combined_15m.parquet
|
||||
|
||||
# 2-A. LightGBM 모델 학습 (심볼별)
|
||||
python scripts/train_model.py --symbol TRXUSDT
|
||||
# → models/trxusdt/lgbm_filter.pkl 에 저장
|
||||
|
||||
# 2-B. MLX 신경망 학습 (Apple Silicon GPU)
|
||||
python scripts/train_mlx_model.py --data data/xrpusdt/combined_15m.parquet
|
||||
|
||||
# 3. LXC 서버에 모델 배포
|
||||
bash scripts/deploy_model.sh --symbol XRPUSDT
|
||||
bash scripts/deploy_model.sh mlx --symbol XRPUSDT
|
||||
```
|
||||
|
||||
학습된 모델은 `models/{symbol}/lgbm_filter.pkl` (LightGBM) 또는 `models/{symbol}/mlx_filter.weights.onnx` (MLX) 에 저장됩니다. 심볼별 디렉토리가 없으면 `models/` 루트로 폴백합니다.
|
||||
|
||||
> **모델 핫리로드**: 봇이 실행 중일 때 모델 파일을 교체하면, 다음 캔들 마감 시 자동으로 감지해 리로드합니다. 봇 재시작이 필요 없습니다.
|
||||
|
||||
### 하이퍼파라미터 자동 튜닝 (Optuna)
|
||||
|
||||
봇 성능이 저하되거나 데이터가 충분히 축적되었을 때 Optuna로 최적 LightGBM 파라미터를 탐색합니다.
|
||||
결과를 확인하고 직접 승인한 후 재학습에 반영하는 **수동 트리거** 방식입니다.
|
||||
|
||||
```bash
|
||||
# 심볼별 튜닝 (50 trials, 5폴드 Walk-Forward, ~30분)
|
||||
python scripts/tune_hyperparams.py --symbol XRPUSDT
|
||||
|
||||
# 빠른 테스트 (10 trials, 3폴드, ~5분)
|
||||
python scripts/tune_hyperparams.py --symbol TRXUSDT --trials 10 --folds 3
|
||||
|
||||
# 베이스라인 측정 없이 탐색만
|
||||
python scripts/tune_hyperparams.py --symbol XRPUSDT --no-baseline
|
||||
```
|
||||
|
||||
결과는 `models/{symbol}/tune_results_YYYYMMDD_HHMMSS.json`에 저장됩니다.
|
||||
콘솔에 Best Params, 베이스라인 대비 개선폭, 폴드별 AUC를 출력하므로 직접 확인 후 판단하세요.
|
||||
|
||||
> **주의**: Optuna가 찾은 파라미터는 과적합 위험이 있습니다. Best Params를 `train_model.py`에 반영하기 전에 반드시 폴드별 AUC 분산과 개선폭을 검토하세요.
|
||||
|
||||
### Apple Silicon GPU 가속 학습 (M1/M2/M3/M4)
|
||||
|
||||
M 시리즈 맥에서는 MLX를 사용해 통합 GPU(Metal)로 학습할 수 있습니다.
|
||||
|
||||
> **설치**: `mlx`는 Apple Silicon 전용이며 `requirements.txt`에 포함되지 않습니다.
|
||||
> 맥미니에서 별도 설치: `pip install mlx`
|
||||
|
||||
MLX로 학습한 모델은 ONNX 포맷으로 export되어 Linux 서버에서 `onnxruntime`으로 추론합니다.
|
||||
|
||||
> **참고**: LightGBM은 Apple Silicon GPU를 공식 지원하지 않습니다. MLX는 Apple이 만든 ML 프레임워크로 통합 GPU를 자동으로 활용합니다.
|
||||
|
||||
---
|
||||
|
||||
## 매매 전략
|
||||
|
||||
### 기술 지표 신호 (15분봉)
|
||||
|
||||
| 지표 | 롱 조건 | 숏 조건 | 가중치 |
|
||||
|------|---------|---------|--------|
|
||||
| RSI (14) | < 35 | > 65 | 1 |
|
||||
| MACD 크로스 | 골든크로스 | 데드크로스 | 2 |
|
||||
| 볼린저 밴드 | 하단 이탈 | 상단 돌파 | 1 |
|
||||
| EMA 정배열 (9/21/50) | 정배열 | 역배열 | 1 |
|
||||
| Stochastic RSI | < 20 + K>D | > 80 + K<D | 1 |
|
||||
| 거래량 | 20MA × 1.5 이상 시 신호 강화 | — | 보조 |
|
||||
|
||||
**진입 조건**: 가중치 합계 ≥ 3 + (거래량 급증 또는 가중치 합계 ≥ 4)
|
||||
**손절/익절**: ATR × 1.5 / ATR × 3.0 (리스크:리워드 = 1:2)
|
||||
**ML 필터**: 예측 확률 ≥ 0.60 이어야 최종 진입
|
||||
|
||||
### 반대 시그널 재진입
|
||||
|
||||
보유 포지션과 반대 방향 신호가 발생하면:
|
||||
1. 기존 포지션 즉시 청산 (미체결 SL/TP 주문 취소 포함)
|
||||
2. ML 필터 통과 시 반대 방향으로 즉시 재진입
|
||||
|
||||
---
|
||||
|
||||
## CI/CD
|
||||
|
||||
`main` 브랜치에 푸시하면 Jenkins 파이프라인이 자동으로 실행됩니다.
|
||||
|
||||
1. **Notify Build Start** — Discord 빌드 시작 알림
|
||||
2. **Git Clone from Gitea** — 소스 체크아웃
|
||||
3. **Build Docker Image** — Docker 이미지 빌드 (`:{BUILD_NUMBER}` + `:latest` 태그)
|
||||
4. **Push to Gitea Registry** — Gitea Container Registry(`10.1.10.28:3000`)에 푸시
|
||||
5. **Deploy to Prod LXC** — 운영 LXC 서버(`10.1.10.24`)에 자동 배포 (`docker compose pull && up -d`)
|
||||
6. **Cleanup** — 빌드 서버 로컬 이미지 정리
|
||||
|
||||
빌드 성공/실패 결과는 Discord로 자동 알림됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 대시보드
|
||||
|
||||
봇 로그를 실시간으로 파싱하여 거래 내역, 수익 통계, 차트를 웹에서 조회할 수 있는 모니터링 대시보드입니다.
|
||||
|
||||
### 기술 스택
|
||||
|
||||
- **프론트엔드**: React 18 + Vite + Recharts, Nginx 정적 서빙
|
||||
- **백엔드**: FastAPI + SQLite, 로그 파서(5초 주기 폴링)
|
||||
- **배포**: Docker Compose 3컨테이너 (`dashboard-ui`, `dashboard-api`, `cointrader`)
|
||||
|
||||
### 주요 화면
|
||||
|
||||
| 탭 | 내용 |
|
||||
|----|------|
|
||||
| **Overview** | 총 수익, 승률, 거래 수, 최대 수익/손실 KPI + 일별 PnL 차트 + 누적 수익 곡선 |
|
||||
| **Trades** | 전체 거래 내역 — 진입/청산가, 방향, 레버리지, 기술 지표(RSI, MACD, ATR), SL/TP, 순익 상세 |
|
||||
| **Chart** | XRP/USDT 15분봉 가격 차트 + RSI 지표 + ADX 추세 강도 |
|
||||
|
||||
### API 엔드포인트
|
||||
|
||||
| 엔드포인트 | 설명 |
|
||||
|-----------|------|
|
||||
| `GET /api/position` | 현재 포지션 + 봇 상태 |
|
||||
| `GET /api/trades` | 청산 거래 내역 (페이지네이션) |
|
||||
| `GET /api/daily` | 일별 PnL 집계 |
|
||||
| `GET /api/stats` | 전체 통계 (총 거래, 승률, 수수료 등) |
|
||||
| `GET /api/candles` | 최근 캔들 + 기술 지표 |
|
||||
| `GET /api/health` | 헬스 체크 |
|
||||
| `POST /api/reset` | DB 초기화 + 로그 파서 재시작 |
|
||||
|
||||
### 실행
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
대시보드는 `http://<서버IP>:8080`에서 접속할 수 있습니다. 봇 로그를 읽기 전용으로 마운트하여 봇 코드를 수정하지 않는 디커플드 설계입니다.
|
||||
|
||||
---
|
||||
|
||||
## 테스트
|
||||
|
||||
```bash
|
||||
# 전체 테스트
|
||||
# 전체 테스트 (138개)
|
||||
bash scripts/run_tests.sh
|
||||
|
||||
# 특정 키워드 필터
|
||||
@@ -305,29 +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 시크릿 |
|
||||
| `SYMBOLS` | `XRPUSDT` | 거래 심볼 목록 (쉼표 구분, 예: `XRPUSDT,TRXUSDT,DOGEUSDT`) |
|
||||
| `CORRELATION_SYMBOLS` | `BTCUSDT,ETHUSDT` | 상관관계 심볼 (BTC/ETH 수익률·상대강도 피처용) |
|
||||
| `LEVERAGE` | `10` | 레버리지 배수 |
|
||||
| `MAX_SAME_DIRECTION` | `2` | 동일 방향 최대 포지션 수 (LONG 2개면 3번째 LONG 차단) |
|
||||
| `DISCORD_WEBHOOK_URL` | — | Discord 웹훅 URL |
|
||||
| `MARGIN_MAX_RATIO` | `0.50` | 최대 증거금 비율 (잔고 대비 50%) |
|
||||
| `MARGIN_MIN_RATIO` | `0.20` | 최소 증거금 비율 (잔고 대비 20%) |
|
||||
| `MARGIN_DECAY_RATE` | `0.0006` | 잔고 증가 시 증거금 비율 감소 속도 |
|
||||
| `NO_ML_FILTER` | — | `true`/`1`/`yes` 설정 시 ML 필터 완전 비활성화 — 모델 로드 없이 모든 신호 허용 |
|
||||
| `ML_THRESHOLD` | `0.55` | ML 필터 예측 확률 임계값 — 이 값 이상이어야 진입 허용 (기본값 0.55) |
|
||||
봇은 모델 파일이 없으면 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 .
|
||||
|
||||
@@ -5,27 +5,31 @@ 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:
|
||||
@@ -37,9 +41,9 @@ def get_symbols():
|
||||
"""활성 심볼 목록 반환."""
|
||||
with get_db() as db:
|
||||
rows = db.execute(
|
||||
"SELECT key FROM bot_status WHERE key LIKE '%:last_start'"
|
||||
"SELECT DISTINCT key FROM bot_status WHERE key LIKE '%:%'"
|
||||
).fetchall()
|
||||
symbols = [r["key"].split(":")[0] for r in rows]
|
||||
symbols = {r["key"].split(":")[0] for r in rows}
|
||||
return {"symbols": sorted(symbols)}
|
||||
|
||||
|
||||
@@ -64,7 +68,7 @@ def get_position(symbol: Optional[str] = None):
|
||||
def get_trades(
|
||||
symbol: Optional[str] = None,
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = 0,
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
with get_db() as db:
|
||||
if symbol:
|
||||
@@ -166,28 +170,28 @@ def health():
|
||||
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():
|
||||
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()
|
||||
|
||||
import subprocess, signal
|
||||
for pid_str in os.listdir("/proc") if os.path.isdir("/proc") else []:
|
||||
if not pid_str.isdigit():
|
||||
continue
|
||||
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):
|
||||
pass
|
||||
subprocess.Popen(["python", "log_parser.py"])
|
||||
# C2: PID file + SIGHUP으로 파서에 재파싱 요청 (프로세스 재시작 불필요)
|
||||
try:
|
||||
with open(PARSER_PID_FILE) as f:
|
||||
pid = int(f.read().strip())
|
||||
os.kill(pid, signal.SIGHUP)
|
||||
except (FileNotFoundError, ValueError, ProcessLookupError, OSError):
|
||||
pass
|
||||
|
||||
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,12 +20,13 @@ 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 = {
|
||||
"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"
|
||||
r".*\[(?P<symbol>\w+)\] 신호: (?P<signal>\w+) \|.*현재가: (?P<price>[\d.]+)"
|
||||
),
|
||||
|
||||
"adx": re.compile(
|
||||
@@ -68,6 +70,12 @@ PATTERNS = {
|
||||
r".*\[(?P<symbol>\w+)\] 오늘 누적 PnL: (?P<pnl>[+\-\d.]+) USDT"
|
||||
),
|
||||
|
||||
"position_monitor": re.compile(
|
||||
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||
r".*\[(?P<symbol>\w+)\] 포지션 모니터 \| (?P<direction>\w+) \| "
|
||||
r"현재가=(?P<price>[\d.]+) \| PnL=(?P<pnl>[+\-\d.]+) USDT \((?P<pnl_pct>[+\-\d.]+)%\)"
|
||||
),
|
||||
|
||||
"bot_start": re.compile(
|
||||
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
|
||||
r".*\[(?P<symbol>\w+)\] 봇 시작, 레버리지 (?P<leverage>\d+)x"
|
||||
@@ -88,25 +96,30 @@ 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_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("""
|
||||
DROP TABLE IF EXISTS trades;
|
||||
DROP TABLE IF EXISTS candles;
|
||||
DROP TABLE IF EXISTS daily_pnl;
|
||||
DROP TABLE IF EXISTS bot_status;
|
||||
DROP TABLE IF EXISTS parse_state;
|
||||
|
||||
CREATE TABLE trades (
|
||||
CREATE TABLE IF NOT EXISTS trades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
@@ -128,10 +141,11 @@ 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 candles (
|
||||
CREATE TABLE IF NOT EXISTS candles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
@@ -144,7 +158,7 @@ class LogParser:
|
||||
UNIQUE(symbol, ts)
|
||||
);
|
||||
|
||||
CREATE TABLE daily_pnl (
|
||||
CREATE TABLE IF NOT EXISTS daily_pnl (
|
||||
symbol TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
cumulative_pnl REAL DEFAULT 0,
|
||||
@@ -155,24 +169,46 @@ class LogParser:
|
||||
PRIMARY KEY(symbol, date)
|
||||
);
|
||||
|
||||
CREATE TABLE bot_status (
|
||||
CREATE TABLE IF NOT EXISTS bot_status (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE parse_state (
|
||||
CREATE TABLE IF NOT EXISTS parse_state (
|
||||
filepath TEXT PRIMARY KEY,
|
||||
position INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_candles_symbol_ts ON candles(symbol, ts);
|
||||
CREATE INDEX idx_trades_status ON trades(status);
|
||||
CREATE INDEX idx_trades_symbol ON trades(symbol);
|
||||
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}
|
||||
@@ -192,8 +228,48 @@ 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()
|
||||
self.conn.execute(
|
||||
@@ -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,12 +290,11 @@ class LogParser:
|
||||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
def _scan_logs(self):
|
||||
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)
|
||||
@@ -272,6 +347,15 @@ 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:
|
||||
@@ -355,7 +439,7 @@ class LogParser:
|
||||
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
|
||||
@@ -387,7 +471,7 @@ class LogParser:
|
||||
ON CONFLICT(symbol, date) DO UPDATE SET cumulative_pnl=?, last_updated=?""",
|
||||
(symbol, day, pnl, ts, pnl, ts)
|
||||
)
|
||||
self.conn.commit()
|
||||
self._dirty = True
|
||||
self._set_status(f"{symbol}:daily_pnl", str(pnl))
|
||||
return
|
||||
|
||||
@@ -396,7 +480,11 @@ class LogParser:
|
||||
leverage=None, sl=None, tp=None, is_recovery=False,
|
||||
rsi=None, macd_hist=None, atr=None):
|
||||
if leverage is None:
|
||||
leverage = 10
|
||||
row = self.conn.execute(
|
||||
"SELECT value FROM bot_status WHERE key=?",
|
||||
(f"{symbol}:leverage",),
|
||||
).fetchone()
|
||||
leverage = int(row["value"]) if row else 10
|
||||
|
||||
# 중복 체크 — 같은 심볼+방향의 OPEN 포지션이 이미 있으면 스킵
|
||||
current = self._current_positions.get(symbol)
|
||||
@@ -417,7 +505,7 @@ 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(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(symbol, direction, ts,
|
||||
@@ -425,7 +513,7 @@ class LogParser:
|
||||
json.dumps({"recovery": is_recovery}),
|
||||
rsi, macd_hist, atr),
|
||||
)
|
||||
self.conn.commit()
|
||||
self._dirty = True
|
||||
self._current_positions[symbol] = {
|
||||
"id": cur.lastrowid,
|
||||
"direction": direction,
|
||||
@@ -447,6 +535,7 @@ class LogParser:
|
||||
|
||||
if not open_trades:
|
||||
print(f"[LogParser] 경고: {symbol} 청산 감지했으나 열린 포지션 없음")
|
||||
self._current_positions.pop(symbol, None)
|
||||
return
|
||||
|
||||
primary_id = open_trades[0]["id"]
|
||||
@@ -461,6 +550,8 @@ class LogParser:
|
||||
reason, primary_id)
|
||||
)
|
||||
|
||||
self._dirty = True
|
||||
|
||||
if len(open_trades) > 1:
|
||||
stale_ids = [r["id"] for r in open_trades[1:]]
|
||||
self.conn.execute(
|
||||
@@ -469,21 +560,24 @@ class LogParser:
|
||||
)
|
||||
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(symbol, date, cumulative_pnl, trade_count, wins, losses, last_updated)
|
||||
VALUES(?, ?, ?, 1, ?, ?, ?)
|
||||
"""INSERT INTO daily_pnl(symbol, date, trade_count, wins, losses, last_updated)
|
||||
VALUES(?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(symbol, date) DO UPDATE SET
|
||||
trade_count = trade_count + 1,
|
||||
wins = wins + ?,
|
||||
losses = losses + ?,
|
||||
last_updated = ?""",
|
||||
(symbol, day, net_pnl, win, loss, ts, win, loss, ts)
|
||||
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(f"{symbol}:position_status", "NONE")
|
||||
print(f"[LogParser] {symbol} 포지션 청산: {reason} @ {exit_price}, PnL={net_pnl}")
|
||||
@@ -492,4 +586,10 @@ class LogParser:
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = LogParser()
|
||||
parser.run()
|
||||
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
@@ -267,6 +267,7 @@ export default function App() {
|
||||
const [lastUpdate, setLastUpdate] = useState(null);
|
||||
|
||||
const [symbols, setSymbols] = useState([]);
|
||||
const symbolsRef = useRef([]);
|
||||
const [selectedSymbol, setSelectedSymbol] = useState(null); // null = ALL
|
||||
|
||||
const [stats, setStats] = useState({
|
||||
@@ -277,24 +278,29 @@ export default function App() {
|
||||
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 sym = selectedSymbol ? `?symbol=${selectedSymbol}` : "";
|
||||
const symRequired = selectedSymbol || symbols[0] || "XRPUSDT";
|
||||
const symRequired = selectedSymbol || symbolsRef.current[0] || "XRPUSDT";
|
||||
|
||||
const [symRes, sRes, pRes, tRes, dRes, cRes] = await Promise.all([
|
||||
api("/symbols"),
|
||||
api(`/stats${sym}`),
|
||||
api(`/position${sym}`),
|
||||
api(`/trades${sym}${sym ? "&" : "?"}limit=50`),
|
||||
api(`/trades${sym}${sym ? "&" : "?"}limit=50&offset=${tradesPage * 50}`),
|
||||
api(`/daily${sym}`),
|
||||
api(`/candles?symbol=${symRequired}&limit=96`),
|
||||
]);
|
||||
|
||||
if (symRes?.symbols) setSymbols(symRes.symbols);
|
||||
if (symRes?.symbols) {
|
||||
symbolsRef.current = symRes.symbols;
|
||||
setSymbols(symRes.symbols);
|
||||
}
|
||||
if (sRes && sRes.total_trades !== undefined) {
|
||||
setStats(sRes);
|
||||
setIsLive(true);
|
||||
@@ -304,10 +310,13 @@ export default function App() {
|
||||
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, symbols]);
|
||||
}, [selectedSymbol, tradesPage]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
@@ -399,7 +408,20 @@ export default function App() {
|
||||
{/* 오픈 포지션 — 복수 표시 */}
|
||||
{positions.length > 0 && (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{positions.map((pos) => (
|
||||
{positions.map((pos) => {
|
||||
const curP = parseFloat(botStatus[`${pos.symbol}:current_price`] || 0);
|
||||
const entP = parseFloat(pos.entry_price || 0);
|
||||
const isShort = pos.direction === "SHORT";
|
||||
const uPnl = botStatus[`${pos.symbol}:unrealized_pnl`];
|
||||
const uPnlPct = botStatus[`${pos.symbol}:unrealized_pnl_pct`];
|
||||
const pnlPct = uPnlPct != null
|
||||
? parseFloat(uPnlPct)
|
||||
: (entP > 0 && curP > 0
|
||||
? ((isShort ? entP - curP : curP - entP) / entP * 100 * (pos.leverage || 10))
|
||||
: null);
|
||||
const pnlUsdt = uPnl != null ? parseFloat(uPnl) : null;
|
||||
const posPnlColor = pnlPct > 0 ? S.green : pnlPct < 0 ? S.red : S.text3;
|
||||
return (
|
||||
<div key={pos.id} style={{
|
||||
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,
|
||||
@@ -410,17 +432,24 @@ export default function App() {
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||
<Badge
|
||||
bg={pos.direction === "SHORT" ? "rgba(239,68,68,0.12)" : "rgba(52,211,153,0.12)"}
|
||||
color={pos.direction === "SHORT" ? S.red : S.green}
|
||||
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>
|
||||
@@ -432,7 +461,7 @@ export default function App() {
|
||||
padding: 4, width: "fit-content",
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setSelectedSymbol(null)}
|
||||
onClick={() => { setSelectedSymbol(null); setTradesPage(0); }}
|
||||
style={{
|
||||
background: selectedSymbol === null ? "rgba(99,102,241,0.15)" : "transparent",
|
||||
border: "none",
|
||||
@@ -444,7 +473,7 @@ export default function App() {
|
||||
{symbols.map((sym) => (
|
||||
<button
|
||||
key={sym}
|
||||
onClick={() => setSelectedSymbol(sym)}
|
||||
onClick={() => { setSelectedSymbol(sym); setTradesPage(0); }}
|
||||
style={{
|
||||
background: selectedSymbol === sym ? "rgba(99,102,241,0.15)" : "transparent",
|
||||
border: "none",
|
||||
@@ -570,7 +599,7 @@ export default function App() {
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
전체 {trades.length}건 보기 →
|
||||
전체 {tradesTotal}건 보기 →
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -583,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
|
||||
@@ -593,6 +622,38 @@ 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>
|
||||
)}
|
||||
|
||||
@@ -624,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>
|
||||
|
||||
@@ -673,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.
Binary file not shown.
BIN
data/linkusdt/combined_15m.parquet
Normal file
BIN
data/linkusdt/combined_15m.parquet
Normal file
Binary file not shown.
BIN
data/solusdt/combined_15m.parquet
Normal file
BIN
data/solusdt/combined_15m.parquet
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -23,6 +23,7 @@ services:
|
||||
restart: unless-stopped
|
||||
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` |
|
||||
@@ -1,86 +1,85 @@
|
||||
# Strategy Parameter Sweep Plan
|
||||
# 전략 파라미터 스윕 계획
|
||||
|
||||
**Date**: 2026-03-06
|
||||
**Status**: Completed
|
||||
**날짜**: 2026-03-06
|
||||
**상태**: 완료
|
||||
|
||||
## Goal
|
||||
## 목표
|
||||
|
||||
Find profitable parameter combinations for the base technical indicator strategy (ML OFF) using walk-forward backtesting, targeting PF >= 1.0 as foundation for ML redesign.
|
||||
Walk-Forward 백테스트를 활용하여 기본 기술 지표 전략(ML OFF)의 수익성 높은 파라미터 조합을 탐색하고, PF >= 1.0을 ML 재설계의 기반으로 확보한다.
|
||||
|
||||
## Background
|
||||
## 배경
|
||||
|
||||
Walk-forward backtest revealed the current XRP strategy is unprofitable (PF 0.71, -641 PnL). The strategy parameter sweep systematically tests 324 combinations of 5 parameters to find profitable regimes.
|
||||
Walk-Forward 백테스트 결과 현재 XRP 전략이 비수익적(PF 0.71, -641 PnL)으로 확인되었다. 전략 파라미터 스윕은 5개 파라미터의 324개 조합을 체계적으로 테스트하여 수익 구간을 탐색한다.
|
||||
|
||||
## Parameters Swept
|
||||
## 스윕 파라미터
|
||||
|
||||
|
||||
| Parameter | Values | Description |
|
||||
| ------------------- | ------------- | ----------------------------------------- |
|
||||
| `atr_sl_mult` | 1.0, 1.5, 2.0 | Stop-loss ATR multiplier |
|
||||
| `atr_tp_mult` | 2.0, 3.0, 4.0 | Take-profit ATR multiplier |
|
||||
| `signal_threshold` | 3, 4, 5 | Min weighted indicator score for entry |
|
||||
| `adx_threshold` | 0, 20, 25, 30 | ADX filter (0=disabled, N=require ADX>=N) |
|
||||
| `volume_multiplier` | 1.5, 2.0, 2.5 | Volume surge detection multiplier |
|
||||
| 파라미터 | 값 | 설명 |
|
||||
| ------------------- | ------------- | ------------------------------------------- |
|
||||
| `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 | 거래량 급증 감지 배수 |
|
||||
|
||||
|
||||
Total combinations: 3 x 3 x 3 x 4 x 3 = **324**
|
||||
총 조합: 3 x 3 x 3 x 4 x 3 = **324**
|
||||
|
||||
## Implementation
|
||||
## 구현
|
||||
|
||||
### Files Modified
|
||||
### 수정된 파일
|
||||
|
||||
- `src/indicators.py` — `get_signal()` accepts `signal_threshold`, `adx_threshold`, `volume_multiplier` params
|
||||
- `src/dataset_builder.py` — `_calc_signals()` accepts same params for vectorized computation
|
||||
- `src/backtester.py` — `BacktestConfig` includes strategy params; `WalkForwardBacktester` propagates them to test folds
|
||||
- `src/indicators.py` — `get_signal()`에 `signal_threshold`, `adx_threshold`, `volume_multiplier` 파라미터 추가
|
||||
- `src/dataset_builder.py` — `_calc_signals()`에 동일 파라미터를 받아 벡터화 계산에 적용
|
||||
- `src/backtester.py` — `BacktestConfig`에 전략 파라미터 포함; `WalkForwardBacktester`가 테스트 폴드에 전파
|
||||
|
||||
### Files Created
|
||||
### 신규 생성 파일
|
||||
|
||||
- `scripts/strategy_sweep.py` — CLI tool for parameter grid sweep
|
||||
- `scripts/strategy_sweep.py` — 파라미터 그리드 스윕 CLI 도구
|
||||
|
||||
### Bug Fix
|
||||
### 버그 수정
|
||||
|
||||
- `WalkForwardBacktester` was not passing `signal_threshold`, `adx_threshold`, `volume_multiplier`, or `use_ml` to fold `BacktestConfig`. All signal params were silently using defaults, making ADX/volume/threshold sweeps have zero effect.
|
||||
- `WalkForwardBacktester`가 `signal_threshold`, `adx_threshold`, `volume_multiplier`, `use_ml`을 폴드 `BacktestConfig`에 전달하지 않는 버그 수정. 모든 신호 파라미터가 기본값으로 적용되어 ADX/거래량/임계값 스윕이 효과 없이 실행되고 있었음.
|
||||
|
||||
## Results (XRPUSDT, Walk-Forward 3/1)
|
||||
## 결과 (XRPUSDT, Walk-Forward 3/1)
|
||||
|
||||
### Top 10 Combinations
|
||||
### 상위 10개 조합
|
||||
|
||||
|
||||
| Rank | SL×ATR | TP×ATR | Signal | ADX | Vol | Trades | WinRate | PF | MDD | PnL | Sharpe |
|
||||
| ---- | ------ | ------ | ------ | --- | --- | ------ | ------- | ---- | ----- | ---- | ------ |
|
||||
| 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 |
|
||||
| 순위 | 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 |
|
||||
|
||||
|
||||
### Current Production (Rank 93/324)
|
||||
### 현재 프로덕션 (324개 중 93위)
|
||||
|
||||
|
||||
| SL×ATR | TP×ATR | Signal | ADX | Vol | Trades | WinRate | PF | MDD | PnL |
|
||||
| ------ | ------ | ------ | --- | --- | ------ | ------- | ---- | ----- | ---- |
|
||||
| 1.5 | 3.0 | 3 | 0 | 1.5 | 118 | 30.5% | 0.71 | 65.9% | -641 |
|
||||
| SL×ATR | TP×ATR | 신호 | ADX | 거래량 | 거래 수 | 승률 | PF | MDD | PnL |
|
||||
| ------ | ------ | ---- | --- | ------ | ------- | ------- | ---- | ----- | ---- |
|
||||
| 1.5 | 3.0 | 3 | 0 | 1.5 | 118 | 30.5% | 0.71 | 65.9% | -641 |
|
||||
|
||||
|
||||
### Key Findings
|
||||
### 핵심 발견 사항
|
||||
|
||||
1. **ADX filter is the single most impactful parameter.** All top 10 results use ADX >= 25, with ADX=30 dominating the top 5. This filters out sideways/ranging markets where signals are noise.
|
||||
2. **Volume multiplier 2.5 dominates.** Higher volume thresholds ensure entries only on strong conviction (genuine breakouts vs. noise).
|
||||
3. **Signal threshold 3 is optimal.** Higher thresholds (4, 5) produced too few trades or zero trades in most ADX-filtered regimes.
|
||||
4. **SL/TP ratios matter less than entry filters.** The top results span all SL/TP combos, but all share ADX=25-30 + Vol=2.5.
|
||||
5. **Trade count drops significantly with filters.** Top combos have 19-39 trades vs. 118 for current. Fewer but higher quality entries.
|
||||
6. **41 combinations achieved PF >= 1.0** out of 324 total (12.7%).
|
||||
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%).
|
||||
|
||||
## Recommended Next Steps
|
||||
|
||||
1. **Update production defaults**: ADX=25, volume_multiplier=2.0 as a conservative choice (more trades than ADX=30)
|
||||
2. **Validate on TRXUSDT and DOGEUSDT** to confirm ADX filter is not XRP-specific
|
||||
3. **Retrain ML models** with updated strategy params — the ML filter should now have a profitable base to improve upon
|
||||
4. **Fine-tune sweep** around the profitable zone: ADX [25-35], Vol [2.0-3.0]
|
||||
## 권장 다음 단계
|
||||
|
||||
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분 | 안정성 개선 |
|
||||
@@ -1,22 +1,22 @@
|
||||
# Weekly Strategy Report Implementation Plan
|
||||
# 주간 전략 리포트 구현 계획
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Automatically measure strategy performance weekly, track trends, detect degradation, and send Discord reports.
|
||||
**Goal:** 매주 전략 성능을 자동 측정하고, 추이를 추적하며, 성능 저하를 감지하고, Discord 리포트를 전송한다.
|
||||
|
||||
**Architecture:** Single script `scripts/weekly_report.py` that orchestrates data fetch (subprocess), Walk-Forward backtest (import), log parsing (reuse `dashboard/api/log_parser.py`), trend analysis (read previous `results/weekly/*.json`), optional parameter sweep (import), and Discord notification (import `src/notifier.py`). No changes to production bot code.
|
||||
**Architecture:** 단일 스크립트 `scripts/weekly_report.py`가 데이터 수집(subprocess), Walk-Forward 백테스트(import), 로그 파싱(`dashboard/api/log_parser.py` 재사용), 추이 분석(기존 `results/weekly/*.json` 읽기), 선택적 파라미터 스윕(import), Discord 알림(`src/notifier.py` import)을 오케스트레이션한다. 프로덕션 봇 코드 변경 없음.
|
||||
|
||||
**Tech Stack:** Python 3.12, existing backtester/sweep/notifier/log_parser modules, subprocess for `fetch_history.py`, httpx for Discord.
|
||||
**Tech Stack:** Python 3.12, 기존 backtester/sweep/notifier/log_parser 모듈, `fetch_history.py` subprocess 호출, Discord용 httpx.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create weekly report core — data fetch + backtest
|
||||
### Task 1: 주간 리포트 코어 — 데이터 수집 + 백테스트
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/weekly_report.py`
|
||||
- Test: `tests/test_weekly_report.py`
|
||||
|
||||
**Step 1: Write the failing test for `fetch_latest_data()`**
|
||||
**Step 1: `fetch_latest_data()` 실패 테스트 작성**
|
||||
|
||||
```python
|
||||
# tests/test_weekly_report.py
|
||||
@@ -45,15 +45,15 @@ def test_fetch_latest_data_calls_subprocess():
|
||||
assert "35" in args_0
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
**Step 2: 테스트 실행하여 실패 확인**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py::test_fetch_latest_data_calls_subprocess -v`
|
||||
Expected: FAIL — `ModuleNotFoundError: No module named 'scripts.weekly_report'`
|
||||
|
||||
**Step 3: Write the failing test for `run_backtest()`**
|
||||
**Step 3: `run_backtest()` 실패 테스트 작성**
|
||||
|
||||
```python
|
||||
# tests/test_weekly_report.py (append)
|
||||
# tests/test_weekly_report.py (추가)
|
||||
def test_run_backtest_returns_summary():
|
||||
"""run_backtest가 심볼별 WF 백테스트를 실행하고 결과를 반환하는지 확인."""
|
||||
from scripts.weekly_report import run_backtest
|
||||
@@ -91,7 +91,7 @@ def test_run_backtest_returns_summary():
|
||||
assert result["summary"]["total_trades"] == 27
|
||||
```
|
||||
|
||||
**Step 4: Write minimal implementation**
|
||||
**Step 4: 최소 구현 작성**
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
@@ -167,30 +167,30 @@ def run_backtest(
|
||||
return wf.run()
|
||||
```
|
||||
|
||||
**Step 5: Run tests to verify they pass**
|
||||
**Step 5: 테스트 실행하여 통과 확인**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py -v`
|
||||
Expected: 2 PASS
|
||||
|
||||
**Step 6: Commit**
|
||||
**Step 6: 커밋**
|
||||
|
||||
```bash
|
||||
git add scripts/weekly_report.py tests/test_weekly_report.py
|
||||
git commit -m "feat(weekly-report): add data fetch and WF backtest core"
|
||||
git commit -m "feat(weekly-report): 데이터 수집 및 WF 백테스트 코어 추가"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add live trade log parsing
|
||||
### Task 2: 실전 트레이드 로그 파싱 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/weekly_report.py`
|
||||
- Test: `tests/test_weekly_report.py`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
**Step 1: 실패 테스트 작성**
|
||||
|
||||
```python
|
||||
# tests/test_weekly_report.py (append)
|
||||
# tests/test_weekly_report.py (추가)
|
||||
def test_parse_live_trades_extracts_entries(tmp_path):
|
||||
"""봇 로그에서 진입/청산 패턴을 파싱하여 트레이드 리스트를 반환."""
|
||||
from scripts.weekly_report import parse_live_trades
|
||||
@@ -218,14 +218,14 @@ def test_parse_live_trades_empty_log(tmp_path):
|
||||
assert trades == []
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
**Step 2: 테스트 실행하여 실패 확인**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py::test_parse_live_trades_extracts_entries -v`
|
||||
Expected: FAIL — `ImportError: cannot import name 'parse_live_trades'`
|
||||
|
||||
**Step 3: Write implementation**
|
||||
**Step 3: 구현 작성**
|
||||
|
||||
Append to `scripts/weekly_report.py`:
|
||||
`scripts/weekly_report.py`에 추가:
|
||||
|
||||
```python
|
||||
import re
|
||||
@@ -289,30 +289,30 @@ def parse_live_trades(log_path: str, days: int = 7) -> list[dict]:
|
||||
return closed_trades
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
**Step 4: 테스트 실행하여 통과 확인**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py -v`
|
||||
Expected: 4 PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
**Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add scripts/weekly_report.py tests/test_weekly_report.py
|
||||
git commit -m "feat(weekly-report): add live trade log parser"
|
||||
git commit -m "feat(weekly-report): 실전 트레이드 로그 파서 추가"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add trend tracking (read previous reports)
|
||||
### Task 3: 추이 추적 (이전 리포트 읽기) 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/weekly_report.py`
|
||||
- Test: `tests/test_weekly_report.py`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
**Step 1: 실패 테스트 작성**
|
||||
|
||||
```python
|
||||
# tests/test_weekly_report.py (append)
|
||||
# tests/test_weekly_report.py (추가)
|
||||
def test_load_trend_reads_previous_reports(tmp_path):
|
||||
"""이전 주간 리포트를 읽어 PF/승률/MDD 추이를 반환."""
|
||||
from scripts.weekly_report import load_trend
|
||||
@@ -349,14 +349,14 @@ def test_load_trend_empty_dir(tmp_path):
|
||||
assert trend["pf_declining_3w"] is False
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
**Step 2: 테스트 실행하여 실패 확인**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py::test_load_trend_reads_previous_reports -v`
|
||||
Expected: FAIL
|
||||
|
||||
**Step 3: Write implementation**
|
||||
**Step 3: 구현 작성**
|
||||
|
||||
Append to `scripts/weekly_report.py`:
|
||||
`scripts/weekly_report.py`에 추가:
|
||||
|
||||
```python
|
||||
WEEKLY_DIR = Path("results/weekly")
|
||||
@@ -396,30 +396,30 @@ def load_trend(report_dir: str, weeks: int = 4) -> dict:
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
**Step 4: 테스트 실행하여 통과 확인**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py -v`
|
||||
Expected: 6 PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
**Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add scripts/weekly_report.py tests/test_weekly_report.py
|
||||
git commit -m "feat(weekly-report): add trend tracking from previous reports"
|
||||
git commit -m "feat(weekly-report): 이전 리포트 추이 추적 추가"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add ML re-trigger check + degradation sweep
|
||||
### Task 4: ML 재트리거 체크 + 성능 저하 스윕 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/weekly_report.py`
|
||||
- Test: `tests/test_weekly_report.py`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
**Step 1: 실패 테스트 작성**
|
||||
|
||||
```python
|
||||
# tests/test_weekly_report.py (append)
|
||||
# tests/test_weekly_report.py (추가)
|
||||
def test_check_ml_trigger_all_met():
|
||||
"""3개 조건 모두 충족 시 recommend=True."""
|
||||
from scripts.weekly_report import check_ml_trigger
|
||||
@@ -473,14 +473,14 @@ def test_run_degradation_sweep_called_when_pf_low():
|
||||
assert alternatives[0]["summary"]["profit_factor"] >= alternatives[1]["summary"]["profit_factor"]
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
**Step 2: 테스트 실행하여 실패 확인**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py -k "ml_trigger or degradation" -v`
|
||||
Expected: FAIL
|
||||
|
||||
**Step 3: Write implementation**
|
||||
**Step 3: 구현 작성**
|
||||
|
||||
Append to `scripts/weekly_report.py`:
|
||||
`scripts/weekly_report.py`에 추가:
|
||||
|
||||
```python
|
||||
from scripts.strategy_sweep import (
|
||||
@@ -538,30 +538,30 @@ def run_degradation_sweep(
|
||||
return results[:top_n]
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
**Step 4: 테스트 실행하여 통과 확인**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py -v`
|
||||
Expected: 9 PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
**Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add scripts/weekly_report.py tests/test_weekly_report.py
|
||||
git commit -m "feat(weekly-report): add ML trigger check and degradation sweep"
|
||||
git commit -m "feat(weekly-report): ML 트리거 체크 및 성능 저하 스윕 추가"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add Discord report formatting + sending
|
||||
### Task 5: Discord 리포트 포맷팅 + 전송 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/weekly_report.py`
|
||||
- Test: `tests/test_weekly_report.py`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
**Step 1: 실패 테스트 작성**
|
||||
|
||||
```python
|
||||
# tests/test_weekly_report.py (append)
|
||||
# tests/test_weekly_report.py (추가)
|
||||
def test_format_report_normal():
|
||||
"""정상 상태(PF >= 1.0) 리포트 포맷."""
|
||||
from scripts.weekly_report import format_report
|
||||
@@ -621,7 +621,7 @@ def test_format_report_degraded():
|
||||
text = format_report(report_data)
|
||||
assert "0.87" in text
|
||||
assert "ML" in text
|
||||
assert "1.15" in text # sweep alternative
|
||||
assert "1.15" in text # 스윕 대안
|
||||
|
||||
|
||||
def test_send_report_uses_notifier():
|
||||
@@ -634,14 +634,14 @@ def test_send_report_uses_notifier():
|
||||
instance._send.assert_called_once_with("test report content")
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
**Step 2: 테스트 실행하여 실패 확인**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py -k "format_report or send_report" -v`
|
||||
Expected: FAIL
|
||||
|
||||
**Step 3: Write implementation**
|
||||
**Step 3: 구현 작성**
|
||||
|
||||
Append to `scripts/weekly_report.py`:
|
||||
`scripts/weekly_report.py`에 추가:
|
||||
|
||||
```python
|
||||
import os
|
||||
@@ -657,10 +657,10 @@ def format_report(data: dict) -> str:
|
||||
|
||||
status = ""
|
||||
if pf < 1.0:
|
||||
status = " \U0001F6A8 손실 구간"
|
||||
status = " 🚨 손실 구간"
|
||||
|
||||
lines = [
|
||||
f"\U0001F4CA 주간 전략 리포트 ({d})",
|
||||
f"📊 주간 전략 리포트 ({d})",
|
||||
"",
|
||||
f"[현재 성능 — Walk-Forward 백테스트]",
|
||||
f" 합산 PF: {pf_str} | 승률: {bt['win_rate']:.0f}% | MDD: {bt['max_drawdown_pct']:.0f}%{status}",
|
||||
@@ -689,7 +689,7 @@ def format_report(data: dict) -> str:
|
||||
trend = data["trend"]
|
||||
if trend["pf"]:
|
||||
pf_trend = " → ".join(f"{v:.2f}" for v in trend["pf"])
|
||||
warn = " \u26A0 하락 추세" if trend["pf_declining_3w"] else ""
|
||||
warn = " ⚠ 하락 추세" if trend["pf_declining_3w"] else ""
|
||||
lines += ["", f"[추이 (최근 {len(trend['pf'])}주)]", f" PF: {pf_trend}{warn}"]
|
||||
if trend["win_rate"]:
|
||||
wr_trend = " → ".join(f"{v:.0f}%" for v in trend["win_rate"])
|
||||
@@ -709,7 +709,7 @@ def format_report(data: dict) -> str:
|
||||
f" {'✅' if cond['pf_declining_3w'] else '☐'} PF 3주 연속 하락: {'예 ⚠' if cond['pf_declining_3w'] else '아니오'}",
|
||||
]
|
||||
if ml["recommend"]:
|
||||
lines.append(f" → \U0001F514 ML 재학습 권장! ({ml['met_count']}/3 충족)")
|
||||
lines.append(f" → 🔔 ML 재학습 권장! ({ml['met_count']}/3 충족)")
|
||||
else:
|
||||
lines.append(f" → ML 재도전 시점: 아직 아님 ({ml['met_count']}/3 충족)")
|
||||
|
||||
@@ -725,7 +725,7 @@ def format_report(data: dict) -> str:
|
||||
diff = apf - pf
|
||||
lines.append(f" 대안 {i+1}: {_param_str(alt['params'])} → PF {apf_str} ({diff:+.2f})")
|
||||
lines.append("")
|
||||
lines.append(" \u26A0 자동 적용되지 않음. 검토 후 승인 필요.")
|
||||
lines.append(" ⚠ 자동 적용되지 않음. 검토 후 승인 필요.")
|
||||
elif pf >= 1.0:
|
||||
lines += ["", "[파라미터 스윕]", " 현재 파라미터가 최적 — 스윕 불필요"]
|
||||
|
||||
@@ -748,30 +748,30 @@ def send_report(content: str, webhook_url: str | None = None) -> None:
|
||||
logger.info("Discord 리포트 전송 완료")
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
**Step 4: 테스트 실행하여 통과 확인**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py -v`
|
||||
Expected: 12 PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
**Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add scripts/weekly_report.py tests/test_weekly_report.py
|
||||
git commit -m "feat(weekly-report): add Discord report formatting and sending"
|
||||
git commit -m "feat(weekly-report): Discord 리포트 포맷팅 및 전송 추가"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Add main orchestration + CLI + JSON save
|
||||
### Task 6: 메인 오케스트레이션 + CLI + JSON 저장 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/weekly_report.py`
|
||||
- Test: `tests/test_weekly_report.py`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
**Step 1: 실패 테스트 작성**
|
||||
|
||||
```python
|
||||
# tests/test_weekly_report.py (append)
|
||||
# tests/test_weekly_report.py (추가)
|
||||
def test_generate_report_orchestration(tmp_path):
|
||||
"""generate_report가 모든 단계를 조합하여 리포트 dict를 반환."""
|
||||
from scripts.weekly_report import generate_report
|
||||
@@ -819,14 +819,14 @@ def test_save_report_creates_json(tmp_path):
|
||||
assert loaded["date"] == "2026-03-07"
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
**Step 2: 테스트 실행하여 실패 확인**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py -k "generate_report or save_report" -v`
|
||||
Expected: FAIL
|
||||
|
||||
**Step 3: Write implementation**
|
||||
**Step 3: 구현 작성**
|
||||
|
||||
Append to `scripts/weekly_report.py`:
|
||||
`scripts/weekly_report.py`에 추가:
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
@@ -984,31 +984,31 @@ if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
**Step 4: 테스트 실행하여 통과 확인**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_weekly_report.py -v`
|
||||
Expected: 14 PASS
|
||||
|
||||
**Step 5: Run existing test suite to verify no regressions**
|
||||
**Step 5: 기존 테스트 스위트 실행하여 회귀 없음 확인**
|
||||
|
||||
Run: `source .venv/bin/activate && bash scripts/run_tests.sh`
|
||||
Expected: 121+ passed (existing) + 14 new = 135+ passed
|
||||
Expected: 121+ 기존 통과 + 14 신규 = 135+ 통과
|
||||
|
||||
**Step 6: Commit**
|
||||
**Step 6: 커밋**
|
||||
|
||||
```bash
|
||||
git add scripts/weekly_report.py tests/test_weekly_report.py
|
||||
git commit -m "feat(weekly-report): add main orchestration, CLI, JSON save"
|
||||
git commit -m "feat(weekly-report): 메인 오케스트레이션, CLI, JSON 저장 추가"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Manual smoke test + crontab guide
|
||||
### Task 7: 수동 스모크 테스트 + 크론탭 가이드
|
||||
|
||||
**Files:**
|
||||
- No new files
|
||||
- 신규 파일 없음
|
||||
|
||||
**Step 1: Dry run (skip fetch, skip Discord)**
|
||||
**Step 1: 드라이 런 (데이터 수집 스킵, Discord 스킵)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
@@ -1017,19 +1017,19 @@ source .venv/bin/activate && python scripts/weekly_report.py --skip-fetch --date
|
||||
|
||||
Expected: 리포트가 터미널에 출력되고 `results/weekly/report_2026-03-07.json` 저장됨.
|
||||
|
||||
**Step 2: Verify saved JSON**
|
||||
**Step 2: 저장된 JSON 확인**
|
||||
|
||||
Run: `cat results/weekly/report_2026-03-07.json | python -m json.tool | head -30`
|
||||
Expected: valid JSON with date, backtest, live_trades, trend, ml_trigger keys
|
||||
Expected: date, backtest, live_trades, trend, ml_trigger 키가 포함된 유효한 JSON
|
||||
|
||||
**Step 3: Commit final state**
|
||||
**Step 3: 최종 상태 커밋**
|
||||
|
||||
```bash
|
||||
git add results/weekly/.gitkeep
|
||||
git commit -m "chore: add results/weekly directory"
|
||||
git commit -m "chore: results/weekly 디렉토리 추가"
|
||||
```
|
||||
|
||||
**Step 4: Document crontab setup**
|
||||
**Step 4: 크론탭 설정 문서화**
|
||||
|
||||
프로덕션 서버에서:
|
||||
```bash
|
||||
@@ -1041,31 +1041,31 @@ crontab -e
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Update CLAUDE.md plan history
|
||||
### Task 8: CLAUDE.md 플랜 히스토리 업데이트
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
**Step 1: Add plan entry to history table**
|
||||
**Step 1: 히스토리 테이블에 플랜 항목 추가**
|
||||
|
||||
Add to the plan history table:
|
||||
플랜 히스토리 테이블에 추가:
|
||||
```
|
||||
| 2026-03-07 | `weekly-report` (plan) | Completed |
|
||||
```
|
||||
|
||||
**Step 2: Add weekly report commands to Common Commands section**
|
||||
**Step 2: Common Commands 섹션에 주간 리포트 명령어 추가**
|
||||
|
||||
```bash
|
||||
# Weekly strategy report (manual)
|
||||
# 주간 전략 리포트 (수동)
|
||||
python scripts/weekly_report.py --skip-fetch
|
||||
|
||||
# Weekly report with data refresh
|
||||
# 주간 리포트 (데이터 새로고침 포함)
|
||||
python scripts/weekly_report.py
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
**Step 3: 커밋**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: add weekly-report to plan history and commands"
|
||||
git commit -m "docs: 주간 리포트를 플랜 히스토리 및 명령어에 추가"
|
||||
```
|
||||
|
||||
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())
|
||||
@@ -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
|
||||
|
||||
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
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
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
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
121
scripts/collect_ls_ratio.py
Normal file
121
scripts/collect_ls_ratio.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Long/Short Ratio 장기 수집 스크립트.
|
||||
15분마다 cron 실행하여 Binance Trading Data API에서
|
||||
top_acct_ls_ratio, global_ls_ratio를 data/{symbol}/ls_ratio_15m.parquet에 누적한다.
|
||||
|
||||
수집 대상:
|
||||
- topLongShortAccountRatio × 3심볼 (XRPUSDT, BTCUSDT, ETHUSDT)
|
||||
- globalLongShortAccountRatio × 3심볼 (XRPUSDT, BTCUSDT, ETHUSDT)
|
||||
→ 총 API 호출 6회/15분 (rate limit 무관)
|
||||
|
||||
사용법:
|
||||
python scripts/collect_ls_ratio.py
|
||||
python scripts/collect_ls_ratio.py --symbols XRPUSDT BTCUSDT
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import aiohttp
|
||||
import pandas as pd
|
||||
|
||||
BASE_URL = "https://fapi.binance.com"
|
||||
DEFAULT_SYMBOLS = ["XRPUSDT", "BTCUSDT", "ETHUSDT"]
|
||||
|
||||
ENDPOINTS = {
|
||||
"top_acct_ls_ratio": "/futures/data/topLongShortAccountRatio",
|
||||
"global_ls_ratio": "/futures/data/globalLongShortAccountRatio",
|
||||
}
|
||||
|
||||
|
||||
async def fetch_latest(session: aiohttp.ClientSession, symbol: str) -> dict | None:
|
||||
"""심볼 하나에 대해 두 ratio의 최신 1건씩 가져온다."""
|
||||
row = {"timestamp": None, "symbol": symbol}
|
||||
|
||||
for col_name, endpoint in ENDPOINTS.items():
|
||||
url = f"{BASE_URL}{endpoint}"
|
||||
params = {"symbol": symbol, "period": "15m", "limit": 1}
|
||||
try:
|
||||
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
data = await resp.json()
|
||||
if isinstance(data, list) and data:
|
||||
row[col_name] = float(data[0]["longShortRatio"])
|
||||
# 타임스탬프는 첫 번째 응답에서 설정
|
||||
if row["timestamp"] is None:
|
||||
row["timestamp"] = pd.Timestamp(
|
||||
int(data[0]["timestamp"]), unit="ms", tz="UTC"
|
||||
)
|
||||
else:
|
||||
print(f"[WARN] {symbol} {col_name}: unexpected response: {data}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[ERROR] {symbol} {col_name}: {e}")
|
||||
return None
|
||||
|
||||
return row
|
||||
|
||||
|
||||
async def collect(symbols: list[str]):
|
||||
"""모든 심볼 데이터를 수집하고 parquet에 추가한다."""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
tasks = [fetch_latest(session, sym) for sym in symbols]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
collected = 0
|
||||
|
||||
for row in results:
|
||||
if row is None:
|
||||
continue
|
||||
|
||||
symbol = row["symbol"]
|
||||
out_path = Path(f"data/{symbol.lower()}/ls_ratio_15m.parquet")
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
new_df = pd.DataFrame([{
|
||||
"timestamp": row["timestamp"],
|
||||
"top_acct_ls_ratio": row["top_acct_ls_ratio"],
|
||||
"global_ls_ratio": row["global_ls_ratio"],
|
||||
}])
|
||||
|
||||
if out_path.exists():
|
||||
existing = pd.read_parquet(out_path)
|
||||
# 중복 방지: 동일 timestamp가 이미 있으면 스킵
|
||||
if row["timestamp"] in existing["timestamp"].values:
|
||||
print(f"[SKIP] {symbol} ts={row['timestamp']} already exists")
|
||||
continue
|
||||
combined = pd.concat([existing, new_df], ignore_index=True)
|
||||
else:
|
||||
combined = new_df
|
||||
|
||||
combined.to_parquet(out_path, index=False)
|
||||
collected += 1
|
||||
print(
|
||||
f"[{now.isoformat()}] {symbol}: "
|
||||
f"top_acct={row['top_acct_ls_ratio']:.4f}, "
|
||||
f"global={row['global_ls_ratio']:.4f} "
|
||||
f"→ {out_path} ({len(combined)} rows)"
|
||||
)
|
||||
|
||||
if collected == 0:
|
||||
print(f"[{now.isoformat()}] No new data collected")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="L/S Ratio 장기 수집")
|
||||
parser.add_argument(
|
||||
"--symbols", nargs="+", default=DEFAULT_SYMBOLS,
|
||||
help="수집 대상 심볼 (기본: XRPUSDT BTCUSDT ETHUSDT)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(collect(args.symbols))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
27
scripts/collect_ls_ratio_loop.sh
Executable file
27
scripts/collect_ls_ratio_loop.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
# 15분 경계에 맞춰 collect_ls_ratio.py를 반복 실행한다.
|
||||
# Docker 컨테이너 entrypoint용.
|
||||
|
||||
set -e
|
||||
|
||||
echo "[collect_ls_ratio] Starting loop (interval: 15m)"
|
||||
|
||||
while true; do
|
||||
# 현재 분/초를 기준으로 다음 15분 경계(00/15/30/45)까지 대기
|
||||
now_min=$(date -u +%M | sed 's/^0//')
|
||||
now_sec=$(date -u +%S | sed 's/^0//')
|
||||
# 다음 15분 경계까지 남은 분
|
||||
remainder=$((now_min % 15))
|
||||
wait_min=$((15 - remainder))
|
||||
# 초 단위로 변환 (경계 직후 10초 여유)
|
||||
wait_sec=$(( wait_min * 60 - now_sec + 10 ))
|
||||
if [ "$wait_sec" -le 10 ]; then
|
||||
wait_sec=$((wait_sec + 900))
|
||||
fi
|
||||
|
||||
echo "[collect_ls_ratio] Next run in ${wait_sec}s ($(date -u))"
|
||||
sleep "$wait_sec"
|
||||
|
||||
echo "[collect_ls_ratio] Running collection... ($(date -u))"
|
||||
python scripts/collect_ls_ratio.py || echo "[collect_ls_ratio] ERROR: collection failed"
|
||||
done
|
||||
203
scripts/compare_symbols.py
Normal file
203
scripts/compare_symbols.py
Normal file
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
종목 비교 백테스트: 후보 심볼별 파라미터 sweep → 최적 파라미터 기준 비교표 출력.
|
||||
|
||||
사용법:
|
||||
python scripts/compare_symbols.py --symbols SOLUSDT LINKUSDT AVAXUSDT
|
||||
python scripts/compare_symbols.py --symbols SOLUSDT LINKUSDT AVAXUSDT --skip-fetch
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import date
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from src.backtester import WalkForwardBacktester, WalkForwardConfig
|
||||
from scripts.strategy_sweep import generate_combinations, PARAM_GRID
|
||||
|
||||
|
||||
TRAIN_MONTHS = 3
|
||||
TEST_MONTHS = 1
|
||||
FETCH_DAYS = 365
|
||||
|
||||
|
||||
def fetch_data(symbols: list[str], days: int = FETCH_DAYS) -> None:
|
||||
script = str(Path(__file__).parent / "fetch_history.py")
|
||||
for sym in symbols:
|
||||
cmd = [
|
||||
sys.executable, script,
|
||||
"--symbol", sym,
|
||||
"--interval", "15m",
|
||||
"--days", str(days),
|
||||
]
|
||||
logger.info(f"데이터 수집: {sym} (최근 {days}일)")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
logger.error(f" {sym} 수집 실패: {result.stderr[:300]}")
|
||||
else:
|
||||
logger.info(f" {sym} 수집 완료")
|
||||
|
||||
|
||||
def run_backtest(symbol: str, params: dict) -> dict:
|
||||
cfg = WalkForwardConfig(
|
||||
symbols=[symbol],
|
||||
use_ml=False,
|
||||
train_months=TRAIN_MONTHS,
|
||||
test_months=TEST_MONTHS,
|
||||
**params,
|
||||
)
|
||||
wf = WalkForwardBacktester(cfg)
|
||||
return wf.run()
|
||||
|
||||
|
||||
def sweep_symbol(symbol: str) -> dict:
|
||||
"""심볼별 파라미터 sweep 실행 → 최적 조합 반환."""
|
||||
combos = generate_combinations(PARAM_GRID)
|
||||
logger.info(f"[{symbol}] 파라미터 sweep 시작: {len(combos)}개 조합")
|
||||
|
||||
best = None
|
||||
best_params = None
|
||||
|
||||
for i, params in enumerate(combos):
|
||||
try:
|
||||
result = run_backtest(symbol, params)
|
||||
summary = result["summary"]
|
||||
|
||||
# 거래 5건 미만은 스킵
|
||||
if summary["total_trades"] < 5:
|
||||
continue
|
||||
|
||||
# PF 기준으로 최적 선택 (동률 시 승률 → 손익비 순)
|
||||
if best is None or _is_better(summary, best):
|
||||
best = summary
|
||||
best_params = params
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f" [{symbol}] 조합 {i+1} 실패: {e}")
|
||||
|
||||
if (i + 1) % 50 == 0:
|
||||
logger.info(f" [{symbol}] {i+1}/{len(combos)} 완료")
|
||||
|
||||
logger.info(f"[{symbol}] sweep 완료 → 최적 PF: {best['profit_factor'] if best else 'N/A'}")
|
||||
return {"symbol": symbol, "best_params": best_params, "summary": best}
|
||||
|
||||
|
||||
def _is_better(new: dict, old: dict) -> bool:
|
||||
"""PF → 손익비 → 승률 순으로 비교."""
|
||||
new_pf = new["profit_factor"] if new["profit_factor"] != float("inf") else 999
|
||||
old_pf = old["profit_factor"] if old["profit_factor"] != float("inf") else 999
|
||||
|
||||
if new_pf != old_pf:
|
||||
return new_pf > old_pf
|
||||
new_pr = new.get("payoff_ratio", 0) or 0
|
||||
old_pr = old.get("payoff_ratio", 0) or 0
|
||||
if new_pr != old_pr:
|
||||
return new_pr > old_pr
|
||||
return new["win_rate"] > old["win_rate"]
|
||||
|
||||
|
||||
def print_comparison(results: list[dict]) -> None:
|
||||
header = (
|
||||
f"{'심볼':<10} {'파라미터':^30} {'거래수':>6} {'승률':>7} "
|
||||
f"{'손익비':>7} {'연속손실':>8} {'PF':>6} {'수익률':>8} {'MDD':>6} {'총PnL':>10}"
|
||||
)
|
||||
sep = "=" * len(header)
|
||||
print(f"\n{sep}")
|
||||
print("종목 비교 백테스트 결과 (심볼별 최적 파라미터)")
|
||||
print(sep)
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
|
||||
for r in results:
|
||||
s = r["summary"]
|
||||
p = r["best_params"]
|
||||
if not s or not p:
|
||||
print(f"{r['symbol'].replace('USDT', ''):<10} {'데이터 부족 또는 sweep 실패':^30}")
|
||||
continue
|
||||
|
||||
short = r["symbol"].replace("USDT", "")
|
||||
param_str = f"SL={p['atr_sl_mult']}/TP={p['atr_tp_mult']}/ADX={p['adx_threshold']}"
|
||||
pf = s["profit_factor"]
|
||||
pf_str = f"{pf:.2f}" if pf != float("inf") else "INF"
|
||||
|
||||
print(
|
||||
f"{short:<10} {param_str:^30} {s['total_trades']:>6} "
|
||||
f"{s['win_rate']:>6.1f}% "
|
||||
f"{s.get('payoff_ratio', 0):>7.2f} "
|
||||
f"{s.get('max_consecutive_losses', 0):>8} "
|
||||
f"{pf_str:>6} "
|
||||
f"{s['return_pct']:>7.2f}% "
|
||||
f"{s['max_drawdown_pct']:>5.1f}% "
|
||||
f"{s['total_pnl']:>+10.2f}"
|
||||
)
|
||||
|
||||
print("-" * len(header))
|
||||
print("\n[판정 기준]")
|
||||
print(" - 승률 50%+ & 손익비 1.0+ → 실전 지속 가능")
|
||||
print(" - 연속 손실 5회 이하 → 멘탈 관리 가능")
|
||||
print(" - 거래 20건+ → 통계적 유의성 있음")
|
||||
print()
|
||||
|
||||
# 상세 파라미터 출력
|
||||
print("[심볼별 최적 파라미터 상세]")
|
||||
for r in results:
|
||||
if r["best_params"]:
|
||||
p = r["best_params"]
|
||||
print(f" {r['symbol']}: SL={p['atr_sl_mult']}, TP={p['atr_tp_mult']}, "
|
||||
f"Signal={p['signal_threshold']}, ADX={p['adx_threshold']}, "
|
||||
f"Vol={p['volume_multiplier']}")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="종목 비교 백테스트 (심볼별 파라미터 sweep)")
|
||||
parser.add_argument(
|
||||
"--symbols", nargs="+", required=True,
|
||||
help="비교할 심볼 리스트 (e.g., SOLUSDT LINKUSDT AVAXUSDT)",
|
||||
)
|
||||
parser.add_argument("--skip-fetch", action="store_true", help="데이터 수집 스킵")
|
||||
parser.add_argument("--days", type=int, default=FETCH_DAYS, help="데이터 수집 기간 (일)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 1) 데이터 수집
|
||||
if not args.skip_fetch:
|
||||
fetch_data(args.symbols, args.days)
|
||||
|
||||
# 2) 심볼별 sweep
|
||||
results = []
|
||||
for sym in args.symbols:
|
||||
try:
|
||||
result = sweep_symbol(sym)
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
logger.error(f" {sym} sweep 실패: {e}")
|
||||
results.append({"symbol": sym, "best_params": None, "summary": None})
|
||||
|
||||
# 3) 비교표
|
||||
if results:
|
||||
print_comparison(results)
|
||||
|
||||
# 4) JSON 저장
|
||||
out_dir = Path("results/compare")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = out_dir / f"compare_{date.today().isoformat()}.json"
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(
|
||||
[{
|
||||
"symbol": r["symbol"],
|
||||
"best_params": r["best_params"],
|
||||
"summary": r["summary"],
|
||||
} for r in results],
|
||||
f, indent=2, ensure_ascii=False,
|
||||
default=lambda x: str(x) if isinstance(x, float) and x == float("inf") else x,
|
||||
)
|
||||
logger.info(f"결과 저장: {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
175
scripts/evaluate_oos.py
Normal file
175
scripts/evaluate_oos.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
MTF Pullback Bot — OOS Dry-run 평가 스크립트
|
||||
─────────────────────────────────────────────
|
||||
프로덕션 서버에서 JSONL 거래 기록을 가져와
|
||||
승률·PF·누적PnL·평균보유시간을 계산하고 LIVE 배포 판정을 출력한다.
|
||||
|
||||
Usage:
|
||||
python scripts/evaluate_oos.py
|
||||
python scripts/evaluate_oos.py --symbol xrpusdt
|
||||
python scripts/evaluate_oos.py --local # 로컬 파일만 사용 (서버 fetch 스킵)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
# ── 설정 ──────────────────────────────────────────────────────────
|
||||
PROD_HOST = "root@10.1.10.24"
|
||||
REMOTE_DIR = "/root/cointrader/data/trade_history"
|
||||
LOCAL_DIR = Path("data/trade_history")
|
||||
|
||||
# ── 판정 기준 ─────────────────────────────────────────────────────
|
||||
MIN_TRADES = 5
|
||||
MIN_PF = 1.0
|
||||
|
||||
|
||||
def fetch_from_prod(filename: str) -> Path:
|
||||
"""프로덕션 서버에서 JSONL 파일을 scp로 가져온다."""
|
||||
LOCAL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
remote_path = f"{PROD_HOST}:{REMOTE_DIR}/{filename}"
|
||||
local_path = LOCAL_DIR / filename
|
||||
|
||||
print(f"[Fetch] {remote_path} → {local_path}")
|
||||
result = subprocess.run(
|
||||
["scp", remote_path, str(local_path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"[Fetch] scp 실패: {result.stderr.strip()}")
|
||||
if local_path.exists():
|
||||
print(f"[Fetch] 로컬 캐시 사용: {local_path}")
|
||||
else:
|
||||
print("[Fetch] 로컬 캐시도 없음. 종료.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"[Fetch] 완료 ({local_path.stat().st_size:,} bytes)")
|
||||
|
||||
return local_path
|
||||
|
||||
|
||||
def load_trades(path: Path) -> pd.DataFrame:
|
||||
"""JSONL 파일을 DataFrame으로 로드."""
|
||||
df = pd.read_json(path, lines=True)
|
||||
|
||||
if df.empty:
|
||||
print("[Load] 거래 기록이 비어있습니다.")
|
||||
sys.exit(1)
|
||||
|
||||
df["entry_ts"] = pd.to_datetime(df["entry_ts"], utc=True)
|
||||
df["exit_ts"] = pd.to_datetime(df["exit_ts"], utc=True)
|
||||
df["duration_min"] = (df["exit_ts"] - df["entry_ts"]).dt.total_seconds() / 60
|
||||
|
||||
print(f"[Load] {len(df)}건 로드 완료 ({df['entry_ts'].min():%Y-%m-%d} ~ {df['exit_ts'].max():%Y-%m-%d})")
|
||||
return df
|
||||
|
||||
|
||||
def calc_metrics(df: pd.DataFrame) -> dict:
|
||||
"""핵심 지표 계산. 빈 DataFrame이면 안전한 기본값 반환."""
|
||||
n = len(df)
|
||||
if n == 0:
|
||||
return {"trades": 0, "win_rate": 0.0, "pf": 0.0, "cum_pnl": 0.0, "avg_dur": 0.0}
|
||||
|
||||
wins = df[df["pnl_bps"] > 0]
|
||||
losses = df[df["pnl_bps"] < 0]
|
||||
|
||||
win_rate = len(wins) / n * 100
|
||||
gross_profit = wins["pnl_bps"].sum() if len(wins) > 0 else 0.0
|
||||
gross_loss = abs(losses["pnl_bps"].sum()) if len(losses) > 0 else 0.0
|
||||
pf = gross_profit / gross_loss if gross_loss > 0 else float("inf")
|
||||
cum_pnl = df["pnl_bps"].sum()
|
||||
avg_dur = df["duration_min"].mean()
|
||||
|
||||
return {
|
||||
"trades": n,
|
||||
"win_rate": round(win_rate, 1),
|
||||
"pf": round(pf, 2),
|
||||
"cum_pnl": round(cum_pnl, 1),
|
||||
"avg_dur": round(avg_dur, 1),
|
||||
}
|
||||
|
||||
|
||||
def print_report(df: pd.DataFrame):
|
||||
"""성적표 출력."""
|
||||
total = calc_metrics(df)
|
||||
longs = calc_metrics(df[df["side"] == "LONG"])
|
||||
shorts = calc_metrics(df[df["side"] == "SHORT"])
|
||||
|
||||
header = f"{'':>10} {'Trades':>8} {'WinRate':>9} {'PF':>8} {'CumPnL':>10} {'AvgDur':>10}"
|
||||
sep = "─" * 60
|
||||
|
||||
print()
|
||||
print(sep)
|
||||
print(" MTF Pullback Bot — OOS Dry-run 성적표")
|
||||
print(sep)
|
||||
print(header)
|
||||
print(sep)
|
||||
|
||||
for label, m in [("Total", total), ("LONG", longs), ("SHORT", shorts)]:
|
||||
pf_str = f"{m['pf']:.2f}" if m["pf"] != float("inf") else "∞"
|
||||
dur_str = f"{m['avg_dur']:.0f}m" if m["trades"] > 0 else "-"
|
||||
print(
|
||||
f"{label:>10} {m['trades']:>8d} {m['win_rate']:>8.1f}% {pf_str:>8} "
|
||||
f"{m['cum_pnl']:>+10.1f} {dur_str:>10}"
|
||||
)
|
||||
|
||||
print(sep)
|
||||
|
||||
# ── 개별 거래 내역 ──
|
||||
print()
|
||||
print(" 거래 내역")
|
||||
print(sep)
|
||||
print(f"{'#':>3} {'Side':>6} {'Entry':>10} {'Exit':>10} {'PnL(bps)':>10} {'Dur':>8} {'Reason'}")
|
||||
print(sep)
|
||||
for i, row in df.iterrows():
|
||||
dur = f"{row['duration_min']:.0f}m"
|
||||
reason = row.get("reason", "")
|
||||
if len(reason) > 25:
|
||||
reason = reason[:25] + "…"
|
||||
print(
|
||||
f"{i+1:>3} {row['side']:>6} {row['entry_price']:>10.4f} {row['exit_price']:>10.4f} "
|
||||
f"{row['pnl_bps']:>+10.1f} {dur:>8} {reason}"
|
||||
)
|
||||
print(sep)
|
||||
|
||||
# ── 최종 판정 ──
|
||||
print()
|
||||
if total["trades"] >= MIN_TRADES and total["pf"] >= MIN_PF:
|
||||
print(f" [판정: 통과] 엣지가 증명되었습니다. LIVE 배포(자금 투입)를 권장합니다.")
|
||||
print(f" (거래수 {total['trades']} >= {MIN_TRADES}, PF {total['pf']:.2f} >= {MIN_PF:.1f})")
|
||||
else:
|
||||
reasons = []
|
||||
if total["trades"] < MIN_TRADES:
|
||||
reasons.append(f"거래수 {total['trades']} < {MIN_TRADES}")
|
||||
if total["pf"] < MIN_PF:
|
||||
reasons.append(f"PF {total['pf']:.2f} < {MIN_PF:.1f}")
|
||||
print(f" [판정: 보류] 기준 미달. OOS 검증 실패로 실전 투입을 보류합니다.")
|
||||
print(f" ({', '.join(reasons)})")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="MTF OOS Dry-run 평가")
|
||||
parser.add_argument("--symbol", default="xrpusdt", help="심볼 (파일명 소문자, 기본: xrpusdt)")
|
||||
parser.add_argument("--local", action="store_true", help="로컬 파일만 사용 (서버 fetch 스킵)")
|
||||
args = parser.parse_args()
|
||||
|
||||
filename = f"mtf_{args.symbol}.jsonl"
|
||||
|
||||
if args.local:
|
||||
local_path = LOCAL_DIR / filename
|
||||
if not local_path.exists():
|
||||
print(f"[Error] 로컬 파일 없음: {local_path}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
local_path = fetch_from_prod(filename)
|
||||
|
||||
df = load_trades(local_path)
|
||||
print_report(df)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -28,6 +28,35 @@ load_dotenv()
|
||||
# 심볼 간 딜레이 없이 연속 요청하면 레이트 리밋(-1003) 발생
|
||||
_REQUEST_DELAY = 0.3 # 초당 ~3.3 req → 안전 마진 충분
|
||||
_FAPI_BASE = "https://fapi.binance.com"
|
||||
_MAX_RETRIES = 3
|
||||
|
||||
|
||||
async def _get_json_with_retry(
|
||||
session: aiohttp.ClientSession,
|
||||
url: str,
|
||||
params: dict,
|
||||
symbol: str,
|
||||
) -> list | dict | None:
|
||||
"""aiohttp GET 요청 + exponential backoff retry (최대 3회)."""
|
||||
for attempt in range(_MAX_RETRIES):
|
||||
try:
|
||||
async with session.get(url, params=params) as resp:
|
||||
if resp.status == 429:
|
||||
wait = 2 ** (attempt + 1)
|
||||
print(f" [{symbol}] Rate limit(429), {wait}초 후 재시도 ({attempt+1}/{_MAX_RETRIES})")
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
except Exception as e:
|
||||
if attempt < _MAX_RETRIES - 1:
|
||||
wait = 2 ** (attempt + 1)
|
||||
print(f" [{symbol}] API 오류 ({e}), {wait}초 후 재시도 ({attempt+1}/{_MAX_RETRIES})")
|
||||
await asyncio.sleep(wait)
|
||||
else:
|
||||
print(f" [{symbol}] API {_MAX_RETRIES}회 실패: {e}")
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
@@ -44,12 +73,23 @@ async def _fetch_klines_with_client(
|
||||
start_ts = int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp() * 1000)
|
||||
all_klines = []
|
||||
while True:
|
||||
klines = await client.futures_klines(
|
||||
symbol=symbol,
|
||||
interval=interval,
|
||||
startTime=start_ts,
|
||||
limit=1500,
|
||||
)
|
||||
for attempt in range(3):
|
||||
try:
|
||||
klines = await client.futures_klines(
|
||||
symbol=symbol,
|
||||
interval=interval,
|
||||
startTime=start_ts,
|
||||
limit=1500,
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt < 2:
|
||||
wait = 2 ** (attempt + 1)
|
||||
print(f" [{symbol}] API 오류 ({e}), {wait}초 후 재시도 ({attempt+1}/3)")
|
||||
await asyncio.sleep(wait)
|
||||
else:
|
||||
print(f" [{symbol}] API 3회 실패, 수집 중단: {e}")
|
||||
raise
|
||||
if not klines:
|
||||
break
|
||||
all_klines.extend(klines)
|
||||
@@ -137,8 +177,7 @@ async def _fetch_oi_hist(
|
||||
"limit": 500,
|
||||
"startTime": start_ts,
|
||||
}
|
||||
async with session.get(url, params=params) as resp:
|
||||
data = await resp.json()
|
||||
data = await _get_json_with_retry(session, url, params, symbol)
|
||||
|
||||
if not data or not isinstance(data, list):
|
||||
break
|
||||
@@ -188,8 +227,7 @@ async def _fetch_funding_rate(
|
||||
"startTime": start_ts,
|
||||
"limit": 1000,
|
||||
}
|
||||
async with session.get(url, params=params) as resp:
|
||||
data = await resp.json()
|
||||
data = await _get_json_with_retry(session, url, params, symbol)
|
||||
|
||||
if not data or not isinstance(data, list):
|
||||
break
|
||||
@@ -311,6 +349,7 @@ def upsert_parquet(path: "Path | str", new_df: pd.DataFrame) -> pd.DataFrame:
|
||||
if col in existing.columns:
|
||||
existing[col] = existing[col].fillna(0.0)
|
||||
|
||||
existing = existing[~existing.index.duplicated(keep='last')]
|
||||
return existing.sort_index()
|
||||
|
||||
|
||||
|
||||
342
scripts/mtf_backtest.py
Normal file
342
scripts/mtf_backtest.py
Normal file
@@ -0,0 +1,342 @@
|
||||
"""
|
||||
MTF Pullback Backtest
|
||||
─────────────────────
|
||||
Trigger: 1h 추세 방향으로 15m 눌림목 진입
|
||||
LONG: 1h Meta=LONG + 15m close < EMA20 + vol < SMA20*0.5 → 다음 봉 close > EMA20 시 진입
|
||||
SHORT: 1h Meta=SHORT + 15m close > EMA20 + vol < SMA20*0.5 → 다음 봉 close < EMA20 시 진입
|
||||
|
||||
SL/TP: 1h ATR 기반 (진입 시점 직전 완성된 1h 캔들)
|
||||
Look-ahead bias 방지: 1h 지표는 직전 완성 봉만 사용
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import pandas_ta as ta
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
|
||||
# ─── 설정 ────────────────────────────────────────────────────────
|
||||
SYMBOL = "xrpusdt"
|
||||
DATA_PATH = Path(f"data/{SYMBOL}/combined_15m.parquet")
|
||||
START = "2026-02-01"
|
||||
END = "2026-03-30"
|
||||
|
||||
ATR_SL_MULT = 1.5
|
||||
ATR_TP_MULT = 2.3
|
||||
FEE_RATE = 0.0004 # 0.04% per side
|
||||
|
||||
# 1h 메타필터
|
||||
MTF_EMA_FAST = 50
|
||||
MTF_EMA_SLOW = 200
|
||||
MTF_ADX_THRESHOLD = 20
|
||||
|
||||
# 15m Trigger
|
||||
EMA_PULLBACK_LEN = 20
|
||||
VOL_DRY_RATIO = 0.5 # volume < vol_ma20 * 0.5
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trade:
|
||||
entry_time: pd.Timestamp
|
||||
entry_price: float
|
||||
side: str
|
||||
sl: float
|
||||
tp: float
|
||||
exit_time: pd.Timestamp | None = None
|
||||
exit_price: float | None = None
|
||||
pnl_pct: float | None = None
|
||||
|
||||
|
||||
def build_1h_data(df_15m: pd.DataFrame) -> pd.DataFrame:
|
||||
"""15m → 1h 리샘플링 + EMA50, EMA200, ADX, ATR."""
|
||||
df_1h = df_15m[["open", "high", "low", "close", "volume"]].resample("1h").agg(
|
||||
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
|
||||
).dropna()
|
||||
|
||||
df_1h["ema50_1h"] = ta.ema(df_1h["close"], length=MTF_EMA_FAST)
|
||||
df_1h["ema200_1h"] = ta.ema(df_1h["close"], length=MTF_EMA_SLOW)
|
||||
adx_df = ta.adx(df_1h["high"], df_1h["low"], df_1h["close"], length=14)
|
||||
df_1h["adx_1h"] = adx_df["ADX_14"]
|
||||
df_1h["atr_1h"] = ta.atr(df_1h["high"], df_1h["low"], df_1h["close"], length=14)
|
||||
|
||||
return df_1h[["ema50_1h", "ema200_1h", "adx_1h", "atr_1h"]]
|
||||
|
||||
|
||||
def merge_1h_to_15m(df_15m: pd.DataFrame, df_1h: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Look-ahead bias 방지: 1h 봉 완성 시점(+1h) 기준 backward merge."""
|
||||
df_1h_shifted = df_1h.copy()
|
||||
df_1h_shifted.index = df_1h_shifted.index + pd.Timedelta(hours=1)
|
||||
|
||||
df_15m_reset = df_15m.reset_index()
|
||||
df_1h_reset = df_1h_shifted.reset_index()
|
||||
df_1h_reset.rename(columns={"index": "timestamp"}, inplace=True)
|
||||
if "timestamp" not in df_15m_reset.columns:
|
||||
df_15m_reset.rename(columns={df_15m_reset.columns[0]: "timestamp"}, inplace=True)
|
||||
|
||||
df_15m_reset["timestamp"] = pd.to_datetime(df_15m_reset["timestamp"]).astype("datetime64[us]")
|
||||
df_1h_reset["timestamp"] = pd.to_datetime(df_1h_reset["timestamp"]).astype("datetime64[us]")
|
||||
|
||||
merged = pd.merge_asof(
|
||||
df_15m_reset.sort_values("timestamp"),
|
||||
df_1h_reset.sort_values("timestamp"),
|
||||
on="timestamp",
|
||||
direction="backward",
|
||||
)
|
||||
return merged.set_index("timestamp")
|
||||
|
||||
|
||||
def get_1h_meta(row) -> str:
|
||||
"""1h 메타필터: EMA50/200 방향 + ADX > 20."""
|
||||
ema50 = row.get("ema50_1h")
|
||||
ema200 = row.get("ema200_1h")
|
||||
adx = row.get("adx_1h")
|
||||
|
||||
if pd.isna(ema50) or pd.isna(ema200) or pd.isna(adx):
|
||||
return "HOLD"
|
||||
if adx < MTF_ADX_THRESHOLD:
|
||||
return "HOLD"
|
||||
if ema50 > ema200:
|
||||
return "LONG"
|
||||
elif ema50 < ema200:
|
||||
return "SHORT"
|
||||
return "HOLD"
|
||||
|
||||
|
||||
def calc_metrics(trades: list[Trade]) -> dict:
|
||||
if not trades:
|
||||
return {"trades": 0, "win_rate": 0, "pf": 0, "pnl_bps": 0, "max_dd_bps": 0,
|
||||
"avg_win_bps": 0, "avg_loss_bps": 0, "long_trades": 0, "short_trades": 0}
|
||||
|
||||
pnls = [t.pnl_pct for t in trades]
|
||||
wins = [p for p in pnls if p > 0]
|
||||
losses = [p for p in pnls if p <= 0]
|
||||
|
||||
gross_profit = sum(wins) if wins else 0
|
||||
gross_loss = abs(sum(losses)) if losses else 0
|
||||
pf = gross_profit / gross_loss if gross_loss > 0 else float("inf")
|
||||
|
||||
cumulative = np.cumsum(pnls)
|
||||
peak = np.maximum.accumulate(cumulative)
|
||||
dd = cumulative - peak
|
||||
max_dd = abs(dd.min()) if len(dd) > 0 else 0
|
||||
|
||||
return {
|
||||
"trades": len(trades),
|
||||
"win_rate": len(wins) / len(trades) * 100,
|
||||
"pf": round(pf, 2),
|
||||
"pnl_bps": round(sum(pnls) * 10000, 1),
|
||||
"max_dd_bps": round(max_dd * 10000, 1),
|
||||
"avg_win_bps": round(np.mean(wins) * 10000, 1) if wins else 0,
|
||||
"avg_loss_bps": round(np.mean(losses) * 10000, 1) if losses else 0,
|
||||
"long_trades": sum(1 for t in trades if t.side == "LONG"),
|
||||
"short_trades": sum(1 for t in trades if t.side == "SHORT"),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print(" MTF Pullback Backtest")
|
||||
print(f" {SYMBOL.upper()} | {START} ~ {END}")
|
||||
print(f" SL: 1h ATR×{ATR_SL_MULT} | TP: 1h ATR×{ATR_TP_MULT} | Fee: {FEE_RATE*100:.2f}%/side")
|
||||
print(f" Pullback: EMA{EMA_PULLBACK_LEN} | Vol dry: <{VOL_DRY_RATIO*100:.0f}% of SMA20")
|
||||
print("=" * 70)
|
||||
|
||||
# ── 데이터 로드 ──
|
||||
df_raw = pd.read_parquet(DATA_PATH)
|
||||
if df_raw.index.tz is not None:
|
||||
df_raw.index = df_raw.index.tz_localize(None)
|
||||
|
||||
# 1h EMA200 워밍업 (200h = 800 bars)
|
||||
warmup_start = pd.Timestamp(START) - pd.Timedelta(hours=250)
|
||||
df_full = df_raw[df_raw.index >= warmup_start].copy()
|
||||
print(f"\n데이터: {len(df_full)} bars (워밍업 포함)")
|
||||
|
||||
# ── 15m 지표: EMA20, vol_ma20 ──
|
||||
df_full["ema20"] = ta.ema(df_full["close"], length=EMA_PULLBACK_LEN)
|
||||
df_full["vol_ma20"] = ta.sma(df_full["volume"], length=20)
|
||||
|
||||
# ── 1h 지표 ──
|
||||
df_1h = build_1h_data(df_full)
|
||||
print(f"1h 캔들: {len(df_1h)} bars")
|
||||
|
||||
# ── 병합 ──
|
||||
df_merged = merge_1h_to_15m(df_full, df_1h)
|
||||
|
||||
# ── 분석 기간 ──
|
||||
df = df_merged[(df_merged.index >= START) & (df_merged.index <= END)].copy()
|
||||
print(f"분석 기간: {len(df)} bars ({df.index.min()} ~ {df.index.max()})")
|
||||
|
||||
# ── 신호 스캔 & 시뮬레이션 ──
|
||||
trades: list[Trade] = []
|
||||
in_trade = False
|
||||
current_trade: Trade | None = None
|
||||
pullback_ready = False # 눌림 감지 상태
|
||||
pullback_side = ""
|
||||
|
||||
# 디버그 카운터
|
||||
meta_long_count = 0
|
||||
meta_short_count = 0
|
||||
pullback_detected = 0
|
||||
entry_triggered = 0
|
||||
|
||||
for i in range(1, len(df)):
|
||||
row = df.iloc[i]
|
||||
prev = df.iloc[i - 1]
|
||||
|
||||
# ── 기존 포지션 SL/TP 체크 ──
|
||||
if in_trade and current_trade is not None:
|
||||
hit_sl = False
|
||||
hit_tp = False
|
||||
|
||||
if current_trade.side == "LONG":
|
||||
if row["low"] <= current_trade.sl:
|
||||
hit_sl = True
|
||||
if row["high"] >= current_trade.tp:
|
||||
hit_tp = True
|
||||
else:
|
||||
if row["high"] >= current_trade.sl:
|
||||
hit_sl = True
|
||||
if row["low"] <= current_trade.tp:
|
||||
hit_tp = True
|
||||
|
||||
if hit_sl or hit_tp:
|
||||
exit_price = current_trade.sl if hit_sl else current_trade.tp
|
||||
if hit_sl and hit_tp:
|
||||
exit_price = current_trade.sl # 보수적
|
||||
|
||||
if current_trade.side == "LONG":
|
||||
raw_pnl = (exit_price - current_trade.entry_price) / current_trade.entry_price
|
||||
else:
|
||||
raw_pnl = (current_trade.entry_price - exit_price) / current_trade.entry_price
|
||||
|
||||
current_trade.exit_time = df.index[i]
|
||||
current_trade.exit_price = exit_price
|
||||
current_trade.pnl_pct = raw_pnl - FEE_RATE * 2
|
||||
trades.append(current_trade)
|
||||
in_trade = False
|
||||
current_trade = None
|
||||
|
||||
# ── 포지션 중이면 새 진입 스킵 ──
|
||||
if in_trade:
|
||||
continue
|
||||
|
||||
# NaN 체크
|
||||
if pd.isna(row.get("ema20")) or pd.isna(row.get("vol_ma20")) or pd.isna(row.get("atr_1h")):
|
||||
pullback_ready = False
|
||||
continue
|
||||
|
||||
# ── Step 1: 1h Meta Filter ──
|
||||
meta = get_1h_meta(row)
|
||||
if meta == "LONG":
|
||||
meta_long_count += 1
|
||||
elif meta == "SHORT":
|
||||
meta_short_count += 1
|
||||
|
||||
if meta == "HOLD":
|
||||
pullback_ready = False
|
||||
continue
|
||||
|
||||
# ── Step 2: 눌림(Pullback) 감지 ──
|
||||
# 이전 봉이 눌림 조건을 충족했는지 확인
|
||||
if pullback_ready and pullback_side == meta:
|
||||
# ── Step 4: 추세 재개 확인 (현재 봉 close 기준) ──
|
||||
if pullback_side == "LONG" and row["close"] > row["ema20"]:
|
||||
# 진입: 이 봉의 open (추세 재개 확인된 봉)
|
||||
# 실제로는 close 시점에 확인하므로 다음 봉 open에 진입해야 look-ahead 방지
|
||||
# 하지만 사양서에 "직후 캔들의 종가가 EMA20 상향 돌파한 첫 번째 캔들의 시가"라고 되어 있으므로
|
||||
# → 이 봉(close > EMA20)의 open에서 진입은 look-ahead bias
|
||||
# → 정확히는: prev가 pullback, 현재 봉 close > EMA20 확인 → 다음 봉 open 진입
|
||||
# 여기서는 다음 봉 open으로 처리
|
||||
if i + 1 < len(df):
|
||||
next_row = df.iloc[i + 1]
|
||||
entry_price = next_row["open"]
|
||||
atr_1h = row["atr_1h"]
|
||||
|
||||
sl = entry_price - atr_1h * ATR_SL_MULT
|
||||
tp = entry_price + atr_1h * ATR_TP_MULT
|
||||
|
||||
current_trade = Trade(
|
||||
entry_time=df.index[i + 1],
|
||||
entry_price=entry_price,
|
||||
side="LONG",
|
||||
sl=sl, tp=tp,
|
||||
)
|
||||
in_trade = True
|
||||
pullback_ready = False
|
||||
entry_triggered += 1
|
||||
continue
|
||||
|
||||
elif pullback_side == "SHORT" and row["close"] < row["ema20"]:
|
||||
if i + 1 < len(df):
|
||||
next_row = df.iloc[i + 1]
|
||||
entry_price = next_row["open"]
|
||||
atr_1h = row["atr_1h"]
|
||||
|
||||
sl = entry_price + atr_1h * ATR_SL_MULT
|
||||
tp = entry_price - atr_1h * ATR_TP_MULT
|
||||
|
||||
current_trade = Trade(
|
||||
entry_time=df.index[i + 1],
|
||||
entry_price=entry_price,
|
||||
side="SHORT",
|
||||
sl=sl, tp=tp,
|
||||
)
|
||||
in_trade = True
|
||||
pullback_ready = False
|
||||
entry_triggered += 1
|
||||
continue
|
||||
|
||||
# ── Step 2+3: 눌림 + 거래량 고갈 감지 (다음 봉에서 재개 확인) ──
|
||||
vol_dry = row["volume"] < row["vol_ma20"] * VOL_DRY_RATIO
|
||||
|
||||
if meta == "LONG" and row["close"] < row["ema20"] and vol_dry:
|
||||
pullback_ready = True
|
||||
pullback_side = "LONG"
|
||||
pullback_detected += 1
|
||||
elif meta == "SHORT" and row["close"] > row["ema20"] and vol_dry:
|
||||
pullback_ready = True
|
||||
pullback_side = "SHORT"
|
||||
pullback_detected += 1
|
||||
else:
|
||||
# 조건 불충족 시 pullback 상태 리셋
|
||||
# 단, 연속 pullback 허용 (여러 봉 동안 눌림 지속 가능)
|
||||
if not (meta == pullback_side):
|
||||
pullback_ready = False
|
||||
|
||||
# ── 결과 출력 ──
|
||||
m = calc_metrics(trades)
|
||||
long_trades = [t for t in trades if t.side == "LONG"]
|
||||
short_trades = [t for t in trades if t.side == "SHORT"]
|
||||
lm = calc_metrics(long_trades)
|
||||
sm = calc_metrics(short_trades)
|
||||
|
||||
print(f"\n─── 신호 파이프라인 ───")
|
||||
print(f"1h Meta LONG: {meta_long_count} bars | SHORT: {meta_short_count} bars")
|
||||
print(f"Pullback 감지: {pullback_detected}건")
|
||||
print(f"진입 트리거: {entry_triggered}건")
|
||||
print(f"실제 거래: {m['trades']}건 (L:{m['long_trades']} / S:{m['short_trades']})")
|
||||
|
||||
print(f"\n{'=' * 70}")
|
||||
print(f" 결과")
|
||||
print(f"{'=' * 70}")
|
||||
|
||||
header = f"{'구분':<10} {'Trades':>7} {'WinRate':>8} {'PF':>6} {'PnL(bps)':>10} {'MaxDD(bps)':>11} {'AvgWin':>8} {'AvgLoss':>8}"
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
print(f"{'전체':<10} {m['trades']:>7} {m['win_rate']:>7.1f}% {m['pf']:>6.2f} {m['pnl_bps']:>10.1f} {m['max_dd_bps']:>11.1f} {m['avg_win_bps']:>8.1f} {m['avg_loss_bps']:>8.1f}")
|
||||
print(f"{'LONG':<10} {lm['trades']:>7} {lm['win_rate']:>7.1f}% {lm['pf']:>6.2f} {lm['pnl_bps']:>10.1f} {lm['max_dd_bps']:>11.1f} {lm['avg_win_bps']:>8.1f} {lm['avg_loss_bps']:>8.1f}")
|
||||
print(f"{'SHORT':<10} {sm['trades']:>7} {sm['win_rate']:>7.1f}% {sm['pf']:>6.2f} {sm['pnl_bps']:>10.1f} {sm['max_dd_bps']:>11.1f} {sm['avg_win_bps']:>8.1f} {sm['avg_loss_bps']:>8.1f}")
|
||||
|
||||
# 개별 거래 목록
|
||||
if trades:
|
||||
print(f"\n─── 개별 거래 ───")
|
||||
print(f"{'#':>3} {'Side':<6} {'Entry Time':<20} {'Entry':>10} {'Exit':>10} {'PnL(bps)':>10} {'Result':>8}")
|
||||
print("-" * 75)
|
||||
for idx, t in enumerate(trades, 1):
|
||||
result = "WIN" if t.pnl_pct > 0 else "LOSS"
|
||||
pnl_bps = t.pnl_pct * 10000
|
||||
print(f"{idx:>3} {t.side:<6} {str(t.entry_time):<20} {t.entry_price:>10.4f} {t.exit_price:>10.4f} {pnl_bps:>+10.1f} {result:>8}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
321
scripts/position_sizing_analysis.py
Normal file
321
scripts/position_sizing_analysis.py
Normal file
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
포지션 사이징 분석: Robust Monte Carlo 방식.
|
||||
|
||||
핵심: 백테스트 31건의 승률/손익비를 고정값으로 믿지 않고,
|
||||
불확실성 범위(승률 30~45%, 손익비 3.0~5.0)를 넣어
|
||||
worst-case 조합에서도 파산하지 않는 리스크 비중을 산출한다.
|
||||
|
||||
사용법:
|
||||
python scripts/position_sizing_analysis.py --symbol SOLUSDT
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import argparse
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
|
||||
from src.backtester import WalkForwardBacktester, WalkForwardConfig
|
||||
|
||||
|
||||
def run_backtest(symbol: str, params: dict) -> dict:
|
||||
cfg = WalkForwardConfig(
|
||||
symbols=[symbol],
|
||||
use_ml=False,
|
||||
train_months=3,
|
||||
test_months=1,
|
||||
**params,
|
||||
)
|
||||
wf = WalkForwardBacktester(cfg)
|
||||
return wf.run()
|
||||
|
||||
|
||||
def extract_r_multiples(trades: list[dict]) -> np.ndarray:
|
||||
"""각 트레이드의 R-multiple을 추출 (1R = SL 히트 시 손실)."""
|
||||
r_multiples = []
|
||||
for t in trades:
|
||||
sl_distance = abs(t["entry_price"] - t["sl"])
|
||||
sl_loss = sl_distance * t["quantity"]
|
||||
if sl_loss <= 0:
|
||||
continue
|
||||
r_multiples.append(t["net_pnl"] / sl_loss)
|
||||
return np.array(r_multiples)
|
||||
|
||||
|
||||
def kelly_criterion(win_rate: float, avg_win_r: float, avg_loss_r: float) -> float:
|
||||
"""Kelly: f* = (W * avg_win_R - (1-W) * |avg_loss_R|) / avg_win_R"""
|
||||
if avg_win_r <= 0:
|
||||
return 0.0
|
||||
expectancy = win_rate * avg_win_r - (1 - win_rate) * abs(avg_loss_r)
|
||||
if expectancy <= 0:
|
||||
return 0.0
|
||||
return expectancy / avg_win_r
|
||||
|
||||
|
||||
def consecutive_loss_survival(risk_pct: float, n: int) -> float:
|
||||
"""n연패 후 잔고 비율(%)."""
|
||||
return (1 - risk_pct) ** n * 100
|
||||
|
||||
|
||||
def robust_monte_carlo(
|
||||
risk_pct: float,
|
||||
win_rate_range: tuple[float, float],
|
||||
payoff_range: tuple[float, float],
|
||||
loss_r_range: tuple[float, float],
|
||||
n_simulations: int = 10000,
|
||||
n_trades: int = 200,
|
||||
initial_balance: float = 1000.0,
|
||||
ruin_threshold: float = 0.20,
|
||||
) -> dict:
|
||||
"""Robust Monte Carlo: 매 시뮬레이션마다 승률/손익비를 범위 내에서 샘플링.
|
||||
|
||||
각 시뮬레이션:
|
||||
1) 승률을 win_rate_range에서 uniform 추출
|
||||
2) 승리 R-multiple을 payoff_range에서 uniform 추출
|
||||
3) 패배 R-multiple을 loss_r_range에서 uniform 추출
|
||||
4) 해당 파라미터로 n_trades건을 생성하여 에퀴티 시뮬레이션
|
||||
"""
|
||||
rng = np.random.default_rng(42)
|
||||
final_balances = np.zeros(n_simulations)
|
||||
max_drawdowns = np.zeros(n_simulations)
|
||||
ruin_count = 0
|
||||
|
||||
for sim in range(n_simulations):
|
||||
# 파라미터 샘플링
|
||||
wr = rng.uniform(*win_rate_range)
|
||||
win_r = rng.uniform(*payoff_range)
|
||||
loss_r = rng.uniform(*loss_r_range)
|
||||
|
||||
# 트레이드 생성
|
||||
outcomes = rng.random(n_trades)
|
||||
r_multiples = np.where(outcomes < wr, win_r, loss_r)
|
||||
|
||||
# 에퀴티 시뮬레이션
|
||||
balance = initial_balance
|
||||
peak = balance
|
||||
max_dd = 0.0
|
||||
ruined = False
|
||||
|
||||
for r in r_multiples:
|
||||
pnl = balance * risk_pct * r
|
||||
balance += pnl
|
||||
|
||||
if balance <= initial_balance * ruin_threshold:
|
||||
ruined = True
|
||||
break
|
||||
|
||||
peak = max(peak, balance)
|
||||
dd = (peak - balance) / peak
|
||||
max_dd = max(max_dd, dd)
|
||||
|
||||
if ruined:
|
||||
ruin_count += 1
|
||||
final_balances[sim] = 0
|
||||
max_drawdowns[sim] = 1.0
|
||||
else:
|
||||
final_balances[sim] = balance
|
||||
max_drawdowns[sim] = max_dd
|
||||
|
||||
return {
|
||||
"risk_pct": risk_pct,
|
||||
"ruin_probability": round(ruin_count / n_simulations * 100, 2),
|
||||
"median_return": round((np.median(final_balances) - initial_balance) / initial_balance * 100, 1),
|
||||
"p5_return": round((np.percentile(final_balances, 5) - initial_balance) / initial_balance * 100, 1),
|
||||
"p25_return": round((np.percentile(final_balances, 25) - initial_balance) / initial_balance * 100, 1),
|
||||
"p75_return": round((np.percentile(final_balances, 75) - initial_balance) / initial_balance * 100, 1),
|
||||
"p95_return": round((np.percentile(final_balances, 95) - initial_balance) / initial_balance * 100, 1),
|
||||
"median_max_dd": round(np.median(max_drawdowns) * 100, 1),
|
||||
"p95_max_dd": round(np.percentile(max_drawdowns, 95) * 100, 1),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="포지션 사이징 분석 (Robust Monte Carlo)")
|
||||
parser.add_argument("--symbol", required=True, type=str)
|
||||
parser.add_argument("--sl-mult", type=float, default=1.0)
|
||||
parser.add_argument("--tp-mult", type=float, default=4.0)
|
||||
parser.add_argument("--signal-threshold", type=int, default=3)
|
||||
parser.add_argument("--adx", type=float, default=20)
|
||||
parser.add_argument("--vol-mult", type=float, default=2.5)
|
||||
args = parser.parse_args()
|
||||
|
||||
symbol = args.symbol.upper()
|
||||
params = {
|
||||
"atr_sl_mult": args.sl_mult,
|
||||
"atr_tp_mult": args.tp_mult,
|
||||
"signal_threshold": args.signal_threshold,
|
||||
"adx_threshold": args.adx,
|
||||
"volume_multiplier": args.vol_mult,
|
||||
}
|
||||
|
||||
# 1) 백테스트로 기준값 추출
|
||||
logger.info(f"[{symbol}] 백테스트 실행")
|
||||
result = run_backtest(symbol, params)
|
||||
trades = result.get("trades", [])
|
||||
summary = result["summary"]
|
||||
|
||||
if len(trades) < 5:
|
||||
logger.error(f"트레이드 {len(trades)}건 — 분석 불가")
|
||||
return
|
||||
|
||||
r_multiples = extract_r_multiples(trades)
|
||||
wins = r_multiples[r_multiples > 0]
|
||||
losses = r_multiples[r_multiples <= 0]
|
||||
obs_wr = len(wins) / len(r_multiples)
|
||||
obs_win_r = float(np.mean(wins)) if len(wins) > 0 else 0
|
||||
obs_loss_r = float(np.mean(losses)) if len(losses) > 0 else 0
|
||||
obs_expectancy = obs_wr * obs_win_r + (1 - obs_wr) * obs_loss_r
|
||||
obs_kelly = kelly_criterion(obs_wr, obs_win_r, abs(obs_loss_r))
|
||||
|
||||
# 불확실성 범위 설정 (관측값 기준 ±보정)
|
||||
# 승률: 관측 38.7% → 30~45% (하방으로 더 넓게)
|
||||
wr_lo = max(0.25, obs_wr - 0.10)
|
||||
wr_hi = min(0.55, obs_wr + 0.07)
|
||||
# 승리 R: 관측 3.85R → 3.0~5.0
|
||||
win_r_lo = max(2.0, obs_win_r - 1.0)
|
||||
win_r_hi = obs_win_r + 1.2
|
||||
# 패배 R: 관측 -1.18R → -1.5 ~ -0.9
|
||||
loss_r_lo = min(-0.8, obs_loss_r + 0.3)
|
||||
loss_r_hi = obs_loss_r - 0.3
|
||||
|
||||
print(f"\n{'=' * 85}")
|
||||
print(f" 포지션 사이징 분석: {symbol} (Robust Monte Carlo)")
|
||||
print(f" 파라미터: SL={args.sl_mult}x ATR, TP={args.tp_mult}x ATR, ADX={args.adx}")
|
||||
print(f"{'=' * 85}")
|
||||
|
||||
# 관측값
|
||||
print(f"\n[백테스트 관측값] ({len(trades)}건)")
|
||||
print(f" 승률: {obs_wr*100:.1f}% | 승리 평균: +{obs_win_r:.2f}R | 패배 평균: {obs_loss_r:.2f}R")
|
||||
print(f" 기대값: {obs_expectancy:.2f}R | Kelly: {obs_kelly*100:.1f}%")
|
||||
print(f" 최대 연속 손실: {summary.get('max_consecutive_losses', 'N/A')}회")
|
||||
|
||||
# R-multiple 분포 (간략)
|
||||
print(f"\n R 분포: 패배 {obs_loss_r:.2f}R (SL+수수료) | 승리 +{obs_win_r:.2f}R (TP-수수료)")
|
||||
print(f" → 거의 바이너리: SL 아니면 TP, 중간 청산 없음")
|
||||
|
||||
# 불확실성 범위
|
||||
print(f"\n[불확실성 범위] (실전 괴리 반영)")
|
||||
print(f" 승률: {wr_lo*100:.0f}% ~ {wr_hi*100:.0f}% (관측: {obs_wr*100:.1f}%)")
|
||||
print(f" 승리 R: +{win_r_lo:.1f}R ~ +{win_r_hi:.1f}R (관측: +{obs_win_r:.2f}R)")
|
||||
print(f" 패배 R: {loss_r_hi:.1f}R ~ {loss_r_lo:.1f}R (관측: {obs_loss_r:.2f}R)")
|
||||
|
||||
# Worst-case Kelly
|
||||
worst_kelly = kelly_criterion(wr_lo, win_r_lo, abs(loss_r_hi))
|
||||
best_kelly = kelly_criterion(wr_hi, win_r_hi, abs(loss_r_lo))
|
||||
print(f"\n Worst-case Kelly: {worst_kelly*100:.1f}% | Best-case Kelly: {best_kelly*100:.1f}%")
|
||||
|
||||
# 연속 손실 생존 테이블
|
||||
print(f"\n[연속 손실 생존 테이블]")
|
||||
print(f" {'리스크%':>8} {'4연패':>7} {'6연패':>7} {'8연패':>7} {'10연패':>7} {'12연패':>7}")
|
||||
print(f" {'-' * 50}")
|
||||
for rp in [0.005, 0.01, 0.015, 0.02, 0.025, 0.03, 0.04, 0.05]:
|
||||
cols = [f"{consecutive_loss_survival(rp, n):.1f}%" for n in [4, 6, 8, 10, 12]]
|
||||
print(f" {rp*100:>7.1f}% {' '.join(f'{c:>7}' for c in cols)}")
|
||||
|
||||
# Robust Monte Carlo
|
||||
risk_levels = [0.005, 0.01, 0.015, 0.02, 0.025, 0.03, 0.04, 0.05, 0.07]
|
||||
|
||||
print(f"\n[Robust Monte Carlo (10,000회 × 200건, 파라미터 매회 랜덤 샘플링)]")
|
||||
print(f" 파산 기준: 잔고 ≤ 20%")
|
||||
print(f" {'리스크%':>8} {'파산%':>6} {'하위5%':>9} {'하위25%':>9} {'중위':>9} "
|
||||
f"{'상위75%':>9} {'상위95%':>10} {'중위MDD':>7} {'95%MDD':>7}")
|
||||
print(f" {'-' * 85}")
|
||||
|
||||
best_risk = None
|
||||
best_score = -999
|
||||
mc_results = []
|
||||
|
||||
for rp in risk_levels:
|
||||
mc = robust_monte_carlo(
|
||||
risk_pct=rp,
|
||||
win_rate_range=(wr_lo, wr_hi),
|
||||
payoff_range=(win_r_lo, win_r_hi),
|
||||
loss_r_range=(loss_r_hi, loss_r_lo), # hi is more negative
|
||||
)
|
||||
mc_results.append(mc)
|
||||
|
||||
# 숫자 포맷
|
||||
def fmt_ret(v):
|
||||
if abs(v) >= 10000:
|
||||
return f"{v/1000:>+7.0f}k%"
|
||||
return f"{v:>+8.1f}%"
|
||||
|
||||
print(f" {rp*100:>7.1f}% {mc['ruin_probability']:>5.1f}% "
|
||||
f"{fmt_ret(mc['p5_return'])} {fmt_ret(mc['p25_return'])} "
|
||||
f"{fmt_ret(mc['median_return'])} {fmt_ret(mc['p75_return'])} "
|
||||
f"{fmt_ret(mc['p95_return']):>10} {mc['median_max_dd']:>6.1f}% "
|
||||
f"{mc['p95_max_dd']:>6.1f}%")
|
||||
|
||||
# 선정 기준: 파산 <1% AND 95%MDD ≤ 30% 에서 중위 수익 최대
|
||||
if (mc["ruin_probability"] <= 1.0
|
||||
and mc["p95_max_dd"] <= 30.0
|
||||
and mc["median_return"] > best_score):
|
||||
best_score = mc["median_return"]
|
||||
best_risk = rp
|
||||
|
||||
# Worst-case 전용 MC (승률 30%, 손익비 3.0 고정)
|
||||
print(f"\n[Worst-Case 시나리오 (승률={wr_lo*100:.0f}%, 승리R=+{win_r_lo:.1f}, 패배R={loss_r_hi:.1f})]")
|
||||
print(f" {'리스크%':>8} {'파산%':>6} {'중위수익':>9} {'95%MDD':>7}")
|
||||
print(f" {'-' * 35}")
|
||||
|
||||
worst_best_risk = None
|
||||
worst_best_score = -999
|
||||
|
||||
for rp in risk_levels:
|
||||
mc = robust_monte_carlo(
|
||||
risk_pct=rp,
|
||||
win_rate_range=(wr_lo, wr_lo + 0.001), # 거의 고정
|
||||
payoff_range=(win_r_lo, win_r_lo + 0.001),
|
||||
loss_r_range=(loss_r_hi, loss_r_hi + 0.001),
|
||||
)
|
||||
|
||||
def fmt_ret(v):
|
||||
if abs(v) >= 10000:
|
||||
return f"{v/1000:>+7.0f}k%"
|
||||
return f"{v:>+8.1f}%"
|
||||
|
||||
print(f" {rp*100:>7.1f}% {mc['ruin_probability']:>5.1f}% "
|
||||
f"{fmt_ret(mc['median_return'])} {mc['p95_max_dd']:>6.1f}%")
|
||||
|
||||
if (mc["ruin_probability"] <= 1.0
|
||||
and mc["p95_max_dd"] <= 30.0
|
||||
and mc["median_return"] > worst_best_score):
|
||||
worst_best_score = mc["median_return"]
|
||||
worst_best_risk = rp
|
||||
|
||||
# 최종 권장
|
||||
print(f"\n{'=' * 85}")
|
||||
print(f" 최종 권장")
|
||||
print(f"{'=' * 85}")
|
||||
|
||||
# 가장 보수적인 값: worst-case MC 최적과 robust MC 최적 중 작은 값
|
||||
candidates = [r for r in [best_risk, worst_best_risk, worst_kelly / 2] if r and r > 0]
|
||||
recommended = min(candidates) if candidates else 0.01
|
||||
recommended = max(0.005, min(recommended, 0.05))
|
||||
|
||||
print(f" Robust MC 최적 (파산<1%, 95%MDD≤30%): {best_risk*100:.1f}%" if best_risk else " Robust MC: 조건 충족 없음")
|
||||
print(f" Worst-Case MC 최적: {worst_best_risk*100:.1f}%" if worst_best_risk else " Worst-Case MC: 조건 충족 없음")
|
||||
print(f" Worst-Case Half Kelly: {worst_kelly/2*100:.1f}%")
|
||||
|
||||
print(f"\n >>> 실전 권장: 1회 리스크 = 계좌의 {recommended*100:.1f}%")
|
||||
print(f" 근거: worst-case에서도 파산하지 않는 가장 보수적 기준")
|
||||
survival_6 = consecutive_loss_survival(recommended, 6)
|
||||
survival_10 = consecutive_loss_survival(recommended, 10)
|
||||
print(f" 6연패 후 잔고: {survival_6:.1f}% | 10연패 후: {survival_10:.1f}%")
|
||||
|
||||
print(f"\n [.env 설정 가이드]")
|
||||
print(f" ATR_SL_MULT_SOLUSDT={args.sl_mult}")
|
||||
print(f" ATR_TP_MULT_SOLUSDT={args.tp_mult}")
|
||||
print(f" ADX_THRESHOLD_SOLUSDT={args.adx}")
|
||||
print(f" SIGNAL_THRESHOLD_SOLUSDT={args.signal_threshold}")
|
||||
for atr_pct in [0.01, 0.012, 0.015]:
|
||||
margin_ratio = recommended / (10 * atr_pct)
|
||||
margin_ratio = min(margin_ratio, 0.50)
|
||||
print(f" ATR≈{atr_pct*100:.1f}% → MARGIN_MAX_RATIO_SOLUSDT ≈ {margin_ratio:.2f}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -20,6 +20,8 @@ import json
|
||||
from datetime import datetime
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pandas_ta as ta
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -50,6 +52,8 @@ def parse_args():
|
||||
|
||||
# Walk-Forward
|
||||
p.add_argument("--walk-forward", action="store_true", help="Walk-Forward 백테스트 (기간별 모델 학습/검증)")
|
||||
p.add_argument("--compare-ml", action="store_true",
|
||||
help="ML on vs off Walk-Forward 비교 (--walk-forward 자동 활성화)")
|
||||
p.add_argument("--train-months", type=int, default=6, help="WF 학습 윈도우 개월 (기본: 6)")
|
||||
p.add_argument("--test-months", type=int, default=1, help="WF 검증 윈도우 개월 (기본: 1)")
|
||||
return p.parse_args()
|
||||
@@ -105,6 +109,174 @@ def print_fold_table(folds: list[dict]):
|
||||
print("=" * 90)
|
||||
|
||||
|
||||
def _classify_regime(btc_return: float, btc_avg_adx: float) -> str:
|
||||
"""BTC ADX와 수익률 기반 시장 레짐 분류."""
|
||||
if btc_avg_adx >= 25:
|
||||
return "상승 추세" if btc_return > 0 else "하락 추세"
|
||||
return "횡보"
|
||||
|
||||
|
||||
def _calc_fold_market_context(
|
||||
raw_df: pd.DataFrame, test_start: str, test_end: str
|
||||
) -> dict:
|
||||
"""폴드 기간의 BTC/ETH 수익률과 시장 레짐 계산."""
|
||||
ts_start = pd.Timestamp(test_start)
|
||||
ts_end = pd.Timestamp(test_end)
|
||||
|
||||
idx = raw_df.index
|
||||
if idx.tz is not None:
|
||||
idx = idx.tz_localize(None)
|
||||
if ts_start.tz is not None:
|
||||
ts_start = ts_start.tz_localize(None)
|
||||
if ts_end.tz is not None:
|
||||
ts_end = ts_end.tz_localize(None)
|
||||
|
||||
fold_df = raw_df[(idx >= ts_start) & (idx < ts_end)]
|
||||
if len(fold_df) < 20:
|
||||
return None
|
||||
|
||||
# BTC return
|
||||
btc_start = fold_df["close_btc"].iloc[0]
|
||||
btc_end = fold_df["close_btc"].iloc[-1]
|
||||
btc_return = (btc_end - btc_start) / btc_start * 100
|
||||
|
||||
# ETH return
|
||||
eth_start = fold_df["close_eth"].iloc[0]
|
||||
eth_end = fold_df["close_eth"].iloc[-1]
|
||||
eth_return = (eth_end - eth_start) / eth_start * 100
|
||||
|
||||
# BTC ADX (period average)
|
||||
adx_df = ta.adx(fold_df["high_btc"], fold_df["low_btc"], fold_df["close_btc"], length=14)
|
||||
btc_avg_adx = adx_df["ADX_14"].mean()
|
||||
if np.isnan(btc_avg_adx):
|
||||
btc_avg_adx = 0.0
|
||||
|
||||
regime = _classify_regime(btc_return, btc_avg_adx)
|
||||
|
||||
return {
|
||||
"btc_return_pct": round(btc_return, 1),
|
||||
"eth_return_pct": round(eth_return, 1),
|
||||
"btc_avg_adx": round(btc_avg_adx, 1),
|
||||
"market_regime": regime,
|
||||
}
|
||||
|
||||
|
||||
def _load_ls_ratio(symbol: str, test_start: str, test_end: str) -> dict | None:
|
||||
"""폴드 기간의 L/S ratio 평균값 로드. 데이터 없으면 None."""
|
||||
path = Path(f"data/{symbol.lower()}/ls_ratio_15m.parquet")
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
df = pd.read_parquet(path)
|
||||
ts_start = pd.Timestamp(test_start)
|
||||
ts_end = pd.Timestamp(test_end)
|
||||
|
||||
# tz 맞추기
|
||||
if df["timestamp"].dt.tz is not None:
|
||||
if ts_start.tz is None:
|
||||
ts_start = ts_start.tz_localize("UTC")
|
||||
if ts_end.tz is None:
|
||||
ts_end = ts_end.tz_localize("UTC")
|
||||
|
||||
mask = (df["timestamp"] >= ts_start) & (df["timestamp"] < ts_end)
|
||||
period_df = df[mask]
|
||||
|
||||
if period_df.empty:
|
||||
return None
|
||||
|
||||
return {
|
||||
"top_acct_avg": round(period_df["top_acct_ls_ratio"].mean(), 2),
|
||||
"global_avg": round(period_df["global_ls_ratio"].mean(), 2),
|
||||
}
|
||||
|
||||
|
||||
def calc_market_context(folds: list[dict], symbols: list[str]) -> list[dict]:
|
||||
"""각 폴드에 대한 시장 컨텍스트 계산."""
|
||||
# XRP parquet에서 BTC/ETH 데이터 로드 (임베딩됨)
|
||||
primary_sym = symbols[0].lower()
|
||||
raw_path = Path(f"data/{primary_sym}/combined_15m.parquet")
|
||||
if not raw_path.exists():
|
||||
logger.warning(f"데이터 파일 없음: {raw_path}")
|
||||
return []
|
||||
|
||||
raw_df = pd.read_parquet(raw_path)
|
||||
if "close_btc" not in raw_df.columns or "close_eth" not in raw_df.columns:
|
||||
logger.warning("BTC/ETH 상관 데이터 없음")
|
||||
return []
|
||||
|
||||
contexts = []
|
||||
for fold in folds:
|
||||
test_start = fold.get("test_start")
|
||||
test_end = fold.get("test_end")
|
||||
if not test_start or not test_end:
|
||||
contexts.append({"fold": fold["fold"], "market_context": None})
|
||||
continue
|
||||
|
||||
ctx = _calc_fold_market_context(raw_df, test_start, test_end)
|
||||
if ctx is None:
|
||||
contexts.append({"fold": fold["fold"], "market_context": None})
|
||||
continue
|
||||
|
||||
# L/S ratio (XRP, BTC, ETH)
|
||||
ls_data = {}
|
||||
for ls_sym in ["xrpusdt", "btcusdt", "ethusdt"]:
|
||||
ls = _load_ls_ratio(ls_sym, test_start, test_end)
|
||||
if ls:
|
||||
ls_data[ls_sym.replace("usdt", "")] = ls
|
||||
|
||||
ctx["ls_ratio"] = ls_data if ls_data else None
|
||||
contexts.append({"fold": fold["fold"], "market_context": ctx})
|
||||
|
||||
return contexts
|
||||
|
||||
|
||||
def print_market_context(contexts: list[dict]):
|
||||
"""시장 컨텍스트 테이블 출력."""
|
||||
if not contexts:
|
||||
return
|
||||
|
||||
# Market Regime 테이블
|
||||
print("\n📊 Market Context per Fold")
|
||||
print(f"{'─' * 80}")
|
||||
print(f" {'Fold':>4} {'BTC Return':>12} {'ETH Return':>12} {'Market Regime':<32}")
|
||||
print(f"{'─' * 80}")
|
||||
|
||||
for c in contexts:
|
||||
ctx = c.get("market_context")
|
||||
if ctx is None:
|
||||
print(f" {c['fold']:>4} {'N/A':>12} {'N/A':>12} {'N/A':<32}")
|
||||
else:
|
||||
regime_str = f"{ctx['market_regime']} (BTC ADX {ctx['btc_avg_adx']:.0f})"
|
||||
print(f" {c['fold']:>4} {ctx['btc_return_pct']:>+11.1f}% "
|
||||
f"{ctx['eth_return_pct']:>+11.1f}% {regime_str:<32}")
|
||||
print(f"{'─' * 80}")
|
||||
|
||||
# L/S Ratio 테이블 (데이터 있는 폴드가 하나라도 있으면)
|
||||
has_ls = any(
|
||||
c.get("market_context") and c["market_context"].get("ls_ratio")
|
||||
for c in contexts
|
||||
)
|
||||
|
||||
if has_ls:
|
||||
print("\n📊 L/S Ratio Context per Fold (period avg)")
|
||||
print(f"{'─' * 80}")
|
||||
print(f" {'Fold':>4} {'XRP Top/Global':>18} {'BTC Top/Global':>18} {'ETH Top/Global':>18}")
|
||||
print(f"{'─' * 80}")
|
||||
for c in contexts:
|
||||
ctx = c.get("market_context")
|
||||
ls = ctx.get("ls_ratio") if ctx else None
|
||||
parts = []
|
||||
for sym in ["xrp", "btc", "eth"]:
|
||||
if ls and sym in ls:
|
||||
parts.append(f"{ls[sym]['top_acct_avg']:.2f} / {ls[sym]['global_avg']:.2f}")
|
||||
else:
|
||||
parts.append("N/A")
|
||||
print(f" {c['fold']:>4} {parts[0]:>18} {parts[1]:>18} {parts[2]:>18}")
|
||||
print(f"{'─' * 80}")
|
||||
else:
|
||||
print(" ℹ️ L/S ratio 데이터 없음 — collector 데이터 축적 후 표시됩니다")
|
||||
|
||||
|
||||
def save_result(result: dict, cfg):
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
mode = result.get("mode", "standard")
|
||||
@@ -148,6 +320,164 @@ def save_result(result: dict, cfg):
|
||||
return path
|
||||
|
||||
|
||||
def compare_ml(symbols: list[str], args):
|
||||
"""ML on vs ML off Walk-Forward 백테스트 비교."""
|
||||
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"])
|
||||
# 시장 컨텍스트는 첫 번째 실행에서만 출력 (동일 데이터)
|
||||
if label == "ML OFF":
|
||||
contexts = calc_market_context(result["folds"], symbols)
|
||||
if contexts:
|
||||
print_market_context(contexts)
|
||||
|
||||
_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]
|
||||
if isinstance(obj, (np.integer,)):
|
||||
return int(obj)
|
||||
if isinstance(obj, (np.floating,)):
|
||||
return float(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)
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
@@ -156,6 +486,12 @@ def main():
|
||||
else:
|
||||
symbols = [s.strip().upper() for s in args.symbols.split(",") if s.strip()]
|
||||
|
||||
if args.compare_ml:
|
||||
if args.no_ml:
|
||||
logger.warning("--no-ml is ignored when using --compare-ml")
|
||||
compare_ml(symbols, args)
|
||||
return
|
||||
|
||||
if args.walk_forward:
|
||||
cfg = WalkForwardConfig(
|
||||
symbols=symbols,
|
||||
@@ -182,6 +518,12 @@ def main():
|
||||
print_summary(result["summary"], cfg, mode="walk_forward")
|
||||
if result.get("folds"):
|
||||
print_fold_table(result["folds"])
|
||||
contexts = calc_market_context(result["folds"], symbols)
|
||||
if contexts:
|
||||
print_market_context(contexts)
|
||||
# JSON에 market_context 추가
|
||||
for fold, ctx in zip(result["folds"], contexts):
|
||||
fold["market_context"] = ctx.get("market_context")
|
||||
save_result(result, cfg)
|
||||
else:
|
||||
cfg = BacktestConfig(
|
||||
|
||||
256
scripts/taker_ratio_analysis.py
Normal file
256
scripts/taker_ratio_analysis.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
Taker Buy/Sell Ratio vs Next-Candle Price Change Correlation Analysis
|
||||
- Taker Buy Ratio (from klines + Trading Data API)
|
||||
- Long/Short Ratio (global)
|
||||
- Top Trader Long/Short Ratio (accounts & positions)
|
||||
|
||||
Usage: python scripts/taker_ratio_analysis.py [SYMBOL1] [SYMBOL2] ...
|
||||
Default: XRPUSDT BTCUSDT ETHUSDT
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import sys
|
||||
|
||||
BASE = "https://fapi.binance.com"
|
||||
SYMBOLS = sys.argv[1:] if len(sys.argv) > 1 else ["XRPUSDT", "BTCUSDT", "ETHUSDT"]
|
||||
INTERVAL = "15m"
|
||||
DAYS = 30
|
||||
|
||||
async def fetch_json(session, url, params):
|
||||
async with session.get(url, params=params) as resp:
|
||||
return await resp.json()
|
||||
|
||||
async def fetch_klines(session, symbol, start_ms, end_ms):
|
||||
all_klines = []
|
||||
current = start_ms
|
||||
while current < end_ms:
|
||||
params = {"symbol": symbol, "interval": INTERVAL, "startTime": current, "endTime": end_ms, "limit": 1500}
|
||||
data = await fetch_json(session, f"{BASE}/fapi/v1/klines", params)
|
||||
if not data:
|
||||
break
|
||||
all_klines.extend(data)
|
||||
current = data[-1][0] + 1
|
||||
return all_klines
|
||||
|
||||
async def fetch_ratio(session, url, symbol):
|
||||
params = {"symbol": symbol, "period": INTERVAL, "limit": 500}
|
||||
data = await fetch_json(session, url, params)
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def analyze_symbol(session, symbol, start_ms, end_ms):
|
||||
"""Fetch and analyze a single symbol"""
|
||||
klines, ls_ratio, top_acct, top_pos, taker = await asyncio.gather(
|
||||
fetch_klines(session, symbol, start_ms, end_ms),
|
||||
fetch_ratio(session, f"{BASE}/futures/data/globalLongShortAccountRatio", symbol),
|
||||
fetch_ratio(session, f"{BASE}/futures/data/topLongShortAccountRatio", symbol),
|
||||
fetch_ratio(session, f"{BASE}/futures/data/topLongShortPositionRatio", symbol),
|
||||
fetch_ratio(session, f"{BASE}/futures/data/takerlongshortRatio", symbol),
|
||||
)
|
||||
|
||||
print(f"\n {symbol}: Klines={len(klines)}, L/S={len(ls_ratio)}, TopAcct={len(top_acct)}, TopPos={len(top_pos)}, Taker={len(taker)}")
|
||||
|
||||
# Build DataFrame
|
||||
df_k = pd.DataFrame(klines, columns=[
|
||||
"open_time","open","high","low","close","volume",
|
||||
"close_time","quote_vol","trades","taker_buy_vol","taker_buy_quote_vol","ignore"
|
||||
])
|
||||
df_k["open_time"] = pd.to_datetime(df_k["open_time"], unit="ms")
|
||||
for c in ["open","high","low","close","volume","taker_buy_vol","taker_buy_quote_vol","quote_vol"]:
|
||||
df_k[c] = df_k[c].astype(float)
|
||||
|
||||
df_k["kline_taker_buy_ratio"] = (df_k["taker_buy_vol"] / df_k["volume"]).replace([np.inf, -np.inf], np.nan)
|
||||
df_k["next_return"] = df_k["close"].shift(-1) / df_k["close"] - 1
|
||||
df_k["next_4_return"] = df_k["close"].shift(-4) / df_k["close"] - 1
|
||||
df_k = df_k.set_index("open_time")
|
||||
|
||||
def join_ratio(data, col_name):
|
||||
if not data:
|
||||
return
|
||||
df = pd.DataFrame(data)
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"].astype(int), unit="ms")
|
||||
if "buySellRatio" in df.columns:
|
||||
df["buySellRatio"] = df["buySellRatio"].astype(float)
|
||||
df["buyVol"] = df["buyVol"].astype(float)
|
||||
df["sellVol"] = df["sellVol"].astype(float)
|
||||
df = df.set_index("timestamp")
|
||||
df_k.update(df_k.join(df[["buySellRatio","buyVol","sellVol"]], how="left"))
|
||||
for c in ["buySellRatio","buyVol","sellVol"]:
|
||||
if c not in df_k.columns:
|
||||
df_k[c] = np.nan
|
||||
joined = df_k.join(df[["buySellRatio","buyVol","sellVol"]], how="left", rsuffix="_new")
|
||||
for c in ["buySellRatio","buyVol","sellVol"]:
|
||||
if f"{c}_new" in joined.columns:
|
||||
df_k[c] = joined[f"{c}_new"]
|
||||
else:
|
||||
df["longShortRatio"] = df["longShortRatio"].astype(float)
|
||||
df = df.set_index("timestamp").rename(columns={"longShortRatio": col_name})
|
||||
df_k[col_name] = df_k.join(df[[col_name]], how="left")[col_name]
|
||||
|
||||
join_ratio(taker, "buySellRatio")
|
||||
join_ratio(ls_ratio, "global_ls_ratio")
|
||||
join_ratio(top_acct, "top_acct_ls_ratio")
|
||||
join_ratio(top_pos, "top_pos_ls_ratio")
|
||||
|
||||
return df_k
|
||||
|
||||
def print_analysis(symbol, df_k):
|
||||
"""Print analysis results for a symbol"""
|
||||
print("\n" + "="*70)
|
||||
print(f"{symbol} {INTERVAL} Taker/Ratio → Price Correlation Analysis ({DAYS} days klines, ~5 days ratios)")
|
||||
print("="*70)
|
||||
|
||||
features = ["kline_taker_buy_ratio", "buySellRatio", "global_ls_ratio",
|
||||
"top_acct_ls_ratio", "top_pos_ls_ratio"]
|
||||
available = [f for f in features if f in df_k.columns and df_k[f].notna().sum() > 20]
|
||||
|
||||
# 1. Correlation
|
||||
print("\n[1] Pearson Correlation with Next-Candle Returns")
|
||||
print("-"*55)
|
||||
print(f"{'Feature':<25} {'next_15m':>12} {'next_1h':>12}")
|
||||
print("-"*55)
|
||||
for feat in available:
|
||||
c1 = df_k[feat].corr(df_k["next_return"])
|
||||
c4 = df_k[feat].corr(df_k["next_4_return"])
|
||||
print(f"{feat:<25} {c1:>12.4f} {c4:>12.4f}")
|
||||
|
||||
# 2. Quintile - Taker
|
||||
print("\n[2] Taker Buy Ratio Quintile → Next Returns")
|
||||
print("-"*60)
|
||||
for ratio_col in ["kline_taker_buy_ratio", "buySellRatio"]:
|
||||
if ratio_col not in available:
|
||||
continue
|
||||
valid = df_k[[ratio_col, "next_return", "next_4_return"]].dropna()
|
||||
try:
|
||||
valid["quintile"] = pd.qcut(valid[ratio_col], 5, labels=["Q1(sell)","Q2","Q3","Q4","Q5(buy)"])
|
||||
except ValueError:
|
||||
continue
|
||||
print(f"\n {ratio_col}:")
|
||||
print(f" {'Quintile':<12} {'mean_ratio':>12} {'next_15m_bps':>14} {'next_1h_bps':>13} {'count':>7} {'win_rate':>10}")
|
||||
for q in ["Q1(sell)","Q2","Q3","Q4","Q5(buy)"]:
|
||||
grp = valid[valid["quintile"] == q]
|
||||
if len(grp) == 0:
|
||||
continue
|
||||
mr = grp[ratio_col].mean()
|
||||
r1 = grp["next_return"].mean() * 10000
|
||||
r4 = grp["next_4_return"].mean() * 10000
|
||||
wr = (grp["next_return"] > 0).mean() * 100
|
||||
print(f" {q:<12} {mr:>12.4f} {r1:>14.2f} {r4:>13.2f} {len(grp):>7} {wr:>9.1f}%")
|
||||
|
||||
# 3. Extreme analysis
|
||||
print("\n[3] Extreme Taker Buy Ratio Analysis (top/bottom 10%)")
|
||||
print("-"*60)
|
||||
for ratio_col in ["kline_taker_buy_ratio", "buySellRatio"]:
|
||||
if ratio_col not in available:
|
||||
continue
|
||||
valid = df_k[[ratio_col, "next_return", "next_4_return"]].dropna()
|
||||
p10 = valid[ratio_col].quantile(0.10)
|
||||
p90 = valid[ratio_col].quantile(0.90)
|
||||
bottom = valid[valid[ratio_col] <= p10]
|
||||
top = valid[valid[ratio_col] >= p90]
|
||||
mid = valid[(valid[ratio_col] > p10) & (valid[ratio_col] < p90)]
|
||||
|
||||
print(f"\n {ratio_col}:")
|
||||
print(f" {'Group':<18} {'mean_ratio':>12} {'next_15m_bps':>14} {'next_1h_bps':>13} {'win_rate':>10} {'count':>7}")
|
||||
for name, grp in [("Bottom 10% (sell)", bottom), ("Middle 80%", mid), ("Top 10% (buy)", top)]:
|
||||
if len(grp) == 0:
|
||||
continue
|
||||
mr = grp[ratio_col].mean()
|
||||
r1 = grp["next_return"].mean() * 10000
|
||||
r4 = grp["next_4_return"].mean() * 10000
|
||||
wr = (grp["next_return"] > 0).mean() * 100
|
||||
print(f" {name:<18} {mr:>12.4f} {r1:>14.2f} {r4:>13.2f} {wr:>9.1f}% {len(grp):>7}")
|
||||
|
||||
# 4. L/S ratio quintile
|
||||
print("\n[4] Long/Short Ratio Quintile → Next Returns")
|
||||
print("-"*60)
|
||||
for ratio_col in ["global_ls_ratio", "top_acct_ls_ratio", "top_pos_ls_ratio"]:
|
||||
if ratio_col not in available:
|
||||
continue
|
||||
valid = df_k[[ratio_col, "next_return", "next_4_return"]].dropna()
|
||||
if len(valid) < 20:
|
||||
continue
|
||||
try:
|
||||
valid["quintile"] = pd.qcut(valid[ratio_col], 5, labels=["Q1(short)","Q2","Q3","Q4","Q5(long)"], duplicates="drop")
|
||||
except ValueError:
|
||||
continue
|
||||
print(f"\n {ratio_col}:")
|
||||
print(f" {'Quintile':<12} {'mean_ratio':>12} {'next_15m_bps':>14} {'next_1h_bps':>13} {'win_rate':>10} {'count':>7}")
|
||||
for q in valid["quintile"].cat.categories:
|
||||
grp = valid[valid["quintile"] == q]
|
||||
if len(grp) == 0:
|
||||
continue
|
||||
mr = grp[ratio_col].mean()
|
||||
r1 = grp["next_return"].mean() * 10000
|
||||
r4 = grp["next_4_return"].mean() * 10000
|
||||
wr = (grp["next_return"] > 0).mean() * 100
|
||||
print(f" {q:<12} {mr:>12.4f} {r1:>14.2f} {r4:>13.2f} {wr:>9.1f}% {len(grp):>7}")
|
||||
|
||||
# 5. Contrarian vs Momentum
|
||||
print("\n[5] Contrarian vs Momentum Signal Test")
|
||||
print("-"*60)
|
||||
for ratio_col, label in [("kline_taker_buy_ratio", "Taker Buy Ratio"),
|
||||
("global_ls_ratio", "Global L/S Ratio"),
|
||||
("top_acct_ls_ratio", "Top Trader Acct Ratio"),
|
||||
("top_pos_ls_ratio", "Top Trader Pos Ratio")]:
|
||||
if ratio_col not in available:
|
||||
continue
|
||||
valid = df_k[[ratio_col, "next_return", "next_4_return"]].dropna()
|
||||
median = valid[ratio_col].median()
|
||||
high = valid[valid[ratio_col] > median]
|
||||
low = valid[valid[ratio_col] <= median]
|
||||
h_wr = (high["next_return"] > 0).mean() * 100
|
||||
l_wr = (low["next_return"] > 0).mean() * 100
|
||||
h_r = high["next_return"].mean() * 10000
|
||||
l_r = low["next_return"].mean() * 10000
|
||||
signal = "Momentum" if h_r > l_r else "Contrarian"
|
||||
print(f"\n {label}:")
|
||||
print(f" Above median → next 15m: {h_r:+.2f} bps (win {h_wr:.1f}%)")
|
||||
print(f" Below median → next 15m: {l_r:+.2f} bps (win {l_wr:.1f}%)")
|
||||
print(f" → Signal type: {signal}")
|
||||
|
||||
# 6. Stats
|
||||
print("\n[6] Feature Statistics Summary")
|
||||
print("-"*60)
|
||||
for feat in available:
|
||||
s = df_k[feat].dropna()
|
||||
print(f" {feat}: mean={s.mean():.4f}, std={s.std():.4f}, min={s.min():.4f}, max={s.max():.4f}, n={len(s)}")
|
||||
print(f"\n Total klines: {len(df_k)}")
|
||||
print(f" Period: {df_k.index[0]} ~ {df_k.index[-1]}")
|
||||
|
||||
async def main():
|
||||
end_dt = datetime.now(timezone.utc)
|
||||
start_dt = end_dt - timedelta(days=DAYS)
|
||||
start_ms = int(start_dt.timestamp() * 1000)
|
||||
end_ms = int(end_dt.timestamp() * 1000)
|
||||
|
||||
print(f"Fetching {DAYS} days of {INTERVAL} data for {', '.join(SYMBOLS)}...")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
results = await asyncio.gather(
|
||||
*[analyze_symbol(session, sym, start_ms, end_ms) for sym in SYMBOLS]
|
||||
)
|
||||
|
||||
for sym, df in zip(SYMBOLS, results):
|
||||
print_analysis(sym, df)
|
||||
|
||||
# Cross-symbol comparison
|
||||
if len(SYMBOLS) > 1:
|
||||
print("\n" + "="*70)
|
||||
print("CROSS-SYMBOL COMPARISON SUMMARY")
|
||||
print("="*70)
|
||||
print(f"\n{'Symbol':<12} {'taker_buy→15m':>14} {'taker_buy→1h':>13} {'global_ls→1h':>13} {'top_acct→1h':>13} {'top_pos→1h':>12}")
|
||||
print("-"*78)
|
||||
for sym, df in zip(SYMBOLS, results):
|
||||
tb = df["kline_taker_buy_ratio"].corr(df["next_return"]) if "kline_taker_buy_ratio" in df.columns else float('nan')
|
||||
tb4 = df["kline_taker_buy_ratio"].corr(df["next_4_return"]) if "kline_taker_buy_ratio" in df.columns else float('nan')
|
||||
gl = df["global_ls_ratio"].corr(df["next_4_return"]) if "global_ls_ratio" in df.columns and df["global_ls_ratio"].notna().sum() > 20 else float('nan')
|
||||
ta = df["top_acct_ls_ratio"].corr(df["next_4_return"]) if "top_acct_ls_ratio" in df.columns and df["top_acct_ls_ratio"].notna().sum() > 20 else float('nan')
|
||||
tp = df["top_pos_ls_ratio"].corr(df["next_4_return"]) if "top_pos_ls_ratio" in df.columns and df["top_pos_ls_ratio"].notna().sum() > 20 else float('nan')
|
||||
print(f"{sym:<12} {tb:>14.4f} {tb4:>13.4f} {gl:>13.4f} {ta:>13.4f} {tp:>12.4f}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -17,7 +17,7 @@ import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.metrics import roc_auc_score, classification_report
|
||||
|
||||
from src.dataset_builder import generate_dataset_vectorized
|
||||
from src.dataset_builder import generate_dataset_vectorized, stratified_undersample
|
||||
from src.ml_features import FEATURE_COLS
|
||||
from src.mlx_filter import MLXFilter
|
||||
|
||||
@@ -45,7 +45,7 @@ def _split_combined(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame | None
|
||||
return xrp_df, btc_df, eth_df
|
||||
|
||||
|
||||
def train_mlx(data_path: str, time_weight_decay: float = 2.0) -> float:
|
||||
def train_mlx(data_path: str, time_weight_decay: float = 2.0, atr_sl_mult: float = 2.0, atr_tp_mult: float = 2.0) -> float:
|
||||
print(f"데이터 로드: {data_path}")
|
||||
raw = pd.read_parquet(data_path)
|
||||
print(f"캔들 수: {len(raw)}")
|
||||
@@ -58,7 +58,8 @@ def train_mlx(data_path: str, time_weight_decay: float = 2.0) -> float:
|
||||
|
||||
print("\n데이터셋 생성 중...")
|
||||
t0 = time.perf_counter()
|
||||
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=time_weight_decay)
|
||||
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)
|
||||
t1 = time.perf_counter()
|
||||
print(f"데이터셋 생성 완료: {t1 - t0:.1f}초, {len(dataset)}개 샘플")
|
||||
|
||||
@@ -85,16 +86,10 @@ def train_mlx(data_path: str, time_weight_decay: float = 2.0) -> float:
|
||||
y_train, y_val = y.iloc[:split], y.iloc[split:]
|
||||
w_train = w[:split]
|
||||
|
||||
# --- 클래스 불균형 처리: 언더샘플링 (가중치 인덱스 보존) ---
|
||||
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)
|
||||
# --- 클래스 불균형 처리: stratified 언더샘플링 (Signal 전수 유지, HOLD만 샘플링) ---
|
||||
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)
|
||||
|
||||
X_train = X_train.iloc[balanced_idx]
|
||||
y_train = y_train.iloc[balanced_idx]
|
||||
@@ -170,6 +165,8 @@ def walk_forward_auc(
|
||||
time_weight_decay: float = 2.0,
|
||||
n_splits: int = 5,
|
||||
train_ratio: float = 0.6,
|
||||
atr_sl_mult: float = 2.0,
|
||||
atr_tp_mult: float = 2.0,
|
||||
) -> None:
|
||||
"""Walk-Forward 검증: 슬라이딩 윈도우로 n_splits번 학습/검증 반복."""
|
||||
print(f"\n=== Walk-Forward 검증 ({n_splits}폴드, decay={time_weight_decay}) ===")
|
||||
@@ -177,7 +174,8 @@ def walk_forward_auc(
|
||||
df, btc_df, eth_df = _split_combined(raw)
|
||||
|
||||
dataset = generate_dataset_vectorized(
|
||||
df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=time_weight_decay
|
||||
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,
|
||||
)
|
||||
missing = [c for c in FEATURE_COLS if c not in dataset.columns]
|
||||
for col in missing:
|
||||
@@ -186,46 +184,37 @@ def walk_forward_auc(
|
||||
X_all = dataset[FEATURE_COLS].values.astype(np.float32)
|
||||
y_all = dataset["label"].values.astype(np.float32)
|
||||
w_all = dataset["sample_weight"].values.astype(np.float32)
|
||||
source_all = dataset["source"].values if "source" in dataset.columns else np.full(len(dataset), "signal")
|
||||
n = len(dataset)
|
||||
|
||||
step = max(1, int(n * (1 - train_ratio) / n_splits))
|
||||
train_end_start = int(n * train_ratio)
|
||||
|
||||
aucs = []
|
||||
from src.dataset_builder import LOOKAHEAD
|
||||
|
||||
for i in range(n_splits):
|
||||
tr_end = train_end_start + i * step
|
||||
val_end = tr_end + step
|
||||
val_start = tr_end + LOOKAHEAD # purged gap
|
||||
val_end = val_start + step
|
||||
if val_end > n:
|
||||
break
|
||||
|
||||
X_tr_raw = X_all[:tr_end]
|
||||
y_tr = y_all[:tr_end]
|
||||
w_tr = w_all[:tr_end]
|
||||
X_val_raw = X_all[tr_end:val_end]
|
||||
y_val = y_all[tr_end:val_end]
|
||||
X_val_raw = X_all[val_start:val_end]
|
||||
y_val = y_all[val_start:val_end]
|
||||
|
||||
pos_idx = np.where(y_tr == 1)[0]
|
||||
neg_idx = np.where(y_tr == 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)
|
||||
bal_idx = np.sort(np.concatenate([pos_idx, neg_idx]))
|
||||
source_tr = source_all[:tr_end]
|
||||
bal_idx = stratified_undersample(y_tr, source_tr, seed=42)
|
||||
|
||||
X_tr_bal = X_tr_raw[bal_idx]
|
||||
y_tr_bal = y_tr[bal_idx]
|
||||
w_tr_bal = w_tr[bal_idx]
|
||||
|
||||
# 폴드별 정규화 (학습 데이터 기준으로 계산, 검증에도 동일 적용)
|
||||
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
|
||||
|
||||
# DataFrame으로 래핑해서 MLXFilter.fit()에 전달
|
||||
# fit() 내부 정규화가 덮어쓰지 않도록 이미 정규화된 데이터를 넘기고
|
||||
# _mean=0, _std=1로 고정해 이중 정규화를 방지
|
||||
X_tr_df = pd.DataFrame(X_tr_norm, columns=FEATURE_COLS)
|
||||
X_val_df = pd.DataFrame(X_val_norm, columns=FEATURE_COLS)
|
||||
X_tr_df = pd.DataFrame(X_tr_bal, columns=FEATURE_COLS)
|
||||
X_val_df = pd.DataFrame(X_val_raw, columns=FEATURE_COLS)
|
||||
|
||||
model = MLXFilter(
|
||||
input_dim=len(FEATURE_COLS),
|
||||
@@ -235,16 +224,13 @@ def walk_forward_auc(
|
||||
batch_size=256,
|
||||
)
|
||||
model.fit(X_tr_df, pd.Series(y_tr_bal), sample_weight=w_tr_bal)
|
||||
# fit()이 내부에서 다시 정규화하므로 저장된 mean/std를 항등 변환으로 교체
|
||||
model._mean = np.zeros(len(FEATURE_COLS), dtype=np.float32)
|
||||
model._std = np.ones(len(FEATURE_COLS), dtype=np.float32)
|
||||
|
||||
proba = model.predict_proba(X_val_df)
|
||||
auc = roc_auc_score(y_val, proba) if len(np.unique(y_val)) > 1 else 0.5
|
||||
aucs.append(auc)
|
||||
print(
|
||||
f" 폴드 {i+1}/{n_splits}: 학습={tr_end}개, "
|
||||
f"검증={tr_end}~{val_end} ({step}개), AUC={auc:.4f}"
|
||||
f"검증={val_start}~{val_end} ({step}개, embargo={LOOKAHEAD}), AUC={auc:.4f}"
|
||||
)
|
||||
|
||||
print(f"\n Walk-Forward 평균 AUC: {np.mean(aucs):.4f} ± {np.std(aucs):.4f}")
|
||||
@@ -260,12 +246,16 @@ def main():
|
||||
)
|
||||
parser.add_argument("--wf", action="store_true", help="Walk-Forward 검증 실행")
|
||||
parser.add_argument("--wf-splits", type=int, default=5, help="Walk-Forward 폴드 수")
|
||||
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)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.wf:
|
||||
walk_forward_auc(args.data, time_weight_decay=args.decay, n_splits=args.wf_splits)
|
||||
walk_forward_auc(args.data, time_weight_decay=args.decay, n_splits=args.wf_splits,
|
||||
atr_sl_mult=args.sl_mult, atr_tp_mult=args.tp_mult)
|
||||
else:
|
||||
train_mlx(args.data, time_weight_decay=args.decay)
|
||||
train_mlx(args.data, time_weight_decay=args.decay,
|
||||
atr_sl_mult=args.sl_mult, atr_tp_mult=args.tp_mult)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -9,6 +9,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import warnings
|
||||
from datetime import datetime
|
||||
from multiprocessing import Pool, cpu_count
|
||||
from pathlib import Path
|
||||
@@ -54,8 +55,6 @@ def _cgroup_cpu_count() -> int:
|
||||
|
||||
|
||||
LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 (dataset_builder.py와 동기화)
|
||||
ATR_SL_MULT = 1.5
|
||||
ATR_TP_MULT = 3.0
|
||||
MODEL_PATH = Path("models/lgbm_filter.pkl")
|
||||
PREV_MODEL_PATH = Path("models/lgbm_filter_prev.pkl")
|
||||
LOG_PATH = Path("models/training_log.json")
|
||||
@@ -63,6 +62,8 @@ LOG_PATH = Path("models/training_log.json")
|
||||
|
||||
def _process_index(args: tuple) -> dict | None:
|
||||
"""단일 인덱스에 대해 피처+레이블을 계산한다. Pool worker 함수."""
|
||||
ATR_SL_MULT = 1.5 # legacy values
|
||||
ATR_TP_MULT = 3.0
|
||||
i, df_values, df_columns = args
|
||||
df = pd.DataFrame(df_values, columns=df_columns)
|
||||
|
||||
@@ -73,7 +74,7 @@ def _process_index(args: tuple) -> dict | None:
|
||||
if df_ind.iloc[-1].isna().any():
|
||||
return None
|
||||
|
||||
signal = ind.get_signal(df_ind)
|
||||
signal, _ = ind.get_signal(df_ind)
|
||||
if signal == "HOLD":
|
||||
return None
|
||||
|
||||
@@ -104,7 +105,11 @@ def _process_index(args: tuple) -> dict | None:
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
total = len(df)
|
||||
indices = range(60, total - LOOKAHEAD)
|
||||
|
||||
@@ -191,7 +196,7 @@ def _load_lgbm_params(tuned_params_path: str | None) -> tuple[dict, float]:
|
||||
return lgbm_params, weight_scale
|
||||
|
||||
|
||||
def train(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str | None = None):
|
||||
def train(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str | None = None, atr_sl_mult: float = 2.0, atr_tp_mult: float = 2.0):
|
||||
print(f"데이터 로드: {data_path}")
|
||||
df_raw = pd.read_parquet(data_path)
|
||||
print(f"캔들 수: {len(df_raw)}, 컬럼: {list(df_raw.columns)}")
|
||||
@@ -217,7 +222,9 @@ def train(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str
|
||||
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,
|
||||
)
|
||||
|
||||
if dataset.empty or "label" not in dataset.columns:
|
||||
@@ -335,6 +342,8 @@ def walk_forward_auc(
|
||||
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:
|
||||
"""Walk-Forward 검증: 슬라이딩 윈도우로 n_splits번 학습/검증 반복.
|
||||
|
||||
@@ -358,7 +367,9 @@ def walk_forward_auc(
|
||||
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,
|
||||
)
|
||||
actual_feature_cols = [c for c in FEATURE_COLS if c in dataset.columns]
|
||||
X = dataset[actual_feature_cols].values
|
||||
@@ -375,14 +386,17 @@ def walk_forward_auc(
|
||||
|
||||
aucs = []
|
||||
fold_metrics = []
|
||||
from src.dataset_builder import LOOKAHEAD
|
||||
|
||||
for i in range(n_splits):
|
||||
tr_end = train_end_start + i * step
|
||||
val_end = tr_end + 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[tr_end:val_end], y[tr_end:val_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)
|
||||
@@ -410,7 +424,7 @@ def walk_forward_auc(
|
||||
fold_metrics.append({"auc": auc, "precision": f_prec, "recall": f_rec, "threshold": f_thr})
|
||||
print(
|
||||
f" 폴드 {i+1}/{n_splits}: 학습={tr_end}개, "
|
||||
f"검증={tr_end}~{val_end} ({step}개), AUC={auc:.4f} | "
|
||||
f"검증={val_start}~{val_end} ({step}개, embargo={LOOKAHEAD}), AUC={auc:.4f} | "
|
||||
f"Thr={f_thr:.4f} Prec={f_prec:.3f} Rec={f_rec:.3f}"
|
||||
)
|
||||
|
||||
@@ -422,7 +436,7 @@ def walk_forward_auc(
|
||||
print(f" 폴드별: {[round(a, 4) for a in aucs]}")
|
||||
|
||||
|
||||
def compare(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str | None = None):
|
||||
def compare(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: str | None = None, atr_sl_mult: float = 2.0, atr_tp_mult: float = 2.0):
|
||||
"""기존 피처 vs OI 파생 피처 추가 버전 A/B 비교."""
|
||||
import warnings
|
||||
|
||||
@@ -448,7 +462,9 @@ def compare(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: s
|
||||
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,
|
||||
)
|
||||
|
||||
if dataset.empty:
|
||||
@@ -529,6 +545,135 @@ def compare(data_path: str, time_weight_decay: float = 2.0, tuned_params_path: s
|
||||
print(f" {feat_name:<25} {imp_val:>6}{marker}")
|
||||
|
||||
|
||||
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 제거
|
||||
"""
|
||||
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]}")
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data", default=None)
|
||||
@@ -544,8 +689,12 @@ def main():
|
||||
"--tuned-params", type=str, default=None,
|
||||
help="Optuna 튜닝 결과 JSON 경로 (지정 시 기본 파라미터를 덮어씀)",
|
||||
)
|
||||
parser.add_argument("--ablation", action="store_true",
|
||||
help="Feature ablation 실험 (signal_strength/side 의존도 진단)")
|
||||
parser.add_argument("--compare", action="store_true",
|
||||
help="OI 파생 피처 추가 전후 A/B 성능 비교")
|
||||
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)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# --symbol 모드: 심볼별 디렉토리 경로 자동 결정
|
||||
@@ -562,17 +711,27 @@ def main():
|
||||
elif args.data is None:
|
||||
args.data = "data/combined_15m.parquet"
|
||||
|
||||
if args.compare:
|
||||
compare(args.data, time_weight_decay=args.decay, tuned_params_path=args.tuned_params)
|
||||
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:
|
||||
compare(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.wf:
|
||||
walk_forward_auc(
|
||||
args.data,
|
||||
time_weight_decay=args.decay,
|
||||
n_splits=args.wf_splits,
|
||||
tuned_params_path=args.tuned_params,
|
||||
atr_sl_mult=args.sl_mult,
|
||||
atr_tp_mult=args.tp_mult,
|
||||
)
|
||||
else:
|
||||
train(args.data, time_weight_decay=args.decay, tuned_params_path=args.tuned_params)
|
||||
train(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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -39,7 +39,7 @@ from src.dataset_builder import generate_dataset_vectorized, stratified_undersam
|
||||
# 데이터 로드 및 데이터셋 생성 (1회 캐싱)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
def load_dataset(data_path: str, atr_sl_mult: float = 2.0, atr_tp_mult: float = 2.0) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""
|
||||
parquet 로드 → 벡터화 데이터셋 생성 → (X, y, w) numpy 배열 반환.
|
||||
study 시작 전 1회만 호출하여 모든 trial이 공유한다.
|
||||
@@ -64,7 +64,8 @@ def load_dataset(data_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray, np
|
||||
df = df_raw[base_cols].copy()
|
||||
|
||||
print("\n데이터셋 생성 중 (1회만 실행)...")
|
||||
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=0.0, negative_ratio=5)
|
||||
dataset = generate_dataset_vectorized(df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=0.0,
|
||||
atr_sl_mult=atr_sl_mult, atr_tp_mult=atr_tp_mult)
|
||||
|
||||
if dataset.empty or "label" not in dataset.columns:
|
||||
raise ValueError("데이터셋 생성 실패: 샘플 0개")
|
||||
@@ -149,14 +150,17 @@ def _walk_forward_cv(
|
||||
fold_n_pos: list[int] = []
|
||||
scores_so_far: list[float] = []
|
||||
|
||||
from src.dataset_builder import LOOKAHEAD
|
||||
|
||||
for fold_idx in range(n_splits):
|
||||
tr_end = train_end_start + fold_idx * step
|
||||
val_end = tr_end + 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[tr_end:val_end], y[tr_end:val_end]
|
||||
X_val, y_val = X[val_start:val_end], y[val_start:val_end]
|
||||
|
||||
# 계층적 샘플링: signal 전수 유지, HOLD negative만 양성 수 만큼
|
||||
source_tr = source[:tr_end]
|
||||
@@ -527,6 +531,8 @@ def main():
|
||||
parser.add_argument("--train-ratio", type=float, default=0.6, help="학습 구간 비율 (기본: 0.6)")
|
||||
parser.add_argument("--min-recall", type=float, default=0.35, help="최소 재현율 제약 (기본: 0.35)")
|
||||
parser.add_argument("--no-baseline", action="store_true", help="베이스라인 측정 건너뜀")
|
||||
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)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# --symbol 모드: 심볼별 디렉토리 경로 자동 결정
|
||||
@@ -538,7 +544,7 @@ def main():
|
||||
args.data = "data/combined_15m.parquet"
|
||||
|
||||
# 1. 데이터셋 로드 (1회)
|
||||
X, y, w, source = load_dataset(args.data)
|
||||
X, y, w, source = load_dataset(args.data, atr_sl_mult=args.sl_mult, atr_tp_mult=args.tp_mult)
|
||||
|
||||
# 2. 베이스라인 측정
|
||||
if args.symbol:
|
||||
|
||||
230
scripts/verify_prod_api.py
Normal file
230
scripts/verify_prod_api.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""실전 API SL/TP 콜백 검증 스크립트.
|
||||
|
||||
검증 항목:
|
||||
1. SL/TP 주문 응답에 orderId vs algoId 확인
|
||||
2. SL 트리거 시 UDS 콜백의 o, ot 필드 값
|
||||
3. futures_cancel_order(orderId=...)로 TP 취소 가능 여부
|
||||
|
||||
사용법:
|
||||
1. 바이낸스 앱/웹에서 XRPUSDT 소액 LONG 포지션 수동 진입
|
||||
2. python scripts/verify_prod_api.py 실행
|
||||
→ 자동으로 SL/TP 배치 + UDS 리스닝
|
||||
3. SL이 트리거되면 콜백 로그 확인 + TP 자동 취소 시도
|
||||
|
||||
환경변수: BINANCE_API_KEY, BINANCE_API_SECRET (실전 키)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from binance import AsyncClient, BinanceSocketManager
|
||||
from loguru import logger
|
||||
|
||||
from src.exchange import BinanceFuturesClient
|
||||
from src.config import Config
|
||||
|
||||
# .env에서 실전 키 로드 (BINANCE_TESTNET이 설정되어 있으면 해제)
|
||||
os.environ.pop("BINANCE_TESTNET", None)
|
||||
load_dotenv()
|
||||
|
||||
SYMBOL = "XRPUSDT"
|
||||
|
||||
|
||||
async def main():
|
||||
api_key = os.getenv("BINANCE_API_KEY", "")
|
||||
api_secret = os.getenv("BINANCE_API_SECRET", "")
|
||||
|
||||
if not api_key or not api_secret:
|
||||
logger.error("BINANCE_API_KEY / BINANCE_API_SECRET 환경변수 필요")
|
||||
return
|
||||
|
||||
# Exchange 클라이언트 (실전)
|
||||
config = Config()
|
||||
config.testnet = False
|
||||
config.api_key = api_key
|
||||
config.api_secret = api_secret
|
||||
config.symbol = SYMBOL
|
||||
|
||||
exchange = BinanceFuturesClient(config, symbol=SYMBOL)
|
||||
|
||||
# ── Step 1: 현재 포지션 확인 ──
|
||||
position = await exchange.get_position()
|
||||
if position is None:
|
||||
logger.error(
|
||||
f"[{SYMBOL}] 포지션 없음. 먼저 바이낸스 앱/웹에서 소액 포지션을 수동으로 진입하세요."
|
||||
)
|
||||
return
|
||||
|
||||
pos_amt = float(position["positionAmt"])
|
||||
entry_price = float(position["entryPrice"])
|
||||
mark_price = float(position.get("markPrice", entry_price))
|
||||
side = "LONG" if pos_amt > 0 else "SHORT"
|
||||
quantity = abs(pos_amt)
|
||||
|
||||
logger.info(f"[{SYMBOL}] 포지션 확인: {side} qty={quantity}, entry={entry_price}, mark={mark_price}")
|
||||
|
||||
# ── Step 2: 기존 오픈 주문 확인/정리 ──
|
||||
open_orders = await exchange.get_open_orders()
|
||||
if open_orders:
|
||||
logger.info(f"[{SYMBOL}] 기존 오픈 주문 {len(open_orders)}개 — 전체 취소")
|
||||
await exchange.cancel_all_orders()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# ── Step 3: SL/TP 주문 배치 (현재가 기준 가까운 값) ──
|
||||
# SL: 현재가에서 0.15% 떨어진 곳 (빨리 트리거되도록)
|
||||
# TP: 현재가에서 2% 떨어진 곳 (트리거 안 되도록)
|
||||
sl_side = "SELL" if side == "LONG" else "BUY"
|
||||
|
||||
if side == "LONG":
|
||||
stop_loss = exchange._round_price(mark_price * 0.9985) # -0.15%
|
||||
take_profit = exchange._round_price(mark_price * 1.02) # +2%
|
||||
else:
|
||||
stop_loss = exchange._round_price(mark_price * 1.0015) # +0.15%
|
||||
take_profit = exchange._round_price(mark_price * 0.98) # -2%
|
||||
|
||||
logger.info(f"[{SYMBOL}] SL/TP 배치 예정: SL={stop_loss}, TP={take_profit}, side={sl_side}")
|
||||
|
||||
# SL 배치
|
||||
sl_result = await exchange.place_order(
|
||||
side=sl_side,
|
||||
quantity=quantity,
|
||||
order_type="STOP_MARKET",
|
||||
stop_price=stop_loss,
|
||||
reduce_only=True,
|
||||
)
|
||||
logger.success(f"[검증1] SL 주문 응답 전체:\n{json.dumps(sl_result, indent=2)}")
|
||||
sl_order_id = sl_result.get("orderId")
|
||||
sl_algo_id = sl_result.get("algoId")
|
||||
logger.info(f" → orderId={sl_order_id}, algoId={sl_algo_id}")
|
||||
|
||||
# TP 배치
|
||||
tp_result = await exchange.place_order(
|
||||
side=sl_side,
|
||||
quantity=quantity,
|
||||
order_type="TAKE_PROFIT_MARKET",
|
||||
stop_price=take_profit,
|
||||
reduce_only=True,
|
||||
)
|
||||
logger.success(f"[검증1] TP 주문 응답 전체:\n{json.dumps(tp_result, indent=2)}")
|
||||
tp_order_id = tp_result.get("orderId")
|
||||
tp_algo_id = tp_result.get("algoId")
|
||||
logger.info(f" → orderId={tp_order_id}, algoId={tp_algo_id}")
|
||||
|
||||
# ── Step 4: UDS 리스닝 — SL 트리거 대기 ──
|
||||
logger.info(f"[{SYMBOL}] UDS 리스닝 시작 — SL 트리거 대기 중 (mark={mark_price}, SL={stop_loss})")
|
||||
logger.info(" SL이 트리거되면 자동으로 TP 취소를 시도합니다.")
|
||||
logger.info(" Ctrl+C로 중단 가능 (중단 시 잔여 주문 정리)")
|
||||
|
||||
sl_triggered = asyncio.Event()
|
||||
|
||||
async def on_uds_message(msg: dict):
|
||||
if msg.get("e") != "ORDER_TRADE_UPDATE":
|
||||
return
|
||||
|
||||
order = msg.get("o", {})
|
||||
if order.get("s") != SYMBOL:
|
||||
return
|
||||
|
||||
# 모든 이벤트 원본 로깅
|
||||
logger.info(
|
||||
f"[검증2] UDS 원본: "
|
||||
f"s={order.get('s')} "
|
||||
f"o={order.get('o')} "
|
||||
f"ot={order.get('ot')} "
|
||||
f"x={order.get('x')} "
|
||||
f"X={order.get('X')} "
|
||||
f"R={order.get('R')} "
|
||||
f"S={order.get('S')} "
|
||||
f"i={order.get('i')} "
|
||||
f"ap={order.get('ap')} "
|
||||
f"rp={order.get('rp')} "
|
||||
f"n={order.get('n')}"
|
||||
)
|
||||
|
||||
# FILLED된 SL 감지
|
||||
if order.get("x") == "TRADE" and order.get("X") == "FILLED":
|
||||
ot = order.get("ot", "")
|
||||
if ot == "STOP_MARKET":
|
||||
logger.success(
|
||||
f"[검증2] SL FILLED 확인! "
|
||||
f"o={order.get('o')}, ot={ot}, "
|
||||
f"orderId={order.get('i')}, "
|
||||
f"exit_price={order.get('ap')}, rp={order.get('rp')}"
|
||||
)
|
||||
sl_triggered.set()
|
||||
|
||||
# UDS 연결
|
||||
client = await AsyncClient.create(
|
||||
api_key=api_key,
|
||||
api_secret=api_secret,
|
||||
)
|
||||
|
||||
try:
|
||||
bm = BinanceSocketManager(client)
|
||||
async with bm.futures_user_socket() as stream:
|
||||
logger.info("UDS 연결 완료")
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(stream.recv(), timeout=1.0)
|
||||
await on_uds_message(msg)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
if sl_triggered.is_set():
|
||||
break
|
||||
|
||||
# ── Step 5: TP 취소 검증 ──
|
||||
cancel_id = tp_order_id or tp_algo_id
|
||||
logger.info(f"[검증3] TP 취소 시도: futures_cancel_order(orderId={cancel_id})")
|
||||
|
||||
try:
|
||||
cancel_result = await exchange.cancel_order(cancel_id)
|
||||
logger.success(f"[검증3] TP 취소 성공:\n{json.dumps(cancel_result, indent=2)}")
|
||||
except Exception as e:
|
||||
logger.error(f"[검증3] TP 취소 실패: {e}")
|
||||
|
||||
# cancel_all_orders 폴백
|
||||
logger.info("[검증3] cancel_all_orders 폴백 시도")
|
||||
try:
|
||||
fallback_result = await exchange.cancel_all_orders()
|
||||
logger.success(f"[검증3] cancel_all_orders 결과: {fallback_result}")
|
||||
except Exception as e2:
|
||||
logger.error(f"[검증3] cancel_all_orders도 실패: {e2}")
|
||||
|
||||
# 최종 오픈 주문 확인
|
||||
remaining = await exchange.get_open_orders()
|
||||
if remaining:
|
||||
logger.warning(f"[검증3] 잔여 오픈 주문 {len(remaining)}개:")
|
||||
for o in remaining:
|
||||
logger.warning(f" id={o.get('orderId')}, type={o.get('type')}, status={o.get('status')}")
|
||||
else:
|
||||
logger.success("[검증3] 잔여 오픈 주문 없음 — 고아주문 없음 확인!")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("중단 — 잔여 주문 정리 중...")
|
||||
try:
|
||||
await exchange.cancel_all_orders()
|
||||
logger.info("잔여 주문 전체 취소 완료")
|
||||
except Exception as e:
|
||||
logger.warning(f"잔여 주문 취소 실패: {e}")
|
||||
finally:
|
||||
await client.close_connection()
|
||||
|
||||
# ── 결과 요약 ──
|
||||
logger.info("=" * 60)
|
||||
logger.info("검증 결과 요약")
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"[1] SL orderId={sl_order_id}, algoId={sl_algo_id}")
|
||||
logger.info(f"[1] TP orderId={tp_order_id}, algoId={tp_algo_id}")
|
||||
logger.info(f"[2] SL 트리거 감지: {'YES' if sl_triggered.is_set() else 'NO (타임아웃/중단)'}")
|
||||
logger.info(f"[3] 위 로그에서 TP 취소 성공 여부 확인")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -14,23 +14,26 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import date, timedelta
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
|
||||
import pandas as pd
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from src.backtester import WalkForwardBacktester, WalkForwardConfig
|
||||
from src.notifier import DiscordNotifier
|
||||
|
||||
|
||||
# ── 프로덕션 파라미터 ──────────────────────────────────────────────
|
||||
SYMBOLS = ["XRPUSDT", "TRXUSDT", "DOGEUSDT"]
|
||||
SYMBOLS = ["XRPUSDT"]
|
||||
PROD_PARAMS = {
|
||||
"atr_sl_mult": 2.0,
|
||||
"atr_tp_mult": 2.0,
|
||||
"atr_sl_mult": 1.5,
|
||||
"atr_tp_mult": 4.0,
|
||||
"signal_threshold": 3,
|
||||
"adx_threshold": 25,
|
||||
"volume_multiplier": 2.5,
|
||||
@@ -76,55 +79,30 @@ def run_backtest(
|
||||
return wf.run()
|
||||
|
||||
|
||||
# ── 로그 파싱 패턴 ────────────────────────────────────────────────
|
||||
_RE_ENTRY = re.compile(
|
||||
r"\[(\w+)\]\s+(LONG|SHORT)\s+진입:\s+가격=([\d.]+),\s+수량=([\d.]+),\s+SL=([\d.]+),\s+TP=([\d.]+)"
|
||||
)
|
||||
_RE_CLOSE = re.compile(
|
||||
r"\[(\w+)\]\s+청산 감지\((\w+)\):\s+exit=([\d.]+),\s+rp=([\d.-]+),\s+commission=([\d.]+),\s+net_pnl=([\d.-]+)"
|
||||
)
|
||||
_RE_TIMESTAMP = re.compile(r"^(\d{4}-\d{2}-\d{2})\s")
|
||||
# ── 대시보드 API에서 실전 트레이드 가져오기 ──────────────────────────
|
||||
DASHBOARD_API_URL = os.getenv("DASHBOARD_API_URL", "http://10.1.10.24:8000")
|
||||
|
||||
|
||||
def parse_live_trades(log_path: str, days: int = 7) -> list[dict]:
|
||||
"""봇 로그에서 최근 N일간의 진입/청산 기록을 파싱한다."""
|
||||
path = Path(log_path)
|
||||
if not path.exists():
|
||||
def fetch_live_trades(api_url: str = DASHBOARD_API_URL, limit: int = 500) -> list[dict]:
|
||||
"""운영 LXC 대시보드 API에서 청산된 트레이드 내역을 가져온다."""
|
||||
try:
|
||||
resp = httpx.get(f"{api_url}/api/trades", params={"limit": limit}, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("trades", [])
|
||||
except Exception as e:
|
||||
logger.warning(f"대시보드 API 트레이드 조회 실패: {e}")
|
||||
return []
|
||||
|
||||
cutoff = (date.today() - timedelta(days=days)).isoformat()
|
||||
open_trades: dict[str, dict] = {}
|
||||
closed_trades: list[dict] = []
|
||||
|
||||
for line in path.read_text().splitlines():
|
||||
m_ts = _RE_TIMESTAMP.match(line)
|
||||
if m_ts and m_ts.group(1) < cutoff:
|
||||
continue
|
||||
|
||||
m = _RE_ENTRY.search(line)
|
||||
if m:
|
||||
sym, side, price, qty, sl, tp = m.groups()
|
||||
open_trades[sym] = {
|
||||
"symbol": sym, "side": side,
|
||||
"entry_price": float(price), "quantity": float(qty),
|
||||
"sl": float(sl), "tp": float(tp),
|
||||
"entry_time": m_ts.group(1) if m_ts else "",
|
||||
}
|
||||
continue
|
||||
|
||||
m = _RE_CLOSE.search(line)
|
||||
if m:
|
||||
sym, reason, exit_price, rp, commission, net_pnl = m.groups()
|
||||
trade = open_trades.pop(sym, {"symbol": sym, "side": "UNKNOWN"})
|
||||
trade.update({
|
||||
"close_reason": reason, "exit_price": float(exit_price),
|
||||
"expected_pnl": float(rp), "commission": float(commission),
|
||||
"net_pnl": float(net_pnl),
|
||||
"exit_time": m_ts.group(1) if m_ts else "",
|
||||
})
|
||||
closed_trades.append(trade)
|
||||
|
||||
return closed_trades
|
||||
def fetch_live_stats(api_url: str = DASHBOARD_API_URL) -> dict:
|
||||
"""운영 LXC 대시보드 API에서 전체 통계를 가져온다."""
|
||||
try:
|
||||
resp = httpx.get(f"{api_url}/api/stats", timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"대시보드 API 통계 조회 실패: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
# ── 추이 추적 ────────────────────────────────────────────────────
|
||||
@@ -220,6 +198,60 @@ def run_degradation_sweep(
|
||||
return results[:top_n]
|
||||
|
||||
|
||||
# ── 킬스위치 모니터링 ──────────────────────────────────────────────
|
||||
_KILL_HISTORY_DIR = Path("data/trade_history")
|
||||
_FAST_KILL_STREAK = 8
|
||||
_SLOW_KILL_WINDOW = 15
|
||||
_SLOW_KILL_PF_THRESHOLD = 0.75
|
||||
|
||||
|
||||
def load_kill_switch_status(symbols: list[str]) -> dict[str, dict]:
|
||||
"""심볼별 킬스위치 지표를 거래 이력 파일에서 산출한다."""
|
||||
result = {}
|
||||
for sym in symbols:
|
||||
path = _KILL_HISTORY_DIR / f"{sym.lower()}.jsonl"
|
||||
trades: list[dict] = []
|
||||
if path.exists():
|
||||
try:
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
trades.append(json.loads(line))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 현재 연속 손실 카운트 (뒤에서부터)
|
||||
consec_loss = 0
|
||||
for t in reversed(trades):
|
||||
if t.get("net_pnl", 0) < 0:
|
||||
consec_loss += 1
|
||||
else:
|
||||
break
|
||||
|
||||
# 최근 15거래 PF
|
||||
recent_pf = None
|
||||
if len(trades) >= _SLOW_KILL_WINDOW:
|
||||
recent = trades[-_SLOW_KILL_WINDOW:]
|
||||
gp = sum(t["net_pnl"] for t in recent if t["net_pnl"] > 0)
|
||||
gl = abs(sum(t["net_pnl"] for t in recent if t["net_pnl"] < 0))
|
||||
recent_pf = round(gp / gl, 2) if gl > 0 else float("inf")
|
||||
|
||||
# 킬 상태 판정
|
||||
killed = (
|
||||
consec_loss >= _FAST_KILL_STREAK
|
||||
or (recent_pf is not None and recent_pf < _SLOW_KILL_PF_THRESHOLD)
|
||||
)
|
||||
|
||||
result[sym] = {
|
||||
"total_trades": len(trades),
|
||||
"consec_loss": consec_loss,
|
||||
"recent_pf": recent_pf,
|
||||
"killed": killed,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
# ── Discord 리포트 포맷 & 전송 ─────────────────────────────────────
|
||||
|
||||
_EMOJI_CHART = "\U0001F4CA"
|
||||
@@ -282,6 +314,27 @@ def format_report(data: dict) -> str:
|
||||
mdd_trend = f" {_ARROW} ".join(f"{v:.0f}%" for v in trend["mdd"])
|
||||
lines.append(f" MDD: {mdd_trend}")
|
||||
|
||||
# 킬스위치 모니터링
|
||||
ks = data.get("kill_switch", {})
|
||||
if ks:
|
||||
lines += ["", "[킬스위치 모니터링]"]
|
||||
for sym, status in ks.items():
|
||||
short = sym.replace("USDT", "")
|
||||
cl = status["consec_loss"]
|
||||
# 연속 손실 경고: 6회 이상이면 ⚠
|
||||
cl_warn = f" {_WARN}" if cl >= 6 else ""
|
||||
cl_str = f"연속손실 {cl}/{_FAST_KILL_STREAK}{cl_warn}"
|
||||
# PF 표시
|
||||
rpf = status["recent_pf"]
|
||||
if rpf is not None:
|
||||
pf_str = f"{_SLOW_KILL_WINDOW}거래PF {rpf:.2f}"
|
||||
else:
|
||||
n = status["total_trades"]
|
||||
pf_str = f"{_SLOW_KILL_WINDOW}거래PF -.-- ({n}건)"
|
||||
# KILLED 표시
|
||||
kill_tag = " \U0001F534 KILLED" if status["killed"] else ""
|
||||
lines.append(f" {short}: {cl_str} | {pf_str}{kill_tag}")
|
||||
|
||||
# ML 재도전 체크리스트
|
||||
ml = data["ml_trigger"]
|
||||
cond = ml["conditions"]
|
||||
@@ -359,6 +412,44 @@ def _sanitize(obj):
|
||||
return obj
|
||||
|
||||
|
||||
def generate_quantstats_report(
|
||||
trades: list[dict],
|
||||
output_path: str,
|
||||
title: str = "CoinTrader 주간 전략 리포트",
|
||||
initial_balance: float = 1000.0,
|
||||
) -> str | None:
|
||||
"""백테스트 트레이드 결과로 quantstats HTML 리포트를 생성한다."""
|
||||
if not trades:
|
||||
logger.warning("트레이드가 없어 quantstats 리포트를 생성할 수 없습니다.")
|
||||
return None
|
||||
|
||||
try:
|
||||
import quantstats as qs
|
||||
|
||||
# 트레이드 PnL을 일별 수익률 시계열로 변환
|
||||
records = []
|
||||
for t in trades:
|
||||
exit_time = pd.Timestamp(t["exit_time"])
|
||||
records.append({"date": exit_time.date(), "pnl": t["net_pnl"]})
|
||||
|
||||
df = pd.DataFrame(records)
|
||||
daily_pnl = df.groupby("date")["pnl"].sum()
|
||||
daily_pnl.index = pd.to_datetime(daily_pnl.index)
|
||||
daily_pnl = daily_pnl.sort_index()
|
||||
|
||||
# PnL → 수익률로 변환 (equity 기반)
|
||||
equity = initial_balance + daily_pnl.cumsum()
|
||||
returns = equity.pct_change().fillna(daily_pnl.iloc[0] / initial_balance)
|
||||
|
||||
qs.reports.html(returns, output=output_path, title=title, download_filename=output_path)
|
||||
logger.info(f"quantstats HTML 리포트 저장: {output_path}")
|
||||
return output_path
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"quantstats 리포트 생성 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def save_report(report: dict, report_dir: str) -> Path:
|
||||
"""리포트를 JSON으로 저장하고 경로를 반환한다."""
|
||||
rdir = Path(report_dir)
|
||||
@@ -370,68 +461,81 @@ def save_report(report: dict, report_dir: str) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def _calc_combined_summary(trades: list[dict], initial_balance: float = 1000.0) -> dict:
|
||||
"""개별 트레이드 리스트에서 합산 지표를 직접 계산한다."""
|
||||
if not trades:
|
||||
return {
|
||||
"profit_factor": 0.0, "win_rate": 0.0, "max_drawdown_pct": 0.0,
|
||||
"total_trades": 0, "total_pnl": 0.0,
|
||||
}
|
||||
|
||||
pnls = [t["net_pnl"] for t in trades]
|
||||
wins = [p for p in pnls if p > 0]
|
||||
losses = [p for p in pnls if p <= 0]
|
||||
|
||||
gross_profit = sum(wins) if wins else 0.0
|
||||
gross_loss = abs(sum(losses)) if losses else 0.0
|
||||
|
||||
# 시간순 정렬 후 포트폴리오 equity curve 기반 MDD
|
||||
sorted_trades = sorted(trades, key=lambda t: t["exit_time"])
|
||||
sorted_pnls = [t["net_pnl"] for t in sorted_trades]
|
||||
cumulative = np.cumsum(sorted_pnls)
|
||||
equity = initial_balance + cumulative
|
||||
peak = np.maximum.accumulate(equity)
|
||||
drawdown = (peak - equity) / peak
|
||||
mdd = float(np.max(drawdown)) * 100 if len(drawdown) > 0 else 0.0
|
||||
|
||||
return {
|
||||
"profit_factor": round(gross_profit / gross_loss, 2) if gross_loss > 0 else float("inf"),
|
||||
"win_rate": round(len(wins) / len(trades) * 100, 1),
|
||||
"max_drawdown_pct": round(mdd, 1),
|
||||
"total_trades": len(trades),
|
||||
"total_pnl": round(sum(pnls), 2),
|
||||
}
|
||||
|
||||
|
||||
def generate_report(
|
||||
symbols: list[str],
|
||||
report_dir: str = str(WEEKLY_DIR),
|
||||
log_path: str = "logs/bot.log",
|
||||
report_date: date | None = None,
|
||||
api_url: str | None = None,
|
||||
) -> dict:
|
||||
"""전체 주간 리포트를 생성한다."""
|
||||
today = report_date or date.today()
|
||||
dashboard_url = api_url or DASHBOARD_API_URL
|
||||
|
||||
# 1) Walk-Forward 백테스트 (심볼별)
|
||||
logger.info("백테스트 실행 중...")
|
||||
bt_results = {}
|
||||
combined_trades = 0
|
||||
combined_pnl = 0.0
|
||||
combined_gp = 0.0
|
||||
combined_gl = 0.0
|
||||
all_bt_trades = []
|
||||
|
||||
for sym in symbols:
|
||||
result = run_backtest([sym], TRAIN_MONTHS, TEST_MONTHS, PROD_PARAMS)
|
||||
bt_results[sym] = result["summary"]
|
||||
s = result["summary"]
|
||||
n = s["total_trades"]
|
||||
combined_trades += n
|
||||
combined_pnl += s["total_pnl"]
|
||||
if n > 0:
|
||||
wr = s["win_rate"] / 100.0
|
||||
n_wins = round(wr * n)
|
||||
n_losses = n - n_wins
|
||||
combined_gp += s["avg_win"] * n_wins if n_wins > 0 else 0
|
||||
combined_gl += abs(s["avg_loss"]) * n_losses if n_losses > 0 else 0
|
||||
all_bt_trades.extend(result.get("trades", []))
|
||||
|
||||
combined_pf = combined_gp / combined_gl if combined_gl > 0 else float("inf")
|
||||
combined_wr = (
|
||||
sum(s["win_rate"] * s["total_trades"] for s in bt_results.values())
|
||||
/ combined_trades if combined_trades > 0 else 0
|
||||
)
|
||||
combined_mdd = max((s["max_drawdown_pct"] for s in bt_results.values()), default=0)
|
||||
# 합산 지표를 개별 트레이드에서 직접 계산 (간접 역산 제거)
|
||||
backtest_summary = _calc_combined_summary(all_bt_trades)
|
||||
|
||||
backtest_summary = {
|
||||
"profit_factor": round(combined_pf, 2),
|
||||
"win_rate": round(combined_wr, 1),
|
||||
"max_drawdown_pct": round(combined_mdd, 1),
|
||||
"total_trades": combined_trades,
|
||||
"total_pnl": round(combined_pnl, 2),
|
||||
}
|
||||
# 2) 운영 대시보드 API에서 실전 트레이드 조회
|
||||
logger.info(f"대시보드 API에서 실전 트레이드 조회 중... ({dashboard_url})")
|
||||
live_stats = fetch_live_stats(dashboard_url)
|
||||
live_trades_list = fetch_live_trades(dashboard_url)
|
||||
|
||||
# 2) 실전 트레이드 파싱
|
||||
logger.info("실전 로그 파싱 중...")
|
||||
live_trades_list = parse_live_trades(log_path, days=7)
|
||||
live_wins = sum(1 for t in live_trades_list if t.get("net_pnl", 0) > 0)
|
||||
live_pnl = sum(t.get("net_pnl", 0) for t in live_trades_list)
|
||||
live_count = live_stats.get("total_trades", len(live_trades_list))
|
||||
live_wins = live_stats.get("wins", 0)
|
||||
live_pnl = live_stats.get("total_pnl", 0)
|
||||
live_summary = {
|
||||
"count": len(live_trades_list),
|
||||
"net_pnl": round(live_pnl, 2),
|
||||
"win_rate": round(live_wins / len(live_trades_list) * 100, 1) if live_trades_list else 0,
|
||||
"count": live_count,
|
||||
"net_pnl": round(float(live_pnl), 2),
|
||||
"win_rate": round(live_wins / live_count * 100, 1) if live_count > 0 else 0,
|
||||
}
|
||||
|
||||
# 3) 추이 로드
|
||||
trend = load_trend(report_dir)
|
||||
|
||||
# 4) 누적 트레이드 수
|
||||
cumulative = combined_trades + len(live_trades_list)
|
||||
# 4) 누적 트레이드 수 (실전 + 이전 리포트)
|
||||
cumulative = live_count
|
||||
rdir = Path(report_dir)
|
||||
if rdir.exists():
|
||||
for rpath in sorted(rdir.glob("report_*.json")):
|
||||
@@ -442,23 +546,28 @@ def generate_report(
|
||||
pass
|
||||
|
||||
# 5) ML 트리거 체크
|
||||
current_pf = backtest_summary["profit_factor"]
|
||||
ml_trigger = check_ml_trigger(
|
||||
cumulative_trades=cumulative,
|
||||
current_pf=combined_pf,
|
||||
current_pf=current_pf,
|
||||
pf_declining_3w=trend["pf_declining_3w"],
|
||||
)
|
||||
|
||||
# 6) PF < 1.0이면 스윕 실행
|
||||
# 6) 킬스위치 모니터링
|
||||
kill_switch = load_kill_switch_status(symbols)
|
||||
|
||||
# 7) PF < 1.0이면 스윕 실행
|
||||
sweep = None
|
||||
if combined_pf < 1.0:
|
||||
if current_pf < 1.0:
|
||||
logger.info("PF < 1.0 — 파라미터 스윕 실행 중...")
|
||||
sweep = run_degradation_sweep(symbols, TRAIN_MONTHS, TEST_MONTHS)
|
||||
|
||||
return {
|
||||
"date": today.isoformat(),
|
||||
"backtest": {"summary": backtest_summary, "per_symbol": bt_results},
|
||||
"backtest": {"summary": backtest_summary, "per_symbol": bt_results, "trades": all_bt_trades},
|
||||
"live_trades": live_summary,
|
||||
"trend": trend,
|
||||
"kill_switch": kill_switch,
|
||||
"ml_trigger": ml_trigger,
|
||||
"sweep": sweep,
|
||||
}
|
||||
@@ -482,7 +591,13 @@ def main():
|
||||
# 3) 저장
|
||||
save_report(report, str(WEEKLY_DIR))
|
||||
|
||||
# 4) Discord 전송
|
||||
# 4) quantstats HTML 리포트
|
||||
bt_trades = report["backtest"].get("trades", [])
|
||||
if bt_trades:
|
||||
html_path = str(WEEKLY_DIR / f"report_{report['date']}.html")
|
||||
generate_quantstats_report(bt_trades, html_path, title=f"CoinTrader 주간 리포트 ({report['date']})")
|
||||
|
||||
# 5) Discord 전송
|
||||
text = format_report(report)
|
||||
print(text)
|
||||
send_report(text)
|
||||
|
||||
@@ -30,7 +30,7 @@ def validate(trades: list[dict], summary: dict, cfg) -> dict:
|
||||
results: list[CheckResult] = []
|
||||
|
||||
# 검증 1: 논리적 불변 조건
|
||||
results.extend(_check_invariants(trades))
|
||||
results.extend(_check_invariants(trades, cfg))
|
||||
|
||||
# 검증 2: 통계적 이상 감지
|
||||
results.extend(_check_statistics(trades, summary))
|
||||
@@ -47,7 +47,7 @@ def validate(trades: list[dict], summary: dict, cfg) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _check_invariants(trades: list[dict]) -> list[CheckResult]:
|
||||
def _check_invariants(trades: list[dict], cfg=None) -> list[CheckResult]:
|
||||
"""논리적 불변 조건. 하나라도 위반 시 FAIL."""
|
||||
results = []
|
||||
|
||||
@@ -120,7 +120,7 @@ def _check_invariants(trades: list[dict]) -> list[CheckResult]:
|
||||
))
|
||||
|
||||
# 5. 잔고가 음수가 된 적 없음
|
||||
balance = 1000.0 # cfg.initial_balance를 몰라도 trades에서 추적 가능
|
||||
balance = cfg.initial_balance if cfg is not None else 1000.0
|
||||
min_balance = balance
|
||||
for t in trades:
|
||||
balance += t["net_pnl"]
|
||||
|
||||
@@ -6,18 +6,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import warnings
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import joblib
|
||||
import lightgbm as lgb
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from loguru import logger
|
||||
|
||||
import warnings
|
||||
# 크립토 24/7 시장: 15분봉 × 96봉/일 × 365일 = 35,040
|
||||
_ANNUALIZE_FACTOR = 35_040
|
||||
|
||||
import joblib
|
||||
import lightgbm as lgb
|
||||
|
||||
def _calc_trade_stats(trades: list[dict], initial_balance: float) -> dict:
|
||||
"""거래 리스트에서 통계 요약을 계산한다. Backtester와 WalkForward 공통 사용."""
|
||||
if not trades:
|
||||
return {
|
||||
"total_trades": 0, "total_pnl": 0.0, "return_pct": 0.0,
|
||||
"win_rate": 0.0, "avg_win": 0.0, "avg_loss": 0.0,
|
||||
"payoff_ratio": 0.0, "max_consecutive_losses": 0,
|
||||
"profit_factor": 0.0, "max_drawdown_pct": 0.0,
|
||||
"sharpe_ratio": 0.0, "total_fees": 0.0, "close_reasons": {},
|
||||
}
|
||||
|
||||
pnls = [t["net_pnl"] for t in trades]
|
||||
wins = [p for p in pnls if p > 0]
|
||||
losses = [p for p in pnls if p <= 0]
|
||||
|
||||
total_pnl = sum(pnls)
|
||||
total_fees = sum(t["entry_fee"] + t["exit_fee"] for t in trades)
|
||||
gross_profit = sum(wins) if wins else 0.0
|
||||
gross_loss = abs(sum(losses)) if losses else 0.0
|
||||
|
||||
cumulative = np.cumsum(pnls)
|
||||
equity = initial_balance + cumulative
|
||||
peak = np.maximum.accumulate(equity)
|
||||
drawdown = (peak - equity) / peak
|
||||
mdd = float(np.max(drawdown)) * 100 if len(drawdown) > 0 else 0.0
|
||||
|
||||
if len(pnls) > 1:
|
||||
pnl_arr = np.array(pnls)
|
||||
sharpe = float(np.mean(pnl_arr) / np.std(pnl_arr) * np.sqrt(_ANNUALIZE_FACTOR)) if np.std(pnl_arr) > 0 else 0.0
|
||||
else:
|
||||
sharpe = 0.0
|
||||
|
||||
avg_w = float(np.mean(wins)) if wins else 0.0
|
||||
avg_l = float(np.mean(losses)) if losses else 0.0
|
||||
payoff_ratio = round(avg_w / abs(avg_l), 2) if avg_l != 0 else float("inf")
|
||||
|
||||
max_consec_loss = 0
|
||||
cur_streak = 0
|
||||
for p in pnls:
|
||||
if p <= 0:
|
||||
cur_streak += 1
|
||||
max_consec_loss = max(max_consec_loss, cur_streak)
|
||||
else:
|
||||
cur_streak = 0
|
||||
|
||||
reasons = {}
|
||||
for t in trades:
|
||||
r = t["close_reason"]
|
||||
reasons[r] = reasons.get(r, 0) + 1
|
||||
|
||||
return {
|
||||
"total_trades": len(trades),
|
||||
"total_pnl": round(total_pnl, 4),
|
||||
"return_pct": round(total_pnl / initial_balance * 100, 2),
|
||||
"win_rate": round(len(wins) / len(trades) * 100, 2),
|
||||
"avg_win": round(avg_w, 4),
|
||||
"avg_loss": round(avg_l, 4),
|
||||
"payoff_ratio": payoff_ratio,
|
||||
"max_consecutive_losses": max_consec_loss,
|
||||
"profit_factor": round(gross_profit / gross_loss, 2) if gross_loss > 0 else float("inf"),
|
||||
"max_drawdown_pct": round(mdd, 2),
|
||||
"sharpe_ratio": round(sharpe, 2),
|
||||
"total_fees": round(total_fees, 4),
|
||||
"close_reasons": reasons,
|
||||
}
|
||||
|
||||
from src.dataset_builder import (
|
||||
_calc_indicators, _calc_signals, _calc_features_vectorized,
|
||||
@@ -76,6 +144,11 @@ class Position:
|
||||
|
||||
# ── 동기 RiskManager ─────────────────────────────────────────────────
|
||||
class BacktestRiskManager:
|
||||
# Kill Switch 상수 (bot.py와 동일)
|
||||
_FAST_KILL_STREAK = 8
|
||||
_SLOW_KILL_WINDOW = 15
|
||||
_SLOW_KILL_PF_THRESHOLD = 0.75
|
||||
|
||||
def __init__(self, cfg: BacktestConfig):
|
||||
self.cfg = cfg
|
||||
self.daily_pnl: float = 0.0
|
||||
@@ -83,6 +156,8 @@ class BacktestRiskManager:
|
||||
self.base_balance: float = cfg.initial_balance
|
||||
self.open_positions: dict[str, str] = {} # {symbol: side}
|
||||
self._current_date: str | None = None
|
||||
self._trade_history: list[float] = [] # 최근 net_pnl 기록
|
||||
self._killed: bool = False
|
||||
|
||||
def new_day(self, date_str: str):
|
||||
if self._current_date != date_str:
|
||||
@@ -90,12 +165,31 @@ class BacktestRiskManager:
|
||||
self.daily_pnl = 0.0
|
||||
|
||||
def is_trading_allowed(self) -> bool:
|
||||
if self._killed:
|
||||
return False
|
||||
if self.initial_balance <= 0:
|
||||
return True
|
||||
if self.daily_pnl < 0 and abs(self.daily_pnl) / self.initial_balance >= self.cfg.max_daily_loss_pct:
|
||||
return False
|
||||
return True
|
||||
|
||||
def record_trade(self, net_pnl: float):
|
||||
"""거래 기록 후 Kill Switch 검사."""
|
||||
self._trade_history.append(net_pnl)
|
||||
# Fast Kill: 8연속 순손실
|
||||
if len(self._trade_history) >= self._FAST_KILL_STREAK:
|
||||
recent = self._trade_history[-self._FAST_KILL_STREAK:]
|
||||
if all(p < 0 for p in recent):
|
||||
self._killed = True
|
||||
return
|
||||
# Slow Kill: 최근 15거래 PF < 0.75
|
||||
if len(self._trade_history) >= self._SLOW_KILL_WINDOW:
|
||||
recent = self._trade_history[-self._SLOW_KILL_WINDOW:]
|
||||
gross_profit = sum(p for p in recent if p > 0)
|
||||
gross_loss = abs(sum(p for p in recent if p < 0))
|
||||
if gross_loss > 0 and gross_profit / gross_loss < self._SLOW_KILL_PF_THRESHOLD:
|
||||
self._killed = True
|
||||
|
||||
def can_open(self, symbol: str, side: str) -> bool:
|
||||
if len(self.open_positions) >= self.cfg.max_positions:
|
||||
return False
|
||||
@@ -112,6 +206,7 @@ class BacktestRiskManager:
|
||||
def close(self, symbol: str, pnl: float):
|
||||
self.open_positions.pop(symbol, None)
|
||||
self.daily_pnl += pnl
|
||||
self.record_trade(pnl)
|
||||
|
||||
def get_dynamic_margin_ratio(self, balance: float) -> float:
|
||||
ratio = self.cfg.margin_max_ratio - (
|
||||
@@ -249,16 +344,9 @@ class Backtester:
|
||||
self.ml_filters = {}
|
||||
for sym in self.cfg.symbols:
|
||||
if sym in ml_models and ml_models[sym] is not None:
|
||||
mf = MLFilter.__new__(MLFilter)
|
||||
mf._disabled = False
|
||||
mf._onnx_session = None
|
||||
mf._lgbm_model = ml_models[sym]
|
||||
mf._threshold = self.cfg.ml_threshold
|
||||
mf._onnx_path = Path("/dev/null")
|
||||
mf._lgbm_path = Path("/dev/null")
|
||||
mf._loaded_onnx_mtime = 0.0
|
||||
mf._loaded_lgbm_mtime = 0.0
|
||||
self.ml_filters[sym] = mf
|
||||
self.ml_filters[sym] = MLFilter.from_model(
|
||||
ml_models[sym], threshold=self.cfg.ml_threshold
|
||||
)
|
||||
else:
|
||||
self.ml_filters[sym] = None
|
||||
|
||||
@@ -267,6 +355,7 @@ class Backtester:
|
||||
logger.info(f"총 이벤트: {len(events):,}개")
|
||||
|
||||
# 메인 루프
|
||||
latest_prices: dict[str, float] = {}
|
||||
for ts, sym, candle_idx in events:
|
||||
date_str = str(ts.date())
|
||||
self.risk.new_day(date_str)
|
||||
@@ -274,9 +363,10 @@ class Backtester:
|
||||
df_ind = all_indicators[sym]
|
||||
signal = all_signals[sym][candle_idx]
|
||||
row = df_ind.iloc[candle_idx]
|
||||
latest_prices[sym] = float(row["close"])
|
||||
|
||||
# 에퀴티 기록
|
||||
self._record_equity(ts)
|
||||
self._record_equity(ts, current_prices=latest_prices)
|
||||
|
||||
# 1) 일일 손실 체크
|
||||
if not self.risk.is_trading_allowed():
|
||||
@@ -423,9 +513,8 @@ class Backtester:
|
||||
buy_side = "BUY" if signal == "LONG" else "SELL"
|
||||
entry_price = _apply_slippage(price, buy_side, self.cfg.slippage_pct)
|
||||
|
||||
# 수수료
|
||||
# 수수료 (청산 시 net_pnl에서 차감하므로 여기서 balance 차감하지 않음)
|
||||
entry_fee = _calc_fee(entry_price, quantity, self.cfg.fee_pct)
|
||||
self.balance -= entry_fee
|
||||
|
||||
# SL/TP 계산
|
||||
atr = float(row.get("atr", 0))
|
||||
@@ -501,12 +590,15 @@ class Backtester:
|
||||
}
|
||||
self.trades.append(trade)
|
||||
|
||||
def _record_equity(self, ts: pd.Timestamp):
|
||||
# 미실현 PnL 포함 에퀴티
|
||||
def _record_equity(self, ts: pd.Timestamp, current_prices: dict[str, float] | None = None):
|
||||
unrealized = 0.0
|
||||
for pos in self.positions.values():
|
||||
# 에퀴티 기록 시점에는 현재가를 알 수 없으므로 entry_price 기준으로 0 처리
|
||||
pass
|
||||
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:
|
||||
@@ -524,63 +616,7 @@ class Backtester:
|
||||
}
|
||||
|
||||
def _calc_summary(self) -> dict:
|
||||
if not self.trades:
|
||||
return {
|
||||
"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": {},
|
||||
}
|
||||
|
||||
pnls = [t["net_pnl"] for t in self.trades]
|
||||
wins = [p for p in pnls if p > 0]
|
||||
losses = [p for p in pnls if p <= 0]
|
||||
|
||||
total_pnl = sum(pnls)
|
||||
total_fees = sum(t["entry_fee"] + t["exit_fee"] for t in self.trades)
|
||||
gross_profit = sum(wins) if wins else 0.0
|
||||
gross_loss = abs(sum(losses)) if losses else 0.0
|
||||
|
||||
# MDD 계산
|
||||
cumulative = np.cumsum(pnls)
|
||||
equity = self.cfg.initial_balance + cumulative
|
||||
peak = np.maximum.accumulate(equity)
|
||||
drawdown = (peak - equity) / peak
|
||||
mdd = float(np.max(drawdown)) * 100 if len(drawdown) > 0 else 0.0
|
||||
|
||||
# 샤프비율 (연율화, 15분봉 기준: 252일 * 96봉 = 24192)
|
||||
if len(pnls) > 1:
|
||||
pnl_arr = np.array(pnls)
|
||||
sharpe = float(np.mean(pnl_arr) / np.std(pnl_arr) * np.sqrt(24192)) if np.std(pnl_arr) > 0 else 0.0
|
||||
else:
|
||||
sharpe = 0.0
|
||||
|
||||
# 청산 사유별 비율
|
||||
reasons = {}
|
||||
for t in self.trades:
|
||||
r = t["close_reason"]
|
||||
reasons[r] = reasons.get(r, 0) + 1
|
||||
|
||||
return {
|
||||
"total_trades": len(self.trades),
|
||||
"total_pnl": round(total_pnl, 4),
|
||||
"return_pct": round(total_pnl / self.cfg.initial_balance * 100, 2),
|
||||
"win_rate": round(len(wins) / len(self.trades) * 100, 2) if self.trades else 0.0,
|
||||
"avg_win": round(np.mean(wins), 4) if wins else 0.0,
|
||||
"avg_loss": round(np.mean(losses), 4) if losses else 0.0,
|
||||
"profit_factor": round(gross_profit / gross_loss, 2) if gross_loss > 0 else float("inf"),
|
||||
"max_drawdown_pct": round(mdd, 2),
|
||||
"sharpe_ratio": round(sharpe, 2),
|
||||
"total_fees": round(total_fees, 4),
|
||||
"close_reasons": reasons,
|
||||
}
|
||||
return _calc_trade_stats(self.trades, self.cfg.initial_balance)
|
||||
|
||||
|
||||
# ── Walk-Forward 백테스트 ─────────────────────────────────────────────
|
||||
@@ -589,7 +625,7 @@ class WalkForwardConfig(BacktestConfig):
|
||||
train_months: int = 6 # 학습 윈도우 (개월)
|
||||
test_months: int = 1 # 검증 윈도우 (개월)
|
||||
time_weight_decay: float = 2.0
|
||||
negative_ratio: int = 5
|
||||
negative_ratio: int = 3
|
||||
|
||||
|
||||
class WalkForwardBacktester:
|
||||
@@ -665,6 +701,8 @@ class WalkForwardBacktester:
|
||||
"fold": i + 1,
|
||||
"train_period": f"{train_start.date()} ~ {train_end.date()}",
|
||||
"test_period": f"{test_start.date()} ~ {test_end.date()}",
|
||||
"test_start": test_start.isoformat(),
|
||||
"test_end": test_end.isoformat(),
|
||||
"summary": result["summary"],
|
||||
})
|
||||
|
||||
@@ -732,6 +770,8 @@ class WalkForwardBacktester:
|
||||
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,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f" [{symbol}] 데이터셋 생성 실패: {e}")
|
||||
@@ -793,52 +833,7 @@ class WalkForwardBacktester:
|
||||
"""폴드별 결과를 합산하여 전체 Walk-Forward 결과 생성."""
|
||||
from src.backtest_validator import validate
|
||||
|
||||
# 전체 통계 계산
|
||||
if not all_trades:
|
||||
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": {}}
|
||||
else:
|
||||
pnls = [t["net_pnl"] for t in all_trades]
|
||||
wins = [p for p in pnls if p > 0]
|
||||
losses = [p for p in pnls if p <= 0]
|
||||
total_pnl = sum(pnls)
|
||||
total_fees = sum(t["entry_fee"] + t["exit_fee"] for t in all_trades)
|
||||
gross_profit = sum(wins) if wins else 0.0
|
||||
gross_loss = abs(sum(losses)) if losses else 0.0
|
||||
|
||||
cumulative = np.cumsum(pnls)
|
||||
equity = self.cfg.initial_balance + cumulative
|
||||
peak = np.maximum.accumulate(equity)
|
||||
drawdown = (peak - equity) / peak
|
||||
mdd = float(np.max(drawdown)) * 100 if len(drawdown) > 0 else 0.0
|
||||
|
||||
if len(pnls) > 1:
|
||||
pnl_arr = np.array(pnls)
|
||||
sharpe = float(np.mean(pnl_arr) / np.std(pnl_arr) * np.sqrt(24192)) if np.std(pnl_arr) > 0 else 0.0
|
||||
else:
|
||||
sharpe = 0.0
|
||||
|
||||
reasons = {}
|
||||
for t in all_trades:
|
||||
r = t["close_reason"]
|
||||
reasons[r] = reasons.get(r, 0) + 1
|
||||
|
||||
summary = {
|
||||
"total_trades": len(all_trades),
|
||||
"total_pnl": round(total_pnl, 4),
|
||||
"return_pct": round(total_pnl / self.cfg.initial_balance * 100, 2),
|
||||
"win_rate": round(len(wins) / len(all_trades) * 100, 2),
|
||||
"avg_win": round(np.mean(wins), 4) if wins else 0.0,
|
||||
"avg_loss": round(np.mean(losses), 4) if losses else 0.0,
|
||||
"profit_factor": round(gross_profit / gross_loss, 2) if gross_loss > 0 else float("inf"),
|
||||
"max_drawdown_pct": round(mdd, 2),
|
||||
"sharpe_ratio": round(sharpe, 2),
|
||||
"total_fees": round(total_fees, 4),
|
||||
"close_reasons": reasons,
|
||||
}
|
||||
|
||||
summary = _calc_trade_stats(all_trades, self.cfg.initial_balance)
|
||||
validation = validate(all_trades, summary, self.cfg)
|
||||
|
||||
return {
|
||||
|
||||
689
src/bot.py
689
src/bot.py
@@ -1,5 +1,9 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import pandas as pd
|
||||
from loguru import logger
|
||||
@@ -13,13 +17,48 @@ from src.ml_filter import MLFilter
|
||||
from src.ml_features import build_features_aligned
|
||||
from src.user_data_stream import UserDataStream
|
||||
|
||||
# ── 킬스위치 상수 ──────────────────────────────────────────────────
|
||||
_FAST_KILL_STREAK = 8 # 연속 손실 N회 → 즉시 중단
|
||||
_SLOW_KILL_WINDOW = 15 # 최근 N거래 PF 산출
|
||||
_SLOW_KILL_PF_THRESHOLD = 0.75 # PF < 이 값이면 중단
|
||||
_TRADE_HISTORY_DIR = Path("data/trade_history")
|
||||
|
||||
|
||||
def _tail_lines(path: Path, n: int) -> list[str]:
|
||||
"""파일 끝에서 최대 n줄을 효율적으로 읽는다 (전체 파일 로드 없이)."""
|
||||
with open(path, "rb") as f:
|
||||
f.seek(0, 2) # EOF
|
||||
fsize = f.tell()
|
||||
if fsize == 0:
|
||||
return []
|
||||
# 뒤에서부터 청크 단위로 읽기
|
||||
chunk_size = min(4096, fsize)
|
||||
lines: list[str] = []
|
||||
pos = fsize
|
||||
remaining = b""
|
||||
while pos > 0 and len(lines) < n + 1:
|
||||
read_size = min(chunk_size, pos)
|
||||
pos -= read_size
|
||||
f.seek(pos)
|
||||
chunk = f.read(read_size) + remaining
|
||||
remaining = b""
|
||||
split = chunk.split(b"\n")
|
||||
# 첫 조각은 이전 청크와 이어질 수 있으므로 따로 보관
|
||||
remaining = split[0]
|
||||
lines = [s.decode() for s in split[1:] if s.strip()] + lines
|
||||
# 남은 조각 처리
|
||||
if remaining.strip():
|
||||
lines = [remaining.decode()] + lines
|
||||
return lines[-n:]
|
||||
|
||||
|
||||
class TradingBot:
|
||||
def __init__(self, config: Config, symbol: str = None, risk: RiskManager = None):
|
||||
self.config = config
|
||||
self.symbol = symbol or config.symbol
|
||||
self.strategy = config.get_symbol_params(self.symbol)
|
||||
self.exchange = BinanceFuturesClient(config, symbol=self.symbol)
|
||||
self.notifier = DiscordNotifier(config.discord_webhook_url)
|
||||
self.notifier = DiscordNotifier(config.discord_webhook_url, testnet=config.testnet)
|
||||
self.risk = risk or RiskManager(config)
|
||||
# 심볼별 모델 디렉토리. 없으면 기존 models/ 루트로 폴백
|
||||
symbol_model_dir = Path(f"models/{self.symbol.lower()}")
|
||||
@@ -38,35 +77,156 @@ class TradingBot:
|
||||
self._entry_price: float | None = None
|
||||
self._entry_quantity: float | None = None
|
||||
self._is_reentering: bool = False # _close_and_reenter 중 콜백 상태 초기화 방지
|
||||
self._entry_time_ms: int | None = None # 포지션 진입 시각 (ms, SYNC PnL 범위 제한용)
|
||||
self._sl_order_id: int | None = None # SL 주문 ID (고아 주문 취소용)
|
||||
self._tp_order_id: int | None = None # TP 주문 ID (고아 주문 취소용)
|
||||
self._sl_price: float | None = None # SL 가격 (가격 기반 close_reason 판별용)
|
||||
self._tp_price: float | None = None # TP 가격 (가격 기반 close_reason 판별용)
|
||||
self._close_event = asyncio.Event() # 콜백 청산 완료 대기용
|
||||
self._close_lock = asyncio.Lock() # 청산 처리 원자성 보장 (C3 fix)
|
||||
self._prev_oi: float | None = None # OI 변화율 계산용 이전 값
|
||||
self._oi_history: deque = deque(maxlen=5)
|
||||
self._oi_history: deque = deque(maxlen=96) # z-score 윈도우(96=1일분 15분봉)
|
||||
self._funding_history: deque = deque(maxlen=96)
|
||||
self._latest_ret_1: float = 0.0
|
||||
self._killed: bool = False # 킬스위치 발동 상태
|
||||
self._trade_history: list[dict] = [] # 최근 거래 이력 (net_pnl 기록)
|
||||
self.stream = MultiSymbolStream(
|
||||
symbols=[self.symbol] + config.correlation_symbols,
|
||||
interval="15m",
|
||||
interval=config.kline_interval,
|
||||
on_candle=self._on_candle_closed,
|
||||
)
|
||||
# 부팅 시 거래 이력 복원 및 킬스위치 소급 검증
|
||||
self._restore_trade_history()
|
||||
self._restore_kill_switch()
|
||||
|
||||
# ── 킬스위치 ──────────────────────────────────────────────────────
|
||||
|
||||
def _trade_history_path(self) -> Path:
|
||||
base = _TRADE_HISTORY_DIR
|
||||
if self.config.testnet:
|
||||
base = base / "testnet"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base / f"{self.symbol.lower()}.jsonl"
|
||||
|
||||
def _restore_trade_history(self) -> None:
|
||||
"""부팅 시 파일 마지막 N줄만 읽어 거래 이력을 복원한다.
|
||||
킬스위치 판단에 필요한 최대 윈도우(_SLOW_KILL_WINDOW)만큼만 유지."""
|
||||
path = self._trade_history_path()
|
||||
if not path.exists():
|
||||
return
|
||||
try:
|
||||
tail_n = max(_FAST_KILL_STREAK, _SLOW_KILL_WINDOW)
|
||||
lines = _tail_lines(path, tail_n)
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line:
|
||||
self._trade_history.append(json.loads(line))
|
||||
logger.info(f"[{self.symbol}] 거래 이력 복원: {len(self._trade_history)}건 (최근 {tail_n}건)")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.symbol}] 거래 이력 복원 실패: {e}")
|
||||
|
||||
def _restore_kill_switch(self) -> None:
|
||||
"""부팅 시 .env 리셋 플래그 확인 후, 이력 기반으로 킬스위치 소급 검증."""
|
||||
reset_key = f"RESET_KILL_SWITCH_{self.symbol}"
|
||||
if os.environ.get(reset_key, "").lower() == "true":
|
||||
logger.info(f"[{self.symbol}] 킬스위치 수동 해제 감지 ({reset_key}=True)")
|
||||
self._killed = False
|
||||
return
|
||||
# 소급 검증
|
||||
if self._check_kill_switch(silent=True):
|
||||
logger.warning(f"[{self.symbol}] 부팅 시 킬스위치 조건 충족 — 신규 진입 차단")
|
||||
|
||||
def _append_trade(self, net_pnl: float, close_reason: str) -> None:
|
||||
"""거래 기록을 메모리 + 파일에 추가한다."""
|
||||
record = {
|
||||
"net_pnl": round(net_pnl, 4),
|
||||
"reason": close_reason,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
self._trade_history.append(record)
|
||||
# 메모리에는 킬스위치 윈도우만큼만 유지
|
||||
max_window = max(_FAST_KILL_STREAK, _SLOW_KILL_WINDOW)
|
||||
if len(self._trade_history) > max_window * 2:
|
||||
self._trade_history = self._trade_history[-max_window:]
|
||||
# 파일에 append (JSONL)
|
||||
try:
|
||||
_TRADE_HISTORY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._trade_history_path(), "a") as f:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.symbol}] 거래 기록 저장 실패: {e}")
|
||||
|
||||
def _check_kill_switch(self, silent: bool = False) -> bool:
|
||||
"""킬스위치 조건을 검사하고, 발동 시 True를 반환한다.
|
||||
|
||||
Fast Kill: 최근 8연속 순손실
|
||||
Slow Kill: 최근 15거래 PF < 0.75
|
||||
"""
|
||||
trades = self._trade_history
|
||||
if not trades:
|
||||
return False
|
||||
|
||||
# Fast Kill: 8연속 순손실
|
||||
if len(trades) >= _FAST_KILL_STREAK:
|
||||
recent = trades[-_FAST_KILL_STREAK:]
|
||||
if all(t["net_pnl"] < 0 for t in recent):
|
||||
reason = f"Fast Kill ({_FAST_KILL_STREAK}연속 순손실)"
|
||||
self._trigger_kill_switch(reason, silent)
|
||||
return True
|
||||
|
||||
# Slow Kill: 최근 15거래 PF < 0.75
|
||||
if len(trades) >= _SLOW_KILL_WINDOW:
|
||||
recent = trades[-_SLOW_KILL_WINDOW:]
|
||||
gross_profit = sum(t["net_pnl"] for t in recent if t["net_pnl"] > 0)
|
||||
gross_loss = abs(sum(t["net_pnl"] for t in recent if t["net_pnl"] < 0))
|
||||
if gross_loss > 0:
|
||||
pf = gross_profit / gross_loss
|
||||
if pf < _SLOW_KILL_PF_THRESHOLD:
|
||||
reason = f"Slow Kill (최근 {_SLOW_KILL_WINDOW}거래 PF={pf:.2f})"
|
||||
self._trigger_kill_switch(reason, silent)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _trigger_kill_switch(self, reason: str, silent: bool = False) -> None:
|
||||
"""킬스위치 발동: 상태 변경 + 알림."""
|
||||
self._killed = True
|
||||
msg = (
|
||||
f"🚨 [KILL SWITCH] {self.symbol} 신규 진입 중단\n"
|
||||
f"사유: {reason}\n"
|
||||
f"기존 포지션 SL/TP는 정상 작동합니다.\n"
|
||||
f"해제: RESET_KILL_SWITCH_{self.symbol}=True 후 봇 재시작"
|
||||
)
|
||||
logger.error(msg)
|
||||
if not silent:
|
||||
self.notifier.notify_info(msg)
|
||||
|
||||
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")
|
||||
corr = self.config.correlation_symbols
|
||||
corr_dfs = {s: self.stream.get_dataframe(s) for s in corr}
|
||||
btc_df = corr_dfs.get("BTCUSDT")
|
||||
eth_df = corr_dfs.get("ETHUSDT")
|
||||
if primary_df is not None:
|
||||
await self.process_candle(primary_df, btc_df=btc_df, eth_df=eth_df)
|
||||
|
||||
async def _recover_position(self) -> None:
|
||||
"""재시작 시 바이낸스에서 현재 포지션을 조회하여 상태 복구."""
|
||||
"""재시작 시 바이낸스에서 현재 포지션을 조회하여 상태 복구.
|
||||
SL/TP 주문이 누락된 경우 ATR 기반으로 재배치한다."""
|
||||
position = await self.exchange.get_position()
|
||||
if position is not None:
|
||||
amt = float(position["positionAmt"])
|
||||
self.current_trade_side = "LONG" if amt > 0 else "SHORT"
|
||||
self._entry_price = float(position["entryPrice"])
|
||||
self._entry_quantity = abs(amt)
|
||||
self._entry_time_ms = int(float(position.get("updateTime", time.time() * 1000)))
|
||||
entry = float(position["entryPrice"])
|
||||
logger.info(
|
||||
f"[{self.symbol}] 기존 포지션 복구: {self.current_trade_side} | "
|
||||
f"진입가={entry:.4f} | 수량={abs(amt)}"
|
||||
)
|
||||
# SL/TP 주문 존재 여부 확인 후 누락 시 재배치
|
||||
await self._ensure_sl_tp_orders(position)
|
||||
self.notifier.notify_info(
|
||||
f"봇 재시작 - 기존 포지션 감지: {self.current_trade_side} "
|
||||
f"진입가={entry:.4f} 수량={abs(amt)}"
|
||||
@@ -74,6 +234,62 @@ class TradingBot:
|
||||
else:
|
||||
logger.info(f"[{self.symbol}] 기존 포지션 없음 - 신규 진입 대기")
|
||||
|
||||
async def _ensure_sl_tp_orders(self, position: dict) -> None:
|
||||
"""포지션에 SL/TP 주문이 없으면 ATR 기반으로 재배치한다."""
|
||||
try:
|
||||
open_orders = await self.exchange.get_open_orders()
|
||||
has_sl = any(o.get("type") == "STOP_MARKET" for o in open_orders)
|
||||
has_tp = any(o.get("type") == "TAKE_PROFIT_MARKET" for o in open_orders)
|
||||
# 오픈 주문에서 SL/TP 가격 복원 (가격 기반 close_reason 판별용)
|
||||
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))
|
||||
if has_sl and has_tp:
|
||||
return
|
||||
missing = []
|
||||
if not has_sl:
|
||||
missing.append("SL")
|
||||
if not has_tp:
|
||||
missing.append("TP")
|
||||
logger.warning(f"[{self.symbol}] {'/'.join(missing)} 주문 누락 감지 — 재배치")
|
||||
|
||||
# 캔들 데이터로 ATR 기반 SL/TP 계산
|
||||
primary_df = self.stream.get_dataframe(self.symbol)
|
||||
if primary_df is None:
|
||||
logger.warning(f"[{self.symbol}] 캔들 데이터 부족 — SL/TP 재배치 건너뜀")
|
||||
return
|
||||
ind = Indicators(primary_df)
|
||||
df_ind = ind.calculate_all()
|
||||
entry = self._entry_price
|
||||
qty = self._entry_quantity
|
||||
sl, tp = ind.get_atr_stop(
|
||||
df_ind, self.current_trade_side, entry,
|
||||
atr_sl_mult=self.strategy.atr_sl_mult,
|
||||
atr_tp_mult=self.strategy.atr_tp_mult,
|
||||
)
|
||||
sl_side = "SELL" if self.current_trade_side == "LONG" else "BUY"
|
||||
if not has_sl:
|
||||
await self.exchange.place_order(
|
||||
side=sl_side, quantity=qty,
|
||||
order_type="STOP_MARKET",
|
||||
stop_price=self.exchange._round_price(sl),
|
||||
reduce_only=True,
|
||||
)
|
||||
logger.info(f"[{self.symbol}] SL 재배치: {sl:.4f}")
|
||||
if not has_tp:
|
||||
await self.exchange.place_order(
|
||||
side=sl_side, quantity=qty,
|
||||
order_type="TAKE_PROFIT_MARKET",
|
||||
stop_price=self.exchange._round_price(tp),
|
||||
reduce_only=True,
|
||||
)
|
||||
logger.info(f"[{self.symbol}] TP 재배치: {tp:.4f}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.symbol}] SL/TP 재배치 실패: {e}")
|
||||
|
||||
async def _init_oi_history(self) -> None:
|
||||
"""봇 시작 시 최근 OI 변화율 히스토리를 조회하여 deque를 채운다."""
|
||||
try:
|
||||
@@ -99,9 +315,13 @@ class TradingBot:
|
||||
oi_change = 0.0
|
||||
fr_float = float(fr_val) if isinstance(fr_val, (int, float)) else 0.0
|
||||
|
||||
# OI 히스토리 업데이트 및 MA5 계산
|
||||
# 히스토리 업데이트 (z-score 계산용)
|
||||
self._oi_history.append(oi_change)
|
||||
oi_ma5 = sum(self._oi_history) / len(self._oi_history) if self._oi_history else 0.0
|
||||
self._funding_history.append(fr_float)
|
||||
|
||||
# OI MA5 계산
|
||||
recent_5 = list(self._oi_history)[-5:]
|
||||
oi_ma5 = sum(recent_5) / len(recent_5) if recent_5 else 0.0
|
||||
|
||||
# OI-가격 스프레드
|
||||
oi_price_spread = oi_change - self._latest_ret_1
|
||||
@@ -122,6 +342,23 @@ class TradingBot:
|
||||
return change
|
||||
|
||||
async def process_candle(self, df, btc_df=None, eth_df=None):
|
||||
# Demo 모드: 시그널/필터 전부 우회, 포지션 없을 때만 1회 LONG 진입 (UDS 검증용)
|
||||
if self.config.testnet:
|
||||
ind = Indicators(df)
|
||||
df_with_indicators = ind.calculate_all()
|
||||
current_price = df_with_indicators["close"].iloc[-1]
|
||||
# 로컬 상태 + 바이낸스 포지션 모두 체크
|
||||
if self.current_trade_side is not None:
|
||||
logger.info(f"[{self.symbol}] [DEMO] 포지션 보유 중 (로컬) — SL/TP 대기 | 현재가: {current_price:.4f}")
|
||||
return
|
||||
position = await self.exchange.get_position()
|
||||
if position is not None:
|
||||
logger.info(f"[{self.symbol}] [DEMO] 포지션 보유 중 (바이낸스) — SL/TP 대기 | 현재가: {current_price:.4f}")
|
||||
return
|
||||
logger.info(f"[{self.symbol}] [DEMO] 강제 LONG 진입 | 현재가: {current_price:.4f}")
|
||||
await self._open_position("LONG", df_with_indicators)
|
||||
return
|
||||
|
||||
self.ml_filter.check_and_reload()
|
||||
|
||||
# 가격 수익률 계산 (oi_price_spread용)
|
||||
@@ -133,26 +370,47 @@ class TradingBot:
|
||||
# 캔들 마감 시 OI/펀딩비 실시간 조회 (실패해도 0으로 폴백)
|
||||
oi_change, funding_rate, oi_ma5, oi_price_spread = await self._fetch_market_microstructure()
|
||||
|
||||
if not self.risk.is_trading_allowed():
|
||||
if not await self.risk.is_trading_allowed():
|
||||
logger.warning(f"[{self.symbol}] 리스크 한도 초과 - 거래 중단")
|
||||
return
|
||||
|
||||
# 킬스위치: 신규 진입만 차단, 기존 포지션 모니터링은 계속
|
||||
if self._killed:
|
||||
return
|
||||
|
||||
ind = Indicators(df)
|
||||
df_with_indicators = ind.calculate_all()
|
||||
raw_signal = ind.get_signal(
|
||||
raw_signal, signal_detail = ind.get_signal(
|
||||
df_with_indicators,
|
||||
signal_threshold=self.config.signal_threshold,
|
||||
adx_threshold=self.config.adx_threshold,
|
||||
volume_multiplier=self.config.volume_multiplier,
|
||||
signal_threshold=self.strategy.signal_threshold,
|
||||
adx_threshold=self.strategy.adx_threshold,
|
||||
volume_multiplier=self.strategy.volume_multiplier,
|
||||
)
|
||||
|
||||
current_price = df_with_indicators["close"].iloc[-1]
|
||||
logger.info(f"[{self.symbol}] 신호: {raw_signal} | 현재가: {current_price:.4f} USDT")
|
||||
adx_str = f"ADX={signal_detail['adx']:.1f}" if signal_detail['adx'] is not None else "ADX=N/A"
|
||||
vol_str = "Vol급증" if signal_detail['vol_surge'] else "Vol정상"
|
||||
score_str = f"L={signal_detail['long']} S={signal_detail['short']}"
|
||||
if raw_signal == "HOLD" and signal_detail['hold_reason']:
|
||||
logger.info(f"[{self.symbol}] 신호: HOLD | {score_str} | {adx_str} | {vol_str} | 사유: {signal_detail['hold_reason']} | 현재가: {current_price:.4f}")
|
||||
else:
|
||||
logger.info(f"[{self.symbol}] 신호: {raw_signal} | {score_str} | {adx_str} | {vol_str} | 현재가: {current_price:.4f}")
|
||||
|
||||
position = await self.exchange.get_position()
|
||||
|
||||
if position is None and raw_signal != "HOLD":
|
||||
self.current_trade_side = None
|
||||
# Binance에 포지션이 없는데 로컬에 남아있으면 risk manager 동기화
|
||||
if self.current_trade_side is not None:
|
||||
logger.warning(
|
||||
f"[{self.symbol}] 포지션 불일치: 로컬={self.current_trade_side}, "
|
||||
f"바이낸스=없음 — risk manager 동기화"
|
||||
)
|
||||
await self.risk.close_position(self.symbol, 0.0)
|
||||
self.current_trade_side = None
|
||||
self._entry_price = None
|
||||
self._entry_quantity = None
|
||||
self._sl_price = None
|
||||
self._tp_price = None
|
||||
if not await self.risk.can_open_new_position(self.symbol, raw_signal):
|
||||
logger.info(f"[{self.symbol}] 포지션 오픈 불가")
|
||||
return
|
||||
@@ -162,6 +420,8 @@ class TradingBot:
|
||||
btc_df=btc_df, eth_df=eth_df,
|
||||
oi_change=oi_change, funding_rate=funding_rate,
|
||||
oi_change_ma5=oi_ma5, oi_price_spread=oi_price_spread,
|
||||
oi_history=list(self._oi_history),
|
||||
funding_history=list(self._funding_history),
|
||||
)
|
||||
if self.ml_filter.is_model_loaded():
|
||||
if not self.ml_filter.should_enter(features):
|
||||
@@ -181,44 +441,65 @@ class TradingBot:
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
logger.info(f"[{self.symbol}] 포지션 크기: 잔고={per_symbol_balance:.2f}/{balance:.2f} USDT, 증거금비율={margin_ratio:.1%}, 수량={quantity}")
|
||||
stop_loss, take_profit = Indicators(df).get_atr_stop(
|
||||
df, signal, price,
|
||||
atr_sl_mult=self.config.atr_sl_mult,
|
||||
atr_tp_mult=self.config.atr_tp_mult,
|
||||
)
|
||||
|
||||
notional = quantity * price
|
||||
if quantity <= 0 or notional < self.exchange.MIN_NOTIONAL:
|
||||
logger.warning(
|
||||
f"주문 건너뜀: 명목금액 {notional:.2f} USDT < 최소 {self.exchange.MIN_NOTIONAL} USDT "
|
||||
f"(잔고={balance:.2f}, 수량={quantity})"
|
||||
# 동시 진입 시 잔고 레이스 방지: entry_lock으로 잔고 조회→주문→등록을 직렬화
|
||||
async with self.risk._entry_lock:
|
||||
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(per_symbol_balance)
|
||||
quantity = self.exchange.calculate_quantity(
|
||||
balance=per_symbol_balance, price=price, leverage=self.config.leverage, margin_ratio=margin_ratio
|
||||
)
|
||||
return
|
||||
logger.info(f"[{self.symbol}] 포지션 크기: 잔고={per_symbol_balance:.2f}/{balance:.2f} USDT, 증거금비율={margin_ratio:.1%}, 수량={quantity}")
|
||||
# Demo 모드: 고정 퍼센트 SL/TP (ATR이 너무 작아 즉시 트리거 방지)
|
||||
if self.config.testnet:
|
||||
sl_pct = 0.005 # 0.5%
|
||||
tp_pct = 0.02
|
||||
if signal == "LONG":
|
||||
stop_loss = price * (1 - sl_pct)
|
||||
take_profit = price * (1 + tp_pct)
|
||||
else:
|
||||
stop_loss = price * (1 + sl_pct)
|
||||
take_profit = price * (1 - tp_pct)
|
||||
else:
|
||||
# df는 이미 calculate_all() 적용된 df_with_indicators이므로
|
||||
# Indicators를 재생성하지 않고 ATR을 직접 사용
|
||||
atr = df["atr"].iloc[-1]
|
||||
if signal == "LONG":
|
||||
stop_loss = price - atr * self.strategy.atr_sl_mult
|
||||
take_profit = price + atr * self.strategy.atr_tp_mult
|
||||
else:
|
||||
stop_loss = price + atr * self.strategy.atr_sl_mult
|
||||
take_profit = price - atr * self.strategy.atr_tp_mult
|
||||
|
||||
side = "BUY" if signal == "LONG" else "SELL"
|
||||
await self.exchange.set_leverage(self.config.leverage)
|
||||
await self.exchange.place_order(side=side, quantity=quantity)
|
||||
notional = quantity * price
|
||||
if quantity <= 0 or notional < self.exchange.MIN_NOTIONAL:
|
||||
logger.warning(
|
||||
f"주문 건너뜀: 명목금액 {notional:.2f} USDT < 최소 {self.exchange.MIN_NOTIONAL} USDT "
|
||||
f"(잔고={balance:.2f}, 수량={quantity})"
|
||||
)
|
||||
return
|
||||
|
||||
last_row = df.iloc[-1]
|
||||
signal_snapshot = {
|
||||
"rsi": float(last_row["rsi"]) if "rsi" in last_row.index and pd.notna(last_row["rsi"]) else 0.0,
|
||||
"macd_hist": float(last_row["macd_hist"]) if "macd_hist" in last_row.index and pd.notna(last_row["macd_hist"]) else 0.0,
|
||||
"atr": float(last_row["atr"]) if "atr" in last_row.index and pd.notna(last_row["atr"]) else 0.0,
|
||||
}
|
||||
side = "BUY" if signal == "LONG" else "SELL"
|
||||
await self.exchange.set_margin_type("ISOLATED")
|
||||
await self.exchange.set_leverage(self.config.leverage)
|
||||
await self.exchange.place_order(side=side, quantity=quantity)
|
||||
|
||||
await self.risk.register_position(self.symbol, signal)
|
||||
self.current_trade_side = signal
|
||||
self._entry_price = price
|
||||
self._entry_quantity = quantity
|
||||
last_row = df.iloc[-1]
|
||||
signal_snapshot = {
|
||||
"rsi": float(last_row["rsi"]) if "rsi" in last_row.index and pd.notna(last_row["rsi"]) else 0.0,
|
||||
"macd_hist": float(last_row["macd_hist"]) if "macd_hist" in last_row.index and pd.notna(last_row["macd_hist"]) else 0.0,
|
||||
"atr": float(last_row["atr"]) if "atr" in last_row.index and pd.notna(last_row["atr"]) else 0.0,
|
||||
}
|
||||
|
||||
await self.risk.register_position(self.symbol, signal)
|
||||
self.current_trade_side = signal
|
||||
self._entry_price = price
|
||||
self._entry_quantity = quantity
|
||||
self._entry_time_ms = int(time.time() * 1000)
|
||||
self._sl_price = stop_loss
|
||||
self._tp_price = take_profit
|
||||
self.notifier.notify_open(
|
||||
symbol=self.symbol,
|
||||
side=signal,
|
||||
@@ -238,20 +519,108 @@ class TradingBot:
|
||||
)
|
||||
|
||||
sl_side = "SELL" if signal == "LONG" else "BUY"
|
||||
await self.exchange.place_order(
|
||||
side=sl_side,
|
||||
quantity=quantity,
|
||||
order_type="STOP_MARKET",
|
||||
stop_price=round(stop_loss, 4),
|
||||
reduce_only=True,
|
||||
)
|
||||
await self.exchange.place_order(
|
||||
side=sl_side,
|
||||
quantity=quantity,
|
||||
order_type="TAKE_PROFIT_MARKET",
|
||||
stop_price=round(take_profit, 4),
|
||||
reduce_only=True,
|
||||
)
|
||||
try:
|
||||
await self._place_sl_tp_with_retry(
|
||||
sl_side, quantity, stop_loss, take_profit
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[{self.symbol}] SL/TP 배치 최종 실패 — 긴급 청산: {e}"
|
||||
)
|
||||
await self._emergency_close(side, quantity)
|
||||
|
||||
_SL_TP_MAX_RETRIES = 3
|
||||
|
||||
async def _place_sl_tp_with_retry(
|
||||
self, sl_side: str, quantity: float, stop_loss: float, take_profit: float
|
||||
) -> None:
|
||||
"""SL/TP 주문을 재시도 로직과 함께 배치한다. 최종 실패 시 예외를 raise."""
|
||||
sl_placed = False
|
||||
tp_placed = False
|
||||
last_error = None
|
||||
|
||||
for attempt in range(1, self._SL_TP_MAX_RETRIES + 1):
|
||||
try:
|
||||
if not sl_placed:
|
||||
sl_result = await self.exchange.place_order(
|
||||
side=sl_side,
|
||||
quantity=quantity,
|
||||
order_type="STOP_MARKET",
|
||||
stop_price=self.exchange._round_price(stop_loss),
|
||||
reduce_only=True,
|
||||
)
|
||||
self._sl_order_id = sl_result.get("orderId") or sl_result.get("algoId")
|
||||
logger.info(f"[{self.symbol}] SL 주문 배치: id={self._sl_order_id}")
|
||||
sl_placed = True
|
||||
if not tp_placed:
|
||||
tp_result = await self.exchange.place_order(
|
||||
side=sl_side,
|
||||
quantity=quantity,
|
||||
order_type="TAKE_PROFIT_MARKET",
|
||||
stop_price=self.exchange._round_price(take_profit),
|
||||
reduce_only=True,
|
||||
)
|
||||
self._tp_order_id = tp_result.get("orderId") or tp_result.get("algoId")
|
||||
logger.info(f"[{self.symbol}] TP 주문 배치: id={self._tp_order_id}")
|
||||
tp_placed = True
|
||||
return # 둘 다 성공
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.warning(
|
||||
f"[{self.symbol}] SL/TP 배치 실패 (시도 {attempt}/{self._SL_TP_MAX_RETRIES}): {e}"
|
||||
)
|
||||
if attempt < self._SL_TP_MAX_RETRIES:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
raise last_error # 모든 재시도 실패
|
||||
|
||||
async def _emergency_close(self, entry_side: str, quantity: float) -> None:
|
||||
"""SL/TP 배치 실패 시 포지션을 긴급 시장가 청산한다."""
|
||||
try:
|
||||
close_side = "SELL" if entry_side == "BUY" else "BUY"
|
||||
await self.exchange.cancel_all_orders()
|
||||
await self.exchange.place_order(
|
||||
side=close_side, quantity=quantity, reduce_only=True
|
||||
)
|
||||
await self.risk.close_position(self.symbol, 0.0)
|
||||
self.current_trade_side = None
|
||||
self._entry_price = None
|
||||
self._entry_quantity = None
|
||||
self._sl_price = None
|
||||
self._tp_price = None
|
||||
self.notifier.notify_info(
|
||||
f"🚨 [{self.symbol}] SL/TP 배치 실패 → 긴급 청산 완료"
|
||||
)
|
||||
logger.warning(f"[{self.symbol}] 긴급 청산 완료")
|
||||
except Exception as e:
|
||||
logger.critical(
|
||||
f"[{self.symbol}] 긴급 청산마저 실패! 수동 개입 필요: {e}"
|
||||
)
|
||||
self.notifier.notify_info(
|
||||
f"🔴 [{self.symbol}] 긴급 청산 실패! 수동 청산 필요: {e}"
|
||||
)
|
||||
|
||||
async def _cancel_remaining_orders(self, reason: str = "") -> None:
|
||||
"""잔여 SL/TP 고아 주문을 저장된 주문 ID로 직접 취소한다."""
|
||||
ctx = f" ({reason})" if reason else ""
|
||||
cancelled = 0
|
||||
for label, oid in [("SL", self._sl_order_id), ("TP", self._tp_order_id)]:
|
||||
if oid is None:
|
||||
continue
|
||||
try:
|
||||
result = await self.exchange.cancel_order(oid)
|
||||
logger.info(
|
||||
f"[{self.symbol}] {label} 주문 취소 완료{ctx}: "
|
||||
f"id={oid} → status={result.get('status', 'N/A')}"
|
||||
)
|
||||
cancelled += 1
|
||||
except Exception as e:
|
||||
# 이미 체결/취소된 주문이면 무시
|
||||
logger.debug(f"[{self.symbol}] {label} 주문 취소 스킵{ctx}: id={oid}: {e}")
|
||||
self._sl_order_id = None
|
||||
self._tp_order_id = None
|
||||
if cancelled == 0:
|
||||
logger.info(f"[{self.symbol}] 취소할 잔여 주문 없음{ctx}")
|
||||
|
||||
def _calc_estimated_pnl(self, exit_price: float) -> float:
|
||||
"""진입가·수량 기반 예상 PnL 계산 (수수료 미반영)."""
|
||||
@@ -268,41 +637,139 @@ class TradingBot:
|
||||
exit_price: float,
|
||||
) -> None:
|
||||
"""User Data Stream에서 청산 감지 시 호출되는 콜백."""
|
||||
estimated_pnl = self._calc_estimated_pnl(exit_price)
|
||||
diff = net_pnl - estimated_pnl
|
||||
async with self._close_lock:
|
||||
# 이미 Flat 상태면 중복 처리 방지 (SYNC 또는 process_candle에서 먼저 처리됨)
|
||||
if self.current_trade_side is None and not self._is_reentering:
|
||||
logger.debug(f"[{self.symbol}] 이미 Flat 상태 — 콜백 건너뜀")
|
||||
self._close_event.set()
|
||||
return
|
||||
|
||||
await self.risk.close_position(self.symbol, net_pnl)
|
||||
# 실전 API에서 algo order는 ot=MARKET로 오므로 MANUAL로 판별됨
|
||||
# → SL/TP 가격과 exit_price 비교로 재판별
|
||||
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)
|
||||
close_reason = "SL" if sl_dist < tp_dist else "TP"
|
||||
logger.info(
|
||||
f"[{self.symbol}] close_reason 재판별: MANUAL → {close_reason} "
|
||||
f"(exit={exit_price:.4f}, SL={self._sl_price:.4f}, TP={self._tp_price:.4f})"
|
||||
)
|
||||
|
||||
self.notifier.notify_close(
|
||||
symbol=self.symbol,
|
||||
side=self.current_trade_side or "UNKNOWN",
|
||||
close_reason=close_reason,
|
||||
exit_price=exit_price,
|
||||
estimated_pnl=estimated_pnl,
|
||||
net_pnl=net_pnl,
|
||||
diff=diff,
|
||||
)
|
||||
estimated_pnl = self._calc_estimated_pnl(exit_price)
|
||||
diff = net_pnl - estimated_pnl
|
||||
|
||||
logger.success(
|
||||
f"[{self.symbol}] 포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
|
||||
f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT"
|
||||
)
|
||||
await self.risk.close_position(self.symbol, net_pnl)
|
||||
|
||||
# _close_and_reenter 중이면 신규 포지션 상태를 덮어쓰지 않는다
|
||||
if self._is_reentering:
|
||||
return
|
||||
self.notifier.notify_close(
|
||||
symbol=self.symbol,
|
||||
side=self.current_trade_side or "UNKNOWN",
|
||||
close_reason=close_reason,
|
||||
exit_price=exit_price,
|
||||
estimated_pnl=estimated_pnl,
|
||||
net_pnl=net_pnl,
|
||||
diff=diff,
|
||||
)
|
||||
|
||||
# Flat 상태로 초기화
|
||||
self.current_trade_side = None
|
||||
self._entry_price = None
|
||||
self._entry_quantity = None
|
||||
logger.success(
|
||||
f"[{self.symbol}] 포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
|
||||
f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT"
|
||||
)
|
||||
|
||||
# 거래 기록 저장 + 킬스위치 검사 (청산 후 항상 수행)
|
||||
self._append_trade(net_pnl, close_reason)
|
||||
self._check_kill_switch()
|
||||
|
||||
# _close_and_reenter 대기 해제
|
||||
self._close_event.set()
|
||||
|
||||
# _close_and_reenter 중이면 신규 포지션 상태를 덮어쓰지 않는다
|
||||
if self._is_reentering:
|
||||
return
|
||||
|
||||
# 잔여 SL/TP 고아 주문 취소
|
||||
await self._cancel_remaining_orders("UDS 청산 콜백")
|
||||
|
||||
# Flat 상태로 초기화
|
||||
self.current_trade_side = None
|
||||
self._entry_price = None
|
||||
self._entry_quantity = None
|
||||
self._entry_time_ms = None
|
||||
self._sl_price = None
|
||||
self._tp_price = None
|
||||
|
||||
_MONITOR_INTERVAL = 300 # 5분
|
||||
|
||||
async def _position_monitor(self):
|
||||
"""포지션 보유 중일 때 5분마다 현재가·미실현 PnL을 로깅한다."""
|
||||
"""포지션 보유 중일 때 5분마다 현재가·미실현 PnL을 로깅한다.
|
||||
또한 Binance API를 조회하여 WebSocket 이벤트 누락 시 청산을 감지한다."""
|
||||
while True:
|
||||
await asyncio.sleep(self._MONITOR_INTERVAL)
|
||||
|
||||
# ── 폴백: Binance API로 실제 포지션 상태 확인 ──
|
||||
if self.current_trade_side is not None and not self._is_reentering:
|
||||
try:
|
||||
actual_pos = await self.exchange.get_position()
|
||||
if actual_pos is None:
|
||||
async with self._close_lock:
|
||||
# Lock 획득 후 재확인 (콜백이 먼저 처리했을 수 있음)
|
||||
if self.current_trade_side is None:
|
||||
continue
|
||||
logger.warning(
|
||||
f"[{self.symbol}] 포지션 불일치 감지: "
|
||||
f"봇={self.current_trade_side}, 바이낸스=포지션 없음 — 상태 동기화"
|
||||
)
|
||||
# Binance income API에서 실제 PnL 조회
|
||||
realized_pnl = 0.0
|
||||
commission = 0.0
|
||||
exit_price = 0.0
|
||||
try:
|
||||
pnl_rows, comm_rows = await self.exchange.get_recent_income(
|
||||
limit=10, start_time=self._entry_time_ms,
|
||||
)
|
||||
if pnl_rows:
|
||||
realized_pnl = sum(float(r.get("income", "0")) for r in pnl_rows)
|
||||
if comm_rows:
|
||||
commission = sum(abs(float(r.get("income", "0"))) for r in comm_rows)
|
||||
except Exception:
|
||||
pass
|
||||
net_pnl = realized_pnl - commission
|
||||
# exit_price 추정: 진입가 + PnL/수량
|
||||
if self._entry_quantity and self._entry_quantity > 0 and self._entry_price:
|
||||
if self.current_trade_side == "LONG":
|
||||
exit_price = self._entry_price + realized_pnl / self._entry_quantity
|
||||
else:
|
||||
exit_price = self._entry_price - realized_pnl / self._entry_quantity
|
||||
|
||||
await self.risk.close_position(self.symbol, net_pnl)
|
||||
self.notifier.notify_close(
|
||||
symbol=self.symbol,
|
||||
side=self.current_trade_side,
|
||||
close_reason="SYNC",
|
||||
exit_price=exit_price,
|
||||
estimated_pnl=realized_pnl,
|
||||
net_pnl=net_pnl,
|
||||
diff=net_pnl - realized_pnl,
|
||||
)
|
||||
logger.info(
|
||||
f"[{self.symbol}] 청산 감지(SYNC): exit={exit_price:.4f}, "
|
||||
f"rp={realized_pnl:+.4f}, commission={commission:.4f}, "
|
||||
f"net_pnl={net_pnl:+.4f}"
|
||||
)
|
||||
self._append_trade(net_pnl, "SYNC")
|
||||
self._check_kill_switch()
|
||||
# 잔여 SL/TP 주문 취소
|
||||
await self._cancel_remaining_orders("SYNC 폴백")
|
||||
self.current_trade_side = None
|
||||
self._entry_price = None
|
||||
self._entry_quantity = None
|
||||
self._entry_time_ms = None
|
||||
self._sl_price = None
|
||||
self._tp_price = None
|
||||
self._close_event.set()
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(f"[{self.symbol}] 포지션 동기화 확인 실패 (무시): {e}")
|
||||
|
||||
if self.current_trade_side is None:
|
||||
continue
|
||||
price = self.stream.latest_price
|
||||
@@ -340,9 +807,32 @@ class TradingBot:
|
||||
"""기존 포지션을 청산하고, ML 필터 통과 시 반대 방향으로 즉시 재진입한다."""
|
||||
# 재진입 플래그: User Data Stream 콜백이 신규 포지션 상태를 초기화하지 않도록 보호
|
||||
self._is_reentering = True
|
||||
self._close_event.clear()
|
||||
try:
|
||||
await self._close_position(position)
|
||||
|
||||
# 콜백이 PnL을 기록할 때까지 대기 (최대 10초)
|
||||
try:
|
||||
await asyncio.wait_for(self._close_event.wait(), timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"[{self.symbol}] 청산 콜백 타임아웃 — 수동 동기화")
|
||||
await self.risk.close_position(self.symbol, 0.0)
|
||||
|
||||
# 로컬 상태를 Flat으로 전환
|
||||
self.current_trade_side = None
|
||||
self._entry_price = None
|
||||
self._entry_quantity = None
|
||||
self._entry_time_ms = None
|
||||
self._sl_price = None
|
||||
self._tp_price = None
|
||||
|
||||
# 잔여 SL/TP 주문 취소 확인 (_close_position에서 cancel_all 호출하지만 검증)
|
||||
await self._cancel_remaining_orders("재진입 전 검증")
|
||||
|
||||
if self._killed:
|
||||
logger.info(f"[{self.symbol}] 킬스위치 활성 — 재진입 건너뜀 (청산만 수행)")
|
||||
return
|
||||
|
||||
if not await self.risk.can_open_new_position(self.symbol, signal):
|
||||
logger.info(f"[{self.symbol}] 최대 포지션 수 도달 — 재진입 건너뜀")
|
||||
return
|
||||
@@ -353,6 +843,8 @@ class TradingBot:
|
||||
btc_df=btc_df, eth_df=eth_df,
|
||||
oi_change=oi_change, funding_rate=funding_rate,
|
||||
oi_change_ma5=oi_change_ma5, oi_price_spread=oi_price_spread,
|
||||
oi_history=list(self._oi_history),
|
||||
funding_history=list(self._funding_history),
|
||||
)
|
||||
if not self.ml_filter.should_enter(features):
|
||||
logger.info(f"[{self.symbol}] ML 필터 차단: {signal} 재진입 무시")
|
||||
@@ -363,12 +855,25 @@ class TradingBot:
|
||||
self._is_reentering = False
|
||||
|
||||
async def run(self):
|
||||
logger.info(f"[{self.symbol}] 봇 시작, 레버리지 {self.config.leverage}x")
|
||||
if self.config.testnet:
|
||||
logger.warning("⚠️ TESTNET MODE ENABLED — 실제 자금이 아닌 테스트넷에서 실행 중")
|
||||
|
||||
s = self.strategy
|
||||
logger.info(
|
||||
f"[{self.symbol}] 봇 시작, 레버리지 {self.config.leverage}x | "
|
||||
f"SL={s.atr_sl_mult}x TP={s.atr_tp_mult}x Signal≥{s.signal_threshold} "
|
||||
f"ADX≥{s.adx_threshold} Vol≥{s.volume_multiplier}x"
|
||||
)
|
||||
await self._recover_position()
|
||||
await self._init_oi_history()
|
||||
balance = await self.exchange.get_balance()
|
||||
self.risk.set_base_balance(balance)
|
||||
logger.info(f"[{self.symbol}] 기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)")
|
||||
|
||||
# 봇 시작 시 포지션 없으면 고아 주문 정리 (저장된 ID 없으므로 cancel_all 사용)
|
||||
if self.current_trade_side is None:
|
||||
try:
|
||||
result = await self.exchange.cancel_all_orders()
|
||||
logger.info(f"[{self.symbol}] 봇 시작 — cancel_all_orders 응답: {result}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.symbol}] 봇 시작 — 주문 취소 실패: {e}")
|
||||
|
||||
user_stream = UserDataStream(
|
||||
symbol=self.symbol,
|
||||
@@ -379,10 +884,12 @@ class TradingBot:
|
||||
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(),
|
||||
)
|
||||
|
||||
@@ -5,6 +5,16 @@ from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SymbolStrategyParams:
|
||||
"""Per-symbol strategy parameters (from sweep optimization)."""
|
||||
atr_sl_mult: float = 2.0
|
||||
atr_tp_mult: float = 2.0
|
||||
signal_threshold: int = 3
|
||||
adx_threshold: float = 25.0
|
||||
volume_multiplier: float = 2.5
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
api_key: str = ""
|
||||
@@ -15,9 +25,6 @@ class Config:
|
||||
leverage: int = 10
|
||||
max_positions: int = 3
|
||||
max_same_direction: int = 2
|
||||
stop_loss_pct: float = 0.015 # 1.5%
|
||||
take_profit_pct: float = 0.045 # 4.5% (3:1 RR)
|
||||
trailing_stop_pct: float = 0.01 # 1%
|
||||
discord_webhook_url: str = ""
|
||||
margin_max_ratio: float = 0.50
|
||||
margin_min_ratio: float = 0.20
|
||||
@@ -28,10 +35,18 @@ class Config:
|
||||
signal_threshold: int = 3
|
||||
adx_threshold: float = 25.0
|
||||
volume_multiplier: float = 2.5
|
||||
kline_interval: str = "15m"
|
||||
testnet: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
self.api_key = os.getenv("BINANCE_API_KEY", "")
|
||||
self.api_secret = os.getenv("BINANCE_API_SECRET", "")
|
||||
self.testnet = os.getenv("BINANCE_TESTNET", "").lower() in ("true", "1", "yes")
|
||||
|
||||
if self.testnet:
|
||||
self.api_key = os.getenv("BINANCE_DEMO_API_KEY", "")
|
||||
self.api_secret = os.getenv("BINANCE_DEMO_API_SECRET", "")
|
||||
else:
|
||||
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", "")
|
||||
@@ -45,6 +60,7 @@ class Config:
|
||||
self.signal_threshold = int(os.getenv("SIGNAL_THRESHOLD", "3"))
|
||||
self.adx_threshold = float(os.getenv("ADX_THRESHOLD", "25"))
|
||||
self.volume_multiplier = float(os.getenv("VOL_MULTIPLIER", "2.5"))
|
||||
self.kline_interval = os.getenv("KLINE_INTERVAL", "15m")
|
||||
|
||||
# symbols: SYMBOLS 환경변수 우선, 없으면 SYMBOL에서 변환
|
||||
symbols_env = os.getenv("SYMBOLS", "")
|
||||
@@ -57,3 +73,36 @@ class Config:
|
||||
corr_env = os.getenv("CORRELATION_SYMBOLS", "BTCUSDT,ETHUSDT")
|
||||
self.correlation_symbols = [s.strip() for s in corr_env.split(",") if s.strip()]
|
||||
|
||||
# 입력 검증
|
||||
if self.leverage < 1:
|
||||
raise ValueError(f"LEVERAGE는 1 이상이어야 합니다: {self.leverage}")
|
||||
if not (0.0 < self.margin_max_ratio <= 1.0):
|
||||
raise ValueError(f"MARGIN_MAX_RATIO는 (0, 1] 범위여야 합니다: {self.margin_max_ratio}")
|
||||
if not (0.0 < self.margin_min_ratio <= 1.0):
|
||||
raise ValueError(f"MARGIN_MIN_RATIO는 (0, 1] 범위여야 합니다: {self.margin_min_ratio}")
|
||||
if self.margin_min_ratio > self.margin_max_ratio:
|
||||
raise ValueError(f"MARGIN_MIN_RATIO({self.margin_min_ratio}) > MARGIN_MAX_RATIO({self.margin_max_ratio})")
|
||||
if not (0.0 < self.ml_threshold <= 1.0):
|
||||
raise ValueError(f"ML_THRESHOLD는 (0, 1] 범위여야 합니다: {self.ml_threshold}")
|
||||
|
||||
# Per-symbol strategy params: {symbol: SymbolStrategyParams}
|
||||
self._symbol_params: dict[str, SymbolStrategyParams] = {}
|
||||
for sym in self.symbols:
|
||||
self._symbol_params[sym] = SymbolStrategyParams(
|
||||
atr_sl_mult=float(os.getenv(f"ATR_SL_MULT_{sym}", str(self.atr_sl_mult))),
|
||||
atr_tp_mult=float(os.getenv(f"ATR_TP_MULT_{sym}", str(self.atr_tp_mult))),
|
||||
signal_threshold=int(os.getenv(f"SIGNAL_THRESHOLD_{sym}", str(self.signal_threshold))),
|
||||
adx_threshold=float(os.getenv(f"ADX_THRESHOLD_{sym}", str(self.adx_threshold))),
|
||||
volume_multiplier=float(os.getenv(f"VOL_MULTIPLIER_{sym}", str(self.volume_multiplier))),
|
||||
)
|
||||
|
||||
def get_symbol_params(self, symbol: str) -> SymbolStrategyParams:
|
||||
"""Get strategy params for a symbol. Falls back to global defaults."""
|
||||
return self._symbol_params.get(symbol, SymbolStrategyParams(
|
||||
atr_sl_mult=self.atr_sl_mult,
|
||||
atr_tp_mult=self.atr_tp_mult,
|
||||
signal_threshold=self.signal_threshold,
|
||||
adx_threshold=self.adx_threshold,
|
||||
volume_multiplier=self.volume_multiplier,
|
||||
))
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@ from loguru import logger
|
||||
_MIN_CANDLES_FOR_SIGNAL = 100
|
||||
|
||||
# 초기 구동 시 REST API로 가져올 과거 캔들 수.
|
||||
# 15분봉 200개 = 50시간치 — EMA50(12.5h) 대비 4배 여유.
|
||||
_PRELOAD_LIMIT = 200
|
||||
# z-score 윈도우(288) + EMA50(50) 안정화 여유분. 15분봉 300개 = 75시간.
|
||||
_PRELOAD_LIMIT = 300
|
||||
|
||||
_RECONNECT_DELAY = 5 # WebSocket 재연결 대기 초
|
||||
|
||||
|
||||
|
||||
@@ -75,10 +77,11 @@ class KlineStream:
|
||||
})
|
||||
logger.info(f"과거 캔들 {len(self.buffer)}개 로드 완료 — 즉시 신호 계산 가능")
|
||||
|
||||
async def start(self, api_key: str, api_secret: str):
|
||||
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,
|
||||
demo=testnet,
|
||||
)
|
||||
await self._preload_history(client)
|
||||
bm = BinanceSocketManager(client)
|
||||
@@ -105,7 +108,7 @@ class MultiSymbolStream:
|
||||
self,
|
||||
symbols: list[str],
|
||||
interval: str = "15m",
|
||||
buffer_size: int = 200,
|
||||
buffer_size: int = 300,
|
||||
on_candle: Callable = None,
|
||||
):
|
||||
self.symbols = [s.lower() for s in symbols]
|
||||
@@ -161,31 +164,37 @@ class MultiSymbolStream:
|
||||
df.set_index("timestamp", inplace=True)
|
||||
return df
|
||||
|
||||
async def _preload_history(self, client: AsyncClient, limit: int = _PRELOAD_LIMIT):
|
||||
"""REST API로 모든 심볼의 과거 캔들을 버퍼에 미리 채운다."""
|
||||
for symbol in self.symbols:
|
||||
logger.info(f"{symbol.upper()} 과거 캔들 {limit}개 로드 중...")
|
||||
klines = await client.futures_klines(
|
||||
symbol=symbol.upper(),
|
||||
interval=self.interval,
|
||||
limit=limit,
|
||||
)
|
||||
for k in klines[:-1]:
|
||||
self.buffers[symbol].append({
|
||||
"timestamp": k[0],
|
||||
"open": float(k[1]),
|
||||
"high": float(k[2]),
|
||||
"low": float(k[3]),
|
||||
"close": float(k[4]),
|
||||
"volume": float(k[5]),
|
||||
"is_closed": True,
|
||||
})
|
||||
logger.info(f"{symbol.upper()} {len(self.buffers[symbol])}개 로드 완료")
|
||||
async def _preload_one(self, client: AsyncClient, symbol: str, limit: int):
|
||||
"""단일 심볼의 과거 캔들을 버퍼에 채운다."""
|
||||
logger.info(f"{symbol.upper()} 과거 캔들 {limit}개 로드 중...")
|
||||
klines = await client.futures_klines(
|
||||
symbol=symbol.upper(),
|
||||
interval=self.interval,
|
||||
limit=limit,
|
||||
)
|
||||
for k in klines[:-1]:
|
||||
self.buffers[symbol].append({
|
||||
"timestamp": k[0],
|
||||
"open": float(k[1]),
|
||||
"high": float(k[2]),
|
||||
"low": float(k[3]),
|
||||
"close": float(k[4]),
|
||||
"volume": float(k[5]),
|
||||
"is_closed": True,
|
||||
})
|
||||
logger.info(f"{symbol.upper()} {len(self.buffers[symbol])}개 로드 완료")
|
||||
|
||||
async def start(self, api_key: str, api_secret: str):
|
||||
async def _preload_history(self, client: AsyncClient, limit: int = _PRELOAD_LIMIT):
|
||||
"""REST API로 모든 심볼의 과거 캔들을 병렬로 버퍼에 미리 채운다."""
|
||||
await asyncio.gather(*[
|
||||
self._preload_one(client, symbol, limit) for symbol in self.symbols
|
||||
])
|
||||
|
||||
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,
|
||||
demo=testnet,
|
||||
)
|
||||
await self._preload_history(client)
|
||||
bm = BinanceSocketManager(client)
|
||||
@@ -194,9 +203,34 @@ class MultiSymbolStream:
|
||||
]
|
||||
logger.info(f"Combined WebSocket 시작: {streams}")
|
||||
try:
|
||||
async with bm.futures_multiplex_socket(streams) as stream:
|
||||
while True:
|
||||
msg = await stream.recv()
|
||||
await self.handle_message(msg)
|
||||
await self._run_loop(bm, streams)
|
||||
finally:
|
||||
await client.close_connection()
|
||||
|
||||
async def _run_loop(self, bm: BinanceSocketManager, streams: list[str]) -> None:
|
||||
"""WebSocket 연결 → 재연결 무한 루프."""
|
||||
while True:
|
||||
try:
|
||||
async with bm.futures_multiplex_socket(streams) as stream:
|
||||
logger.info("Kline WebSocket 연결 완료")
|
||||
while True:
|
||||
msg = await stream.recv()
|
||||
|
||||
if isinstance(msg, dict) and msg.get("e") == "error":
|
||||
logger.warning(
|
||||
f"Kline WebSocket 에러 수신: {msg.get('m', msg)} — 재연결"
|
||||
)
|
||||
break
|
||||
|
||||
await self.handle_message(msg)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Kline WebSocket 정상 종료")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Kline WebSocket 끊김: {e} — "
|
||||
f"{_RECONNECT_DELAY}초 후 재연결"
|
||||
)
|
||||
await asyncio.sleep(_RECONNECT_DELAY)
|
||||
|
||||
@@ -12,10 +12,19 @@ import pandas_ta as ta
|
||||
from src.ml_features import FEATURE_COLS
|
||||
|
||||
LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 뷰
|
||||
ATR_SL_MULT = 1.5
|
||||
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)
|
||||
|
||||
|
||||
def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""전체 시계열에 기술 지표를 1회 계산한다."""
|
||||
@@ -56,9 +65,9 @@ def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
||||
|
||||
def _calc_signals(
|
||||
d: pd.DataFrame,
|
||||
signal_threshold: int = 3,
|
||||
adx_threshold: float = 25,
|
||||
volume_multiplier: float = 2.5,
|
||||
signal_threshold: int = TRAIN_SIGNAL_THRESHOLD,
|
||||
adx_threshold: float = TRAIN_ADX_THRESHOLD,
|
||||
volume_multiplier: float = TRAIN_VOLUME_MULTIPLIER,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
indicators.py get_signal() 로직을 numpy 배열 연산으로 재현한다.
|
||||
@@ -266,15 +275,15 @@ def _calc_features_vectorized(
|
||||
eth_r3 = _align(eth_ret_3, n).astype(np.float32)
|
||||
eth_r5 = _align(eth_ret_5, n).astype(np.float32)
|
||||
|
||||
xrp_r1 = ret_1.astype(np.float32)
|
||||
xrp_btc_rs_raw = np.divide(
|
||||
xrp_r1, btc_r1,
|
||||
out=np.zeros_like(xrp_r1),
|
||||
primary_r1 = ret_1.astype(np.float32)
|
||||
primary_btc_rs_raw = np.divide(
|
||||
primary_r1, btc_r1,
|
||||
out=np.zeros_like(primary_r1),
|
||||
where=(btc_r1 != 0),
|
||||
).astype(np.float32)
|
||||
xrp_eth_rs_raw = np.divide(
|
||||
xrp_r1, eth_r1,
|
||||
out=np.zeros_like(xrp_r1),
|
||||
primary_eth_rs_raw = np.divide(
|
||||
primary_r1, eth_r1,
|
||||
out=np.zeros_like(primary_r1),
|
||||
where=(eth_r1 != 0),
|
||||
).astype(np.float32)
|
||||
|
||||
@@ -285,8 +294,8 @@ def _calc_features_vectorized(
|
||||
"eth_ret_1": _rolling_zscore(eth_r1),
|
||||
"eth_ret_3": _rolling_zscore(eth_r3),
|
||||
"eth_ret_5": _rolling_zscore(eth_r5),
|
||||
"xrp_btc_rs": _rolling_zscore(xrp_btc_rs_raw),
|
||||
"xrp_eth_rs": _rolling_zscore(xrp_eth_rs_raw),
|
||||
"primary_btc_rs": _rolling_zscore(primary_btc_rs_raw),
|
||||
"primary_eth_rs": _rolling_zscore(primary_eth_rs_raw),
|
||||
}, index=d.index)
|
||||
result = pd.concat([result, extra], axis=1)
|
||||
|
||||
@@ -323,6 +332,8 @@ 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]:
|
||||
"""
|
||||
label_builder.py build_labels() 로직을 numpy 2D 배열로 벡터화한다.
|
||||
@@ -348,11 +359,11 @@ def _calc_labels_vectorized(
|
||||
continue
|
||||
|
||||
if signal == "LONG":
|
||||
sl = entry - atr * ATR_SL_MULT
|
||||
tp = entry + atr * ATR_TP_MULT
|
||||
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
|
||||
sl = entry + atr * atr_sl_mult
|
||||
tp = entry - atr * atr_tp_mult
|
||||
|
||||
end = min(idx + 1 + LOOKAHEAD, n_total)
|
||||
fut_high = highs[idx + 1 : end]
|
||||
@@ -387,10 +398,12 @@ def generate_dataset_vectorized(
|
||||
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,
|
||||
negative_ratio: int = TRAIN_NEGATIVE_RATIO,
|
||||
signal_threshold: int = TRAIN_SIGNAL_THRESHOLD,
|
||||
adx_threshold: float = TRAIN_ADX_THRESHOLD,
|
||||
volume_multiplier: float = TRAIN_VOLUME_MULTIPLIER,
|
||||
atr_sl_mult: float = ATR_SL_MULT,
|
||||
atr_tp_mult: float = ATR_TP_MULT,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
전체 시계열을 1회 계산해 학습 데이터셋을 생성한다.
|
||||
@@ -435,7 +448,10 @@ def generate_dataset_vectorized(
|
||||
print(f" 신호 발생 인덱스: {len(sig_idx):,}개")
|
||||
|
||||
print(" [3/3] 레이블 계산...")
|
||||
labels, valid_mask = _calc_labels_vectorized(d, feat_all, sig_idx)
|
||||
labels, valid_mask = _calc_labels_vectorized(
|
||||
d, feat_all, sig_idx,
|
||||
atr_sl_mult=atr_sl_mult, atr_tp_mult=atr_tp_mult,
|
||||
)
|
||||
|
||||
final_sig_idx = sig_idx[valid_mask]
|
||||
available_feature_cols = [c for c in FEATURE_COLS if c in feat_all.columns]
|
||||
|
||||
256
src/exchange.py
256
src/exchange.py
@@ -1,4 +1,7 @@
|
||||
import asyncio
|
||||
import math
|
||||
import threading
|
||||
import time as _time
|
||||
from binance.client import Client
|
||||
from binance.exceptions import BinanceAPIException
|
||||
from loguru import logger
|
||||
@@ -6,48 +9,127 @@ from src.config import Config
|
||||
|
||||
|
||||
class BinanceFuturesClient:
|
||||
# 클래스 레벨 exchange info 캐시 (TTL 24시간)
|
||||
_exchange_info_cache: dict | None = None
|
||||
_exchange_info_time: float = 0.0
|
||||
_EXCHANGE_INFO_TTL: float = 86400.0 # 24시간
|
||||
|
||||
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,
|
||||
demo=config.testnet,
|
||||
)
|
||||
self._qty_precision: int | None = None
|
||||
self._price_precision: int | None = None
|
||||
self._api_lock = threading.Lock() # requests.Session 스레드 안전성 보장
|
||||
|
||||
MIN_NOTIONAL = 5.0 # 바이낸스 선물 최소 명목금액 (USDT)
|
||||
|
||||
async def _run_api(self, func):
|
||||
"""동기 API 호출을 스레드 풀에서 실행하되, _api_lock으로 직렬화한다."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, lambda: self._call_with_lock(func),
|
||||
)
|
||||
|
||||
def _call_with_lock(self, func):
|
||||
with self._api_lock:
|
||||
return func()
|
||||
|
||||
@classmethod
|
||||
def _get_exchange_info(cls, client: Client) -> dict | None:
|
||||
"""exchange info를 클래스 레벨로 캐시한다 (TTL 24시간)."""
|
||||
now = _time.monotonic()
|
||||
if cls._exchange_info_cache is None or (now - cls._exchange_info_time) > cls._EXCHANGE_INFO_TTL:
|
||||
try:
|
||||
cls._exchange_info_cache = client.futures_exchange_info()
|
||||
cls._exchange_info_time = now
|
||||
except Exception as e:
|
||||
logger.warning(f"exchange info 조회 실패: {e}")
|
||||
return cls._exchange_info_cache # 만료돼도 기존 캐시 반환
|
||||
return cls._exchange_info_cache
|
||||
|
||||
def _load_symbol_precision(self) -> None:
|
||||
"""바이낸스 exchange info에서 심볼별 수량/가격 정밀도를 로드한다."""
|
||||
info = self._get_exchange_info(self.client)
|
||||
if info is not None:
|
||||
for s in info["symbols"]:
|
||||
if s["symbol"] == self.symbol:
|
||||
self._qty_precision = s.get("quantityPrecision", 1)
|
||||
self._price_precision = s.get("pricePrecision", 2)
|
||||
logger.info(
|
||||
f"[{self.symbol}] 정밀도 로드: qty={self._qty_precision}, price={self._price_precision}"
|
||||
)
|
||||
return
|
||||
logger.warning(f"[{self.symbol}] exchange info에서 심볼 미발견, 기본 정밀도 사용")
|
||||
self._qty_precision = 1
|
||||
self._price_precision = 2
|
||||
|
||||
@property
|
||||
def qty_precision(self) -> int:
|
||||
if self._qty_precision is None:
|
||||
self._load_symbol_precision()
|
||||
return self._qty_precision
|
||||
|
||||
@property
|
||||
def price_precision(self) -> int:
|
||||
if self._price_precision is None:
|
||||
self._load_symbol_precision()
|
||||
return self._price_precision
|
||||
|
||||
def _round_qty(self, qty: float) -> float:
|
||||
"""심볼의 quantityPrecision에 맞춰 수량을 내림(truncate)한다."""
|
||||
p = self.qty_precision
|
||||
factor = 10 ** p
|
||||
return math.floor(qty * factor) / factor
|
||||
|
||||
def _round_price(self, price: float) -> float:
|
||||
"""심볼의 pricePrecision에 맞춰 가격을 반올림한다."""
|
||||
return round(price, self.price_precision)
|
||||
|
||||
def calculate_quantity(self, balance: float, price: float, leverage: int, margin_ratio: float) -> float:
|
||||
"""동적 증거금 비율 기반 포지션 크기 계산 (최소 명목금액 $5 보장)"""
|
||||
notional = balance * margin_ratio * leverage
|
||||
if notional < self.MIN_NOTIONAL:
|
||||
notional = self.MIN_NOTIONAL
|
||||
quantity = notional / price
|
||||
qty_rounded = round(quantity, 1)
|
||||
qty_rounded = self._round_qty(quantity)
|
||||
if qty_rounded * price < self.MIN_NOTIONAL:
|
||||
qty_rounded = round(self.MIN_NOTIONAL / price + 0.05, 1)
|
||||
qty_rounded = self._round_qty(self.MIN_NOTIONAL / price + 10 ** -self.qty_precision)
|
||||
return qty_rounded
|
||||
|
||||
async def set_leverage(self, leverage: int) -> dict:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
return await self._run_api(
|
||||
lambda: self.client.futures_change_leverage(
|
||||
symbol=self.symbol, leverage=leverage
|
||||
),
|
||||
)
|
||||
|
||||
async def set_margin_type(self, margin_type: str = "ISOLATED") -> None:
|
||||
"""마진 타입을 변경한다. 이미 동일 타입이면 무시."""
|
||||
try:
|
||||
await self._run_api(
|
||||
lambda: self.client.futures_change_margin_type(
|
||||
symbol=self.symbol, marginType=margin_type
|
||||
),
|
||||
)
|
||||
logger.info(f"[{self.symbol}] 마진 타입 변경: {margin_type}")
|
||||
except BinanceAPIException as e:
|
||||
if e.code == -4046: # "No need to change margin type."
|
||||
logger.debug(f"[{self.symbol}] 마진 타입 이미 {margin_type}")
|
||||
else:
|
||||
raise
|
||||
|
||||
async def get_balance(self) -> float:
|
||||
loop = asyncio.get_event_loop()
|
||||
balances = await loop.run_in_executor(
|
||||
None, self.client.futures_account_balance
|
||||
)
|
||||
balances = await self._run_api(self.client.futures_account_balance)
|
||||
for b in balances:
|
||||
if b["asset"] == "USDT":
|
||||
return float(b["balance"])
|
||||
return 0.0
|
||||
|
||||
_ALGO_ORDER_TYPES = {"STOP_MARKET", "TAKE_PROFIT_MARKET", "STOP", "TAKE_PROFIT", "TRAILING_STOP_MARKET"}
|
||||
|
||||
async def place_order(
|
||||
self,
|
||||
side: str,
|
||||
@@ -57,17 +139,6 @@ class BinanceFuturesClient:
|
||||
stop_price: float = None,
|
||||
reduce_only: bool = False,
|
||||
) -> dict:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
if order_type in self._ALGO_ORDER_TYPES:
|
||||
return await self._place_algo_order(
|
||||
side=side,
|
||||
quantity=quantity,
|
||||
order_type=order_type,
|
||||
stop_price=stop_price,
|
||||
reduce_only=reduce_only,
|
||||
)
|
||||
|
||||
params = dict(
|
||||
symbol=self.symbol,
|
||||
side=side,
|
||||
@@ -75,51 +146,21 @@ class BinanceFuturesClient:
|
||||
quantity=quantity,
|
||||
reduceOnly=reduce_only,
|
||||
)
|
||||
if price:
|
||||
if price is not None:
|
||||
params["price"] = price
|
||||
params["timeInForce"] = "GTC"
|
||||
if stop_price:
|
||||
if stop_price is not None:
|
||||
params["stopPrice"] = stop_price
|
||||
try:
|
||||
return await loop.run_in_executor(
|
||||
None, lambda: self.client.futures_create_order(**params)
|
||||
return await self._run_api(
|
||||
lambda: self.client.futures_create_order(**params)
|
||||
)
|
||||
except BinanceAPIException as e:
|
||||
logger.error(f"주문 실패: {e}")
|
||||
raise
|
||||
|
||||
async def _place_algo_order(
|
||||
self,
|
||||
side: str,
|
||||
quantity: float,
|
||||
order_type: str,
|
||||
stop_price: float = None,
|
||||
reduce_only: bool = False,
|
||||
) -> dict:
|
||||
"""STOP_MARKET / TAKE_PROFIT_MARKET 등 Algo Order API(/fapi/v1/algoOrder)로 전송."""
|
||||
loop = asyncio.get_event_loop()
|
||||
params = dict(
|
||||
symbol=self.symbol,
|
||||
side=side,
|
||||
algoType="CONDITIONAL",
|
||||
type=order_type,
|
||||
quantity=quantity,
|
||||
reduceOnly="true" if reduce_only else "false",
|
||||
)
|
||||
if stop_price:
|
||||
params["triggerPrice"] = stop_price
|
||||
try:
|
||||
return await loop.run_in_executor(
|
||||
None, lambda: self.client.futures_create_algo_order(**params)
|
||||
)
|
||||
except BinanceAPIException as e:
|
||||
logger.error(f"Algo 주문 실패: {e}")
|
||||
raise
|
||||
|
||||
async def get_position(self) -> dict | None:
|
||||
loop = asyncio.get_event_loop()
|
||||
positions = await loop.run_in_executor(
|
||||
None,
|
||||
positions = await self._run_api(
|
||||
lambda: self.client.futures_position_information(
|
||||
symbol=self.symbol
|
||||
),
|
||||
@@ -129,31 +170,85 @@ class BinanceFuturesClient:
|
||||
return p
|
||||
return None
|
||||
|
||||
async def get_open_orders(self) -> list[dict]:
|
||||
"""현재 심볼의 오픈 주문 + algo 주문을 병합 반환한다."""
|
||||
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_open_algo_orders(symbol=self.symbol)
|
||||
)
|
||||
for ao in algo_orders.get("orders", []):
|
||||
orders.append({
|
||||
"orderId": ao.get("algoId"),
|
||||
"type": ao.get("orderType"),
|
||||
"stopPrice": ao.get("triggerPrice"),
|
||||
"side": ao.get("side"),
|
||||
"status": ao.get("algoStatus"),
|
||||
"_is_algo": True,
|
||||
})
|
||||
except Exception:
|
||||
pass # algo 주문 없으면 실패 가능
|
||||
return orders
|
||||
|
||||
async def cancel_all_orders(self):
|
||||
"""일반 오픈 주문과 Algo 오픈 주문을 모두 취소한다."""
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
"""일반 주문 + algo 주문을 모두 취소한다."""
|
||||
await self._run_api(
|
||||
lambda: self.client.futures_cancel_all_open_orders(
|
||||
symbol=self.symbol
|
||||
),
|
||||
)
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.client.futures_cancel_all_algo_open_orders(
|
||||
symbol=self.symbol
|
||||
await self._run_api(
|
||||
lambda: self.client.futures_cancel_all_algo_open_orders(symbol=self.symbol)
|
||||
)
|
||||
except Exception:
|
||||
pass # algo 주문 없으면 실패 가능
|
||||
|
||||
async def cancel_order(self, order_id: int):
|
||||
"""개별 주문을 취소한다. 일반 주문 실패 시 algo 주문으로 재시도."""
|
||||
try:
|
||||
return await self._run_api(
|
||||
lambda: self.client.futures_cancel_order(
|
||||
symbol=self.symbol, orderId=order_id
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
# Algo order (데모 API의 조건부 주문) 취소 시도
|
||||
return await self._run_api(
|
||||
lambda: self.client.futures_cancel_algo_order(algoId=order_id),
|
||||
)
|
||||
|
||||
async def get_recent_income(self, limit: int = 5, start_time: int | None = None) -> tuple[list[dict], list[dict]]:
|
||||
"""최근 REALIZED_PNL + COMMISSION 내역을 조회한다.
|
||||
|
||||
Args:
|
||||
limit: 최대 조회 건수
|
||||
start_time: 밀리초 단위 시작 시각. 지정 시 해당 시각 이후 데이터만 반환.
|
||||
"""
|
||||
try:
|
||||
pnl_params = dict(symbol=self.symbol, incomeType="REALIZED_PNL", limit=limit)
|
||||
comm_params = dict(symbol=self.symbol, incomeType="COMMISSION", limit=limit)
|
||||
if start_time is not None:
|
||||
pnl_params["startTime"] = start_time
|
||||
comm_params["startTime"] = start_time
|
||||
|
||||
rows = await self._run_api(
|
||||
lambda: self.client.futures_income_history(**pnl_params),
|
||||
)
|
||||
commissions = await self._run_api(
|
||||
lambda: self.client.futures_income_history(**comm_params),
|
||||
)
|
||||
return rows, commissions
|
||||
except Exception as e:
|
||||
logger.warning(f"Algo 주문 전체 취소 실패 (무시): {e}")
|
||||
logger.warning(f"[{self.symbol}] 수익 내역 조회 실패: {e}")
|
||||
return [], []
|
||||
|
||||
async def get_open_interest(self) -> float | None:
|
||||
"""현재 미결제약정(OI)을 조회한다. 오류 시 None 반환."""
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
result = await self._run_api(
|
||||
lambda: self.client.futures_open_interest(symbol=self.symbol),
|
||||
)
|
||||
return float(result["openInterest"])
|
||||
@@ -163,10 +258,8 @@ class BinanceFuturesClient:
|
||||
|
||||
async def get_funding_rate(self) -> float | None:
|
||||
"""현재 펀딩비를 조회한다. 오류 시 None 반환."""
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
result = await self._run_api(
|
||||
lambda: self.client.futures_mark_price(symbol=self.symbol),
|
||||
)
|
||||
return float(result["lastFundingRate"])
|
||||
@@ -176,10 +269,8 @@ class BinanceFuturesClient:
|
||||
|
||||
async def get_oi_history(self, limit: int = 5) -> list[float]:
|
||||
"""최근 OI 변화율 히스토리를 조회한다 (봇 초기화용). 실패 시 빈 리스트."""
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
result = await self._run_api(
|
||||
lambda: self.client.futures_open_interest_hist(
|
||||
symbol=self.symbol, period="15m", limit=limit + 1,
|
||||
),
|
||||
@@ -200,27 +291,18 @@ class BinanceFuturesClient:
|
||||
|
||||
async def create_listen_key(self) -> str:
|
||||
"""POST /fapi/v1/listenKey — listenKey 신규 발급"""
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.client.futures_stream_get_listen_key(),
|
||||
)
|
||||
return result
|
||||
return await self._run_api(self.client.futures_stream_get_listen_key)
|
||||
|
||||
async def keepalive_listen_key(self, listen_key: str) -> None:
|
||||
"""PUT /fapi/v1/listenKey — listenKey 만료 연장 (60분 → 리셋)"""
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
await self._run_api(
|
||||
lambda: self.client.futures_stream_keepalive(listenKey=listen_key),
|
||||
)
|
||||
|
||||
async def delete_listen_key(self, listen_key: str) -> None:
|
||||
"""DELETE /fapi/v1/listenKey — listenKey 삭제 (정상 종료 시)"""
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
await self._run_api(
|
||||
lambda: self.client.futures_stream_close(listenKey=listen_key),
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -58,23 +58,29 @@ class Indicators:
|
||||
signal_threshold: int = 3,
|
||||
adx_threshold: float = 25,
|
||||
volume_multiplier: float = 2.5,
|
||||
) -> str:
|
||||
) -> tuple[str, dict]:
|
||||
"""
|
||||
복합 지표 기반 매매 신호 생성.
|
||||
|
||||
signal_threshold: 최소 가중치 합계 (기본 3)
|
||||
adx_threshold: ADX 최소값 필터 (0=비활성화, 25=ADX<25이면 HOLD)
|
||||
volume_multiplier: 거래량 급증 배수 (기본 1.5)
|
||||
|
||||
Returns:
|
||||
(signal, detail) — signal은 "LONG"/"SHORT"/"HOLD",
|
||||
detail은 {"long": int, "short": int, "vol_surge": bool, "adx": float|None, "hold_reason": str}
|
||||
"""
|
||||
last = df.iloc[-1]
|
||||
prev = df.iloc[-2]
|
||||
|
||||
# ADX 필터
|
||||
adx = last.get("adx", None)
|
||||
if adx is not None and not pd.isna(adx):
|
||||
logger.debug(f"ADX: {adx:.1f}")
|
||||
if adx_threshold > 0 and adx < adx_threshold:
|
||||
return "HOLD"
|
||||
adx_val = adx if adx is not None and not pd.isna(adx) else None
|
||||
if adx_val is not None:
|
||||
logger.debug(f"ADX: {adx_val:.1f}")
|
||||
if adx_threshold > 0 and adx_val < adx_threshold:
|
||||
detail = {"long": 0, "short": 0, "vol_surge": False, "adx": adx_val, "hold_reason": f"ADX({adx_val:.1f}) < {adx_threshold}"}
|
||||
return "HOLD", detail
|
||||
|
||||
long_signals = 0
|
||||
short_signals = 0
|
||||
@@ -112,11 +118,23 @@ class Indicators:
|
||||
# 6. 거래량 확인 (신호 강화)
|
||||
vol_surge = last["volume"] > last["vol_ma20"] * volume_multiplier
|
||||
|
||||
detail = {"long": long_signals, "short": short_signals, "vol_surge": vol_surge, "adx": adx_val, "hold_reason": ""}
|
||||
|
||||
if long_signals >= signal_threshold and (vol_surge or long_signals >= signal_threshold + 1):
|
||||
return "LONG"
|
||||
return "LONG", detail
|
||||
elif short_signals >= signal_threshold and (vol_surge or short_signals >= signal_threshold + 1):
|
||||
return "SHORT"
|
||||
return "HOLD"
|
||||
return "SHORT", detail
|
||||
|
||||
# HOLD 사유 구성
|
||||
best_side = "LONG" if long_signals >= short_signals else "SHORT"
|
||||
best_score = max(long_signals, short_signals)
|
||||
reasons = []
|
||||
if best_score < signal_threshold:
|
||||
reasons.append(f"{best_side} 점수({best_score}) < 임계값({signal_threshold})")
|
||||
elif not vol_surge and best_score < signal_threshold + 1:
|
||||
reasons.append(f"거래량 미급증 & {best_side} 점수({best_score}) < {signal_threshold + 1}")
|
||||
detail["hold_reason"] = ", ".join(reasons) if reasons else "점수 부족"
|
||||
return "HOLD", detail
|
||||
|
||||
def get_atr_stop(
|
||||
self, df: pd.DataFrame, side: str, entry_price: float,
|
||||
|
||||
@@ -7,7 +7,7 @@ FEATURE_COLS = [
|
||||
"ret_1", "ret_3", "ret_5", "signal_strength", "side",
|
||||
"btc_ret_1", "btc_ret_3", "btc_ret_5",
|
||||
"eth_ret_1", "eth_ret_3", "eth_ret_5",
|
||||
"xrp_btc_rs", "xrp_eth_rs",
|
||||
"primary_btc_rs", "primary_eth_rs",
|
||||
# 시장 미시구조: OI 변화율(z-score), 펀딩비(z-score)
|
||||
"oi_change", "funding_rate",
|
||||
# OI 파생 피처
|
||||
@@ -28,11 +28,11 @@ def _calc_ret(closes: pd.Series, n: int) -> float:
|
||||
return (closes.iloc[-1] - prev) / prev if prev != 0 else 0.0
|
||||
|
||||
|
||||
def _calc_rs(xrp_ret: float, other_ret: float) -> float:
|
||||
"""상대강도 = xrp_ret / other_ret. 분모 0이면 0.0."""
|
||||
def _calc_rs(primary_ret: float, other_ret: float) -> float:
|
||||
"""상대강도 = primary_ret / other_ret. 분모 0이면 0.0."""
|
||||
if other_ret == 0.0:
|
||||
return 0.0
|
||||
return xrp_ret / other_ret
|
||||
return primary_ret / other_ret
|
||||
|
||||
|
||||
def _rolling_zscore_last(arr: np.ndarray, window: int = _ZSCORE_WINDOW) -> float:
|
||||
@@ -144,8 +144,8 @@ def build_features(
|
||||
"eth_ret_1": float(eth_ret_1),
|
||||
"eth_ret_3": float(eth_ret_3),
|
||||
"eth_ret_5": float(eth_ret_5),
|
||||
"xrp_btc_rs": float(_calc_rs(ret_1, btc_ret_1)),
|
||||
"xrp_eth_rs": float(_calc_rs(ret_1, eth_ret_1)),
|
||||
"primary_btc_rs": float(_calc_rs(ret_1, btc_ret_1)),
|
||||
"primary_eth_rs": float(_calc_rs(ret_1, eth_ret_1)),
|
||||
})
|
||||
|
||||
# 실시간에서 실제 값이 제공되면 사용, 없으면 0으로 채운다
|
||||
@@ -167,6 +167,8 @@ def build_features_aligned(
|
||||
funding_rate: float | None = None,
|
||||
oi_change_ma5: float | None = None,
|
||||
oi_price_spread: float | None = None,
|
||||
oi_history: list[float] | None = None,
|
||||
funding_history: list[float] | None = None,
|
||||
) -> pd.Series:
|
||||
"""
|
||||
학습(dataset_builder._calc_features_vectorized)과 동일한 rolling z-score를
|
||||
@@ -293,17 +295,41 @@ def build_features_aligned(
|
||||
"eth_ret_1": _rolling_zscore_last(eth_r1),
|
||||
"eth_ret_3": _rolling_zscore_last(eth_r3),
|
||||
"eth_ret_5": _rolling_zscore_last(eth_r5),
|
||||
"xrp_btc_rs": _rolling_zscore_last(rs_btc),
|
||||
"xrp_eth_rs": _rolling_zscore_last(rs_eth),
|
||||
"primary_btc_rs": _rolling_zscore_last(rs_btc),
|
||||
"primary_eth_rs": _rolling_zscore_last(rs_eth),
|
||||
})
|
||||
|
||||
# OI/펀딩비 z-score (실시간 값이 제공되면 히스토리 끝에 추가하여 z-score)
|
||||
# 서빙 시 OI/펀딩비 히스토리가 없으므로 단일 값 → z-score 불가, NaN 처리
|
||||
# LightGBM은 NaN을 자체 처리함
|
||||
base["oi_change"] = float(oi_change) if oi_change is not None else np.nan
|
||||
base["funding_rate"] = float(funding_rate) if funding_rate is not None else np.nan
|
||||
base["oi_change_ma5"] = float(oi_change_ma5) if oi_change_ma5 is not None else np.nan
|
||||
base["oi_price_spread"] = float(oi_price_spread) if oi_price_spread is not None else np.nan
|
||||
# OI/펀딩비 z-score (학습과 동일한 rolling z-score 적용)
|
||||
if oi_history and len(oi_history) >= 2 and oi_change is not None:
|
||||
oi_arr = np.array(oi_history, dtype=np.float64)
|
||||
base["oi_change"] = _rolling_zscore_last(oi_arr, window=_ZSCORE_WINDOW_OI)
|
||||
else:
|
||||
base["oi_change"] = np.nan
|
||||
|
||||
if funding_history and len(funding_history) >= 2 and funding_rate is not None:
|
||||
fr_arr = np.array(funding_history, dtype=np.float64)
|
||||
base["funding_rate"] = _rolling_zscore_last(fr_arr, window=_ZSCORE_WINDOW_OI)
|
||||
else:
|
||||
base["funding_rate"] = np.nan
|
||||
|
||||
if oi_history and len(oi_history) >= 5 and oi_change_ma5 is not None:
|
||||
# OI MA5 히스토리로 z-score
|
||||
oi_arr = np.array(oi_history, dtype=np.float64)
|
||||
ma5 = pd.Series(oi_arr).rolling(5, min_periods=1).mean().values
|
||||
base["oi_change_ma5"] = _rolling_zscore_last(ma5, window=_ZSCORE_WINDOW_OI)
|
||||
else:
|
||||
base["oi_change_ma5"] = np.nan
|
||||
|
||||
# oi_price_spread = oi_z - ret_1_z (학습과 동일하게 z-score 적용된 값의 차이)
|
||||
if oi_history and len(oi_history) >= 2 and oi_price_spread is not None:
|
||||
oi_z = base.get("oi_change", np.nan)
|
||||
ret_1_z = base.get("ret_1", 0.0)
|
||||
if not np.isnan(oi_z):
|
||||
base["oi_price_spread"] = oi_z - ret_1_z
|
||||
else:
|
||||
base["oi_price_spread"] = np.nan
|
||||
else:
|
||||
base["oi_price_spread"] = np.nan
|
||||
base["adx"] = adx_z
|
||||
|
||||
return pd.Series(base)
|
||||
|
||||
@@ -139,10 +139,12 @@ class MLFilter:
|
||||
if self._onnx_session is not None:
|
||||
input_name = self._onnx_session.get_inputs()[0].name
|
||||
X = features[FEATURE_COLS].values.astype(np.float32).reshape(1, -1)
|
||||
X = np.nan_to_num(X, nan=0.0)
|
||||
proba = float(self._onnx_session.run(None, {input_name: X})[0][0])
|
||||
else:
|
||||
available = [c for c in FEATURE_COLS if c in features.index]
|
||||
X = pd.DataFrame([features[available].values.astype(np.float64)], columns=available)
|
||||
X = X.fillna(0.0) # ONNX(nan_to_num)와 동일한 NaN 처리
|
||||
proba = float(self._lgbm_model.predict_proba(X)[0][1])
|
||||
logger.debug(
|
||||
f"ML 필터 [{self.active_backend}] 확률: {proba:.3f} "
|
||||
@@ -153,6 +155,21 @@ class MLFilter:
|
||||
logger.warning(f"ML 필터 예측 오류 (진입 차단): {e}")
|
||||
return False
|
||||
|
||||
@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
|
||||
|
||||
def reload_model(self):
|
||||
"""외부에서 강제 리로드할 때 사용 (하위 호환)."""
|
||||
prev_backend = self.active_backend
|
||||
|
||||
@@ -141,18 +141,24 @@ class MLXFilter:
|
||||
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)
|
||||
|
||||
# nan-safe 정규화: nanmean/nanstd로 통계 계산 후 nan → 0.0 대치
|
||||
# (z-score 후 0.0 = 평균값, 신경망에 줄 수 있는 가장 무난한 결측 대치값)
|
||||
mean_vals = np.nanmean(X_np, axis=0)
|
||||
self._mean = np.nan_to_num(mean_vals, nan=0.0) # 전체-NaN 컬럼 → 평균 0.0
|
||||
std_vals = np.nanstd(X_np, axis=0)
|
||||
self._std = np.nan_to_num(std_vals, nan=1.0) + 1e-8 # 전체-NaN 컬럼 → std 1.0
|
||||
X_np = (X_np - self._mean) / self._std
|
||||
X_np = np.nan_to_num(X_np, nan=0.0)
|
||||
if normalize:
|
||||
# nan-safe 정규화: nanmean/nanstd로 통계 계산 후 nan → 0.0 대치
|
||||
# (z-score 후 0.0 = 평균값, 신경망에 줄 수 있는 가장 무난한 결측 대치값)
|
||||
mean_vals = np.nanmean(X_np, axis=0)
|
||||
self._mean = np.nan_to_num(mean_vals, nan=0.0) # 전체-NaN 컬럼 → 평균 0.0
|
||||
std_vals = np.nanstd(X_np, axis=0)
|
||||
self._std = np.nan_to_num(std_vals, nan=1.0) + 1e-8 # 전체-NaN 컬럼 → std 1.0
|
||||
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)
|
||||
|
||||
w_np = sample_weight.astype(np.float32) if sample_weight is not None else None
|
||||
|
||||
|
||||
982
src/mtf_bot.py
Normal file
982
src/mtf_bot.py
Normal file
@@ -0,0 +1,982 @@
|
||||
"""
|
||||
MTF Pullback Bot — Module 1~4
|
||||
──────────────────────────────
|
||||
Module 1: TimeframeSync, DataFetcher (REST 폴링 기반)
|
||||
Module 2: MetaFilter (1h EMA50/200 + ADX + ATR)
|
||||
Module 3: TriggerStrategy (15m Volume-backed Pullback 3캔들 시퀀스)
|
||||
Module 4: ExecutionManager (Dry-run 가상 주문 + SL/TP 관리)
|
||||
|
||||
핵심 원칙:
|
||||
- Look-ahead bias 원천 차단: 완성된 캔들만 사용 ([:-1] 슬라이싱)
|
||||
- Binance 서버 딜레이 고려: 캔들 판별 시 2~5초 range
|
||||
- REST 폴링 기반 안정성: WebSocket 대신 30초 주기 폴링
|
||||
- 메모리 최적화: deque(maxlen=250)
|
||||
- Dry-run 모드: 4월 OOS 검증 기간, 실주문 API 주석 처리
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time as _time
|
||||
from datetime import datetime, timezone
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
import pandas as pd
|
||||
import pandas_ta as ta
|
||||
import ccxt.async_support as ccxt
|
||||
from loguru import logger
|
||||
from src.notifier import DiscordNotifier
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Module 1: TimeframeSync
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TimeframeSync:
|
||||
"""현재 시간이 15m/1h 캔들 종료 직후인지 판별 (Binance 서버 딜레이 2~5초 고려)."""
|
||||
|
||||
_15M_MINUTES = {0, 15, 30, 45}
|
||||
|
||||
@staticmethod
|
||||
def is_15m_candle_closed(current_ts: int) -> bool:
|
||||
"""
|
||||
15m 캔들 종료 판별.
|
||||
|
||||
Args:
|
||||
current_ts: Unix timestamp (밀리초)
|
||||
|
||||
Returns:
|
||||
True if 분(minute)이 [0, 15, 30, 45] 중 하나이고 초(second)가 2~5초 사이
|
||||
"""
|
||||
dt = datetime.fromtimestamp(current_ts / 1000, tz=timezone.utc)
|
||||
return dt.minute in TimeframeSync._15M_MINUTES and 2 <= dt.second <= 5
|
||||
|
||||
@staticmethod
|
||||
def is_1h_candle_closed(current_ts: int) -> bool:
|
||||
"""
|
||||
1h 캔들 종료 판별.
|
||||
|
||||
Args:
|
||||
current_ts: Unix timestamp (밀리초)
|
||||
|
||||
Returns:
|
||||
True if 분(minute)이 0이고 초(second)가 2~5초 사이
|
||||
"""
|
||||
dt = datetime.fromtimestamp(current_ts / 1000, tz=timezone.utc)
|
||||
return dt.minute == 0 and 2 <= dt.second <= 5
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Module 1: DataFetcher
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class DataFetcher:
|
||||
"""Binance Futures에서 15m/1h OHLCV 데이터 fetch 및 관리."""
|
||||
|
||||
def __init__(self, symbol: str = "XRP/USDT:USDT"):
|
||||
self.symbol = symbol
|
||||
self.exchange = ccxt.binance({
|
||||
"enableRateLimit": True,
|
||||
"options": {"defaultType": "future"},
|
||||
})
|
||||
self.klines_15m: deque = deque(maxlen=250)
|
||||
self.klines_1h: deque = deque(maxlen=250)
|
||||
self._last_15m_ts: int = 0 # 마지막으로 저장된 15m 캔들 timestamp
|
||||
self._last_1h_ts: int = 0
|
||||
|
||||
@staticmethod
|
||||
def _remove_incomplete_candle(df: pd.DataFrame, interval_sec: int) -> pd.DataFrame:
|
||||
"""미완성(진행 중) 캔들을 조건부로 제거. ccxt timestamp는 ms 단위."""
|
||||
if df.empty:
|
||||
return df
|
||||
now_ms = int(_time.time() * 1000)
|
||||
current_candle_start_ms = (now_ms // (interval_sec * 1000)) * (interval_sec * 1000)
|
||||
# DataFrame index가 datetime인 경우 원본 timestamp 컬럼이 없으므로 index에서 추출
|
||||
last_open_ms = int(df.index[-1].timestamp() * 1000)
|
||||
if last_open_ms >= current_candle_start_ms:
|
||||
return df.iloc[:-1].copy()
|
||||
return df
|
||||
|
||||
async def fetch_ohlcv(self, symbol: str, timeframe: str, limit: int = 250) -> List[List]:
|
||||
"""
|
||||
ccxt를 통해 OHLCV 데이터 fetch.
|
||||
|
||||
Returns:
|
||||
[[timestamp, open, high, low, close, volume], ...]
|
||||
"""
|
||||
return await self.exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
|
||||
|
||||
async def initialize(self):
|
||||
"""봇 시작 시 초기 데이터 로드 (250개씩)."""
|
||||
# 15m 캔들
|
||||
raw_15m = await self.fetch_ohlcv(self.symbol, "15m", limit=250)
|
||||
for candle in raw_15m:
|
||||
self.klines_15m.append(candle)
|
||||
if raw_15m:
|
||||
self._last_15m_ts = raw_15m[-1][0]
|
||||
|
||||
# 1h 캔들
|
||||
raw_1h = await self.fetch_ohlcv(self.symbol, "1h", limit=250)
|
||||
for candle in raw_1h:
|
||||
self.klines_1h.append(candle)
|
||||
if raw_1h:
|
||||
self._last_1h_ts = raw_1h[-1][0]
|
||||
|
||||
logger.info(
|
||||
f"[DataFetcher] 초기화 완료: 15m={len(self.klines_15m)}개, 1h={len(self.klines_1h)}개"
|
||||
)
|
||||
|
||||
def get_15m_dataframe(self) -> Optional[pd.DataFrame]:
|
||||
"""완성된 15m 캔들을 DataFrame으로 반환 (미완성 캔들 조건부 제거)."""
|
||||
if not self.klines_15m:
|
||||
return None
|
||||
data = list(self.klines_15m)
|
||||
df = pd.DataFrame(data, columns=["timestamp", "open", "high", "low", "close", "volume"])
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
df = df.set_index("timestamp")
|
||||
return self._remove_incomplete_candle(df, interval_sec=900)
|
||||
|
||||
def get_1h_dataframe_completed(self) -> Optional[pd.DataFrame]:
|
||||
"""
|
||||
'완성된' 1h 캔들만 반환.
|
||||
|
||||
조건부 슬라이싱: _remove_incomplete_candle()로 진행 중인 최신 1h 캔들 제외.
|
||||
이유: Look-ahead bias 원천 차단 — 아직 완성되지 않은 캔들의
|
||||
high/low/close는 미래 데이터이므로 지표 계산에 사용하면 안 됨.
|
||||
"""
|
||||
if len(self.klines_1h) < 2:
|
||||
return None
|
||||
data = list(self.klines_1h)
|
||||
df = pd.DataFrame(data, columns=["timestamp", "open", "high", "low", "close", "volume"])
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
df = df.set_index("timestamp")
|
||||
return self._remove_incomplete_candle(df, interval_sec=3600)
|
||||
|
||||
async def close(self):
|
||||
"""ccxt exchange 연결 정리."""
|
||||
await self.exchange.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Module 2: MetaFilter
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class MetaFilter:
|
||||
"""1시간봉 데이터로부터 거시 추세 판독."""
|
||||
|
||||
EMA_FAST = 50
|
||||
EMA_SLOW = 200
|
||||
ADX_THRESHOLD = 20
|
||||
|
||||
def __init__(self, data_fetcher: DataFetcher):
|
||||
self.data_fetcher = data_fetcher
|
||||
self._cached_indicators: Optional[pd.DataFrame] = None
|
||||
self._cache_timestamp: Optional[pd.Timestamp] = None
|
||||
|
||||
def _calc_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""1h DataFrame에 EMA50, EMA200, ADX, ATR 계산 (캔들 단위 캐싱)."""
|
||||
if df is None or df.empty:
|
||||
return df
|
||||
|
||||
last_ts = df.index[-1]
|
||||
if self._cached_indicators is not None and self._cache_timestamp == last_ts:
|
||||
return self._cached_indicators
|
||||
|
||||
df = df.copy()
|
||||
df["ema50"] = ta.ema(df["close"], length=self.EMA_FAST)
|
||||
df["ema200"] = ta.ema(df["close"], length=self.EMA_SLOW)
|
||||
adx_df = ta.adx(df["high"], df["low"], df["close"], length=14)
|
||||
df["adx"] = adx_df["ADX_14"]
|
||||
df["atr"] = ta.atr(df["high"], df["low"], df["close"], length=14)
|
||||
|
||||
self._cached_indicators = df
|
||||
self._cache_timestamp = last_ts
|
||||
return df
|
||||
|
||||
def get_market_state(self) -> str:
|
||||
"""
|
||||
1h 메타필터 상태 반환.
|
||||
|
||||
Returns:
|
||||
'LONG_ALLOWED': EMA50 > EMA200 & ADX > 20 → 상승 추세, LONG 진입 허용
|
||||
'SHORT_ALLOWED': EMA50 < EMA200 & ADX > 20 → 하락 추세, SHORT 진입 허용
|
||||
'WAIT': 그 외 (추세 약하거나 데이터 부족)
|
||||
"""
|
||||
df = self.data_fetcher.get_1h_dataframe_completed()
|
||||
if df is None or len(df) < self.EMA_SLOW:
|
||||
return "WAIT"
|
||||
|
||||
df = self._calc_indicators(df)
|
||||
last = df.iloc[-1]
|
||||
|
||||
if pd.isna(last["ema50"]) or pd.isna(last["ema200"]) or pd.isna(last["adx"]):
|
||||
return "WAIT"
|
||||
|
||||
if last["adx"] < self.ADX_THRESHOLD:
|
||||
return "WAIT"
|
||||
|
||||
if last["ema50"] > last["ema200"]:
|
||||
return "LONG_ALLOWED"
|
||||
elif last["ema50"] < last["ema200"]:
|
||||
return "SHORT_ALLOWED"
|
||||
|
||||
return "WAIT"
|
||||
|
||||
def get_current_atr(self) -> Optional[float]:
|
||||
"""현재 1h ATR 값 반환 (SL/TP 계산용)."""
|
||||
df = self.data_fetcher.get_1h_dataframe_completed()
|
||||
if df is None or len(df) < 15: # ATR(14) 최소 데이터
|
||||
return None
|
||||
|
||||
df = self._calc_indicators(df)
|
||||
atr = df["atr"].iloc[-1]
|
||||
return float(atr) if not pd.isna(atr) else None
|
||||
|
||||
def get_meta_info(self) -> Dict:
|
||||
"""전체 메타 정보 반환 (디버깅용)."""
|
||||
df = self.data_fetcher.get_1h_dataframe_completed()
|
||||
if df is None or len(df) < self.EMA_SLOW:
|
||||
return {"state": "WAIT", "ema50": None, "ema200": None,
|
||||
"adx": None, "atr": None, "timestamp": None}
|
||||
|
||||
df = self._calc_indicators(df)
|
||||
last = df.iloc[-1]
|
||||
|
||||
return {
|
||||
"state": self.get_market_state(),
|
||||
"ema50": float(last["ema50"]) if not pd.isna(last["ema50"]) else None,
|
||||
"ema200": float(last["ema200"]) if not pd.isna(last["ema200"]) else None,
|
||||
"adx": float(last["adx"]) if not pd.isna(last["adx"]) else None,
|
||||
"atr": float(last["atr"]) if not pd.isna(last["atr"]) else None,
|
||||
"timestamp": str(df.index[-1]),
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Module 3: TriggerStrategy
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TriggerStrategy:
|
||||
"""
|
||||
15분봉 Volume-backed Pullback 패턴을 3캔들 시퀀스로 인식.
|
||||
|
||||
3캔들 시퀀스:
|
||||
t-2: 기준 캔들 (Vol_SMA20 산출 기준)
|
||||
t-1: 풀백 캔들 (EMA 이탈 + 거래량 고갈 확인)
|
||||
t : 돌파 캔들 (가장 최근 완성된 캔들, EMA 복귀 확인)
|
||||
"""
|
||||
|
||||
EMA_PERIOD = 15
|
||||
VOL_SMA_PERIOD = 20
|
||||
VOL_THRESHOLD = 0.50 # vol < vol_sma20 * 0.50
|
||||
|
||||
def __init__(self):
|
||||
self._last_info: Dict = {}
|
||||
|
||||
def _calc_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""15m DataFrame에 EMA15, Vol_SMA20 계산."""
|
||||
df = df.copy()
|
||||
df["ema15"] = ta.ema(df["close"], length=self.EMA_PERIOD)
|
||||
df["vol_sma20"] = df["volume"].rolling(self.VOL_SMA_PERIOD).mean()
|
||||
return df
|
||||
|
||||
def generate_signal(self, df_15m: pd.DataFrame, meta_state: str) -> str:
|
||||
"""
|
||||
3캔들 시퀀스 기반 진입 신호 생성.
|
||||
|
||||
Args:
|
||||
df_15m: 15분봉 DataFrame (OHLCV)
|
||||
meta_state: 'LONG_ALLOWED' | 'SHORT_ALLOWED' | 'WAIT'
|
||||
|
||||
Returns:
|
||||
'EXECUTE_LONG' | 'EXECUTE_SHORT' | 'HOLD'
|
||||
"""
|
||||
# Step 1: 데이터 유효성
|
||||
if meta_state == "WAIT":
|
||||
self._last_info = {"signal": "HOLD", "reason": "meta_state=WAIT"}
|
||||
return "HOLD"
|
||||
|
||||
if df_15m is None or len(df_15m) < 25:
|
||||
self._last_info = {"signal": "HOLD", "reason": f"데이터 부족 ({len(df_15m) if df_15m is not None else 0}행)"}
|
||||
return "HOLD"
|
||||
|
||||
df = self._calc_indicators(df_15m)
|
||||
|
||||
# Step 2: 캔들 인덱싱
|
||||
t = df.iloc[-1] # 최근 완성 캔들 (돌파 확인)
|
||||
t_1 = df.iloc[-2] # 직전 캔들 (풀백 확인)
|
||||
t_2 = df.iloc[-3] # 그 이전 캔들 (Vol SMA 기준)
|
||||
|
||||
# NaN 체크
|
||||
if (pd.isna(t["ema15"]) or pd.isna(t_1["ema15"])
|
||||
or pd.isna(t_2["vol_sma20"])):
|
||||
self._last_info = {"signal": "HOLD", "reason": "지표 NaN"}
|
||||
return "HOLD"
|
||||
|
||||
vol_sma20_t2 = t_2["vol_sma20"]
|
||||
vol_t1 = t_1["volume"]
|
||||
vol_ratio = vol_t1 / vol_sma20_t2 if vol_sma20_t2 > 0 else float("inf")
|
||||
vol_dry = vol_ratio < self.VOL_THRESHOLD
|
||||
|
||||
# 공통 info 구성
|
||||
self._last_info = {
|
||||
"ema15_t": float(t["ema15"]),
|
||||
"ema15_t1": float(t_1["ema15"]),
|
||||
"vol_sma20_t2": float(vol_sma20_t2),
|
||||
"vol_t1": float(vol_t1),
|
||||
"vol_ratio": round(vol_ratio, 4),
|
||||
"close_t1": float(t_1["close"]),
|
||||
"close_t": float(t["close"]),
|
||||
}
|
||||
|
||||
# Step 3: LONG 시그널
|
||||
if meta_state == "LONG_ALLOWED":
|
||||
pullback = t_1["close"] < t_1["ema15"] # t-1 EMA 아래로 이탈
|
||||
resumption = t["close"] > t["ema15"] # t EMA 위로 복귀
|
||||
|
||||
if pullback and vol_dry and resumption:
|
||||
self._last_info.update({
|
||||
"signal": "EXECUTE_LONG",
|
||||
"reason": f"풀백 이탈 + 거래량 고갈({vol_ratio:.2f}) + 돌파 복귀",
|
||||
})
|
||||
return "EXECUTE_LONG"
|
||||
|
||||
reasons = []
|
||||
if not pullback:
|
||||
reasons.append(f"이탈 없음(close_t1={t_1['close']:.4f} >= ema15={t_1['ema15']:.4f})")
|
||||
if not vol_dry:
|
||||
reasons.append(f"거래량 과다({vol_ratio:.2f} >= {self.VOL_THRESHOLD})")
|
||||
if not resumption:
|
||||
reasons.append(f"복귀 실패(close_t={t['close']:.4f} <= ema15={t['ema15']:.4f})")
|
||||
self._last_info.update({"signal": "HOLD", "reason": " | ".join(reasons)})
|
||||
return "HOLD"
|
||||
|
||||
# Step 4: SHORT 시그널
|
||||
if meta_state == "SHORT_ALLOWED":
|
||||
pullback = t_1["close"] > t_1["ema15"] # t-1 EMA 위로 이탈
|
||||
resumption = t["close"] < t["ema15"] # t EMA 아래로 복귀
|
||||
|
||||
if pullback and vol_dry and resumption:
|
||||
self._last_info.update({
|
||||
"signal": "EXECUTE_SHORT",
|
||||
"reason": f"풀백 이탈 + 거래량 고갈({vol_ratio:.2f}) + 돌파 복귀",
|
||||
})
|
||||
return "EXECUTE_SHORT"
|
||||
|
||||
reasons = []
|
||||
if not pullback:
|
||||
reasons.append(f"이탈 없음(close_t1={t_1['close']:.4f} <= ema15={t_1['ema15']:.4f})")
|
||||
if not vol_dry:
|
||||
reasons.append(f"거래량 과다({vol_ratio:.2f} >= {self.VOL_THRESHOLD})")
|
||||
if not resumption:
|
||||
reasons.append(f"복귀 실패(close_t={t['close']:.4f} >= ema15={t['ema15']:.4f})")
|
||||
self._last_info.update({"signal": "HOLD", "reason": " | ".join(reasons)})
|
||||
return "HOLD"
|
||||
|
||||
# Step 5: 기본값
|
||||
self._last_info.update({"signal": "HOLD", "reason": f"미지원 meta_state={meta_state}"})
|
||||
return "HOLD"
|
||||
|
||||
def get_trigger_info(self) -> Dict:
|
||||
"""디버깅 및 로그용 트리거 상태 정보 반환."""
|
||||
return self._last_info.copy()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Module 4: ExecutionManager
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
_MTF_TRADE_DIR = Path("data/trade_history")
|
||||
|
||||
|
||||
class ExecutionManager:
|
||||
"""
|
||||
TriggerStrategy의 신호를 받아 포지션 상태를 관리하고
|
||||
SL/TP를 계산하여 가상 주문을 실행한다 (Dry-run 모드).
|
||||
"""
|
||||
|
||||
ATR_SL_MULT = 1.5
|
||||
ATR_TP_MULT = 2.3
|
||||
|
||||
def __init__(self, symbol: str = "XRPUSDT"):
|
||||
self.symbol = symbol
|
||||
self.current_position: Optional[str] = None # None | 'LONG' | 'SHORT'
|
||||
self._entry_price: Optional[float] = None
|
||||
self._entry_ts: Optional[str] = None
|
||||
self._sl_price: Optional[float] = None
|
||||
self._tp_price: Optional[float] = None
|
||||
self._atr_at_entry: Optional[float] = None
|
||||
|
||||
def execute(self, signal: str, current_price: float, atr_value: Optional[float]) -> Optional[Dict]:
|
||||
"""
|
||||
신호에 따라 가상 주문 실행.
|
||||
|
||||
Args:
|
||||
signal: 'EXECUTE_LONG' | 'EXECUTE_SHORT' | 'HOLD'
|
||||
current_price: 현재 시장가
|
||||
atr_value: 1h ATR 값
|
||||
|
||||
Returns:
|
||||
주문 정보 Dict 또는 None (HOLD / 중복 포지션 / ATR 무효)
|
||||
"""
|
||||
if signal == "HOLD":
|
||||
return None
|
||||
|
||||
if self.current_position is not None:
|
||||
logger.debug(
|
||||
f"[ExecutionManager] 포지션 중복 차단: "
|
||||
f"현재={self.current_position}, 신호={signal}"
|
||||
)
|
||||
return None
|
||||
|
||||
if atr_value is None or atr_value <= 0 or pd.isna(atr_value):
|
||||
logger.warning(f"[ExecutionManager] ATR 무효({atr_value}), 주문 차단")
|
||||
return None
|
||||
|
||||
entry_price = current_price
|
||||
|
||||
if signal == "EXECUTE_LONG":
|
||||
sl_price = entry_price - (atr_value * self.ATR_SL_MULT)
|
||||
tp_price = entry_price + (atr_value * self.ATR_TP_MULT)
|
||||
side = "LONG"
|
||||
elif signal == "EXECUTE_SHORT":
|
||||
sl_price = entry_price + (atr_value * self.ATR_SL_MULT)
|
||||
tp_price = entry_price - (atr_value * self.ATR_TP_MULT)
|
||||
side = "SHORT"
|
||||
else:
|
||||
return None
|
||||
|
||||
self.current_position = side
|
||||
self._entry_price = entry_price
|
||||
self._entry_ts = datetime.now(timezone.utc).isoformat()
|
||||
self._sl_price = sl_price
|
||||
self._tp_price = tp_price
|
||||
self._atr_at_entry = atr_value
|
||||
|
||||
sl_dist = abs(entry_price - sl_price)
|
||||
tp_dist = abs(tp_price - entry_price)
|
||||
rr_ratio = tp_dist / sl_dist if sl_dist > 0 else 0
|
||||
|
||||
# ── Dry-run 로그 ──
|
||||
logger.info(
|
||||
f"\n┌──────────────────────────────────────────────┐\n"
|
||||
f"│ [DRY-RUN] 가상 주문 실행 │\n"
|
||||
f"│ 방향: {side:<5} | 진입가: {entry_price:.4f} │\n"
|
||||
f"│ SL: {sl_price:.4f} ({'-' if side == 'LONG' else '+'}{sl_dist:.4f}, ATR×{self.ATR_SL_MULT}) │\n"
|
||||
f"│ TP: {tp_price:.4f} ({'+' if side == 'LONG' else '-'}{tp_dist:.4f}, ATR×{self.ATR_TP_MULT}) │\n"
|
||||
f"│ R:R = 1:{rr_ratio:.1f} │\n"
|
||||
f"└──────────────────────────────────────────────┘"
|
||||
)
|
||||
|
||||
# ── 실주문 (프로덕션 전환 시 주석 해제) ──
|
||||
# if side == "LONG":
|
||||
# await self.exchange.create_market_buy_order(symbol, amount)
|
||||
# await self.exchange.create_order(symbol, 'stop_market', 'sell', amount, params={'stopPrice': sl_price})
|
||||
# await self.exchange.create_order(symbol, 'take_profit_market', 'sell', amount, params={'stopPrice': tp_price})
|
||||
# elif side == "SHORT":
|
||||
# await self.exchange.create_market_sell_order(symbol, amount)
|
||||
# await self.exchange.create_order(symbol, 'stop_market', 'buy', amount, params={'stopPrice': sl_price})
|
||||
# await self.exchange.create_order(symbol, 'take_profit_market', 'buy', amount, params={'stopPrice': tp_price})
|
||||
|
||||
return {
|
||||
"action": side,
|
||||
"entry_price": entry_price,
|
||||
"sl_price": sl_price,
|
||||
"tp_price": tp_price,
|
||||
"atr": atr_value,
|
||||
"risk_reward": round(rr_ratio, 2),
|
||||
}
|
||||
|
||||
def close_position(self, reason: str, exit_price: float = 0.0, pnl_bps: float = 0.0) -> None:
|
||||
"""포지션 청산 + JSONL 기록 (상태 초기화)."""
|
||||
if self.current_position is None:
|
||||
logger.debug("[ExecutionManager] 청산할 포지션 없음")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"[ExecutionManager] 포지션 청산: {self.current_position} "
|
||||
f"(진입: {self._entry_price:.4f}) | 사유: {reason}"
|
||||
)
|
||||
|
||||
# JSONL에 기록
|
||||
self._save_trade(reason, exit_price, pnl_bps)
|
||||
|
||||
# ── 실주문 (프로덕션 전환 시 주석 해제) ──
|
||||
# if self.current_position == "LONG":
|
||||
# await self.exchange.create_market_sell_order(symbol, amount)
|
||||
# elif self.current_position == "SHORT":
|
||||
# await self.exchange.create_market_buy_order(symbol, amount)
|
||||
|
||||
self.current_position = None
|
||||
self._entry_price = None
|
||||
self._entry_ts = None
|
||||
self._sl_price = None
|
||||
self._tp_price = None
|
||||
self._atr_at_entry = None
|
||||
|
||||
def _save_trade(self, reason: str, exit_price: float, pnl_bps: float) -> None:
|
||||
"""거래 기록을 JSONL 파일에 append."""
|
||||
record = {
|
||||
"symbol": self.symbol,
|
||||
"side": self.current_position,
|
||||
"entry_price": self._entry_price,
|
||||
"entry_ts": self._entry_ts,
|
||||
"exit_price": exit_price,
|
||||
"exit_ts": datetime.now(timezone.utc).isoformat(),
|
||||
"sl_price": self._sl_price,
|
||||
"tp_price": self._tp_price,
|
||||
"atr": self._atr_at_entry,
|
||||
"pnl_bps": round(pnl_bps, 1),
|
||||
"reason": reason,
|
||||
}
|
||||
try:
|
||||
_MTF_TRADE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = _MTF_TRADE_DIR / f"mtf_{self.symbol.replace('/', '').replace(':', '').lower()}.jsonl"
|
||||
with open(path, "a") as f:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
logger.info(f"[ExecutionManager] 거래 기록 저장: {path.name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[ExecutionManager] 거래 기록 저장 실패: {e}")
|
||||
|
||||
def get_position_info(self) -> Dict:
|
||||
"""현재 포지션 정보 반환."""
|
||||
return {
|
||||
"position": self.current_position,
|
||||
"entry_price": self._entry_price,
|
||||
"sl_price": self._sl_price,
|
||||
"tp_price": self._tp_price,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 검증 테스트
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Main Loop: OOS Dry-run
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class MTFPullbackBot:
|
||||
"""MTF Pullback Bot 메인 루프 — Dry-run OOS 검증용."""
|
||||
|
||||
# TODO(LIVE): Kill switch 로직 구현 필요 (Fast Kill 8연패 + Slow Kill PF<0.75) — 2026-04-15 LIVE 전환 시
|
||||
# TODO(LIVE): 글로벌 RiskManager 통합 필요 — 2026-04-15 LIVE 전환 시
|
||||
|
||||
LOOP_INTERVAL = 1 # 초 (TimeframeSync 4초 윈도우를 놓치지 않기 위해)
|
||||
POLL_INTERVAL = 30 # 데이터 폴링 주기 (초)
|
||||
|
||||
def __init__(self, symbol: str = "XRP/USDT:USDT"):
|
||||
self.symbol = symbol
|
||||
self.fetcher = DataFetcher(symbol=symbol)
|
||||
self.meta = MetaFilter(self.fetcher)
|
||||
self.trigger = TriggerStrategy()
|
||||
self.executor = ExecutionManager(symbol=symbol)
|
||||
self.notifier = DiscordNotifier(
|
||||
webhook_url=os.getenv("DISCORD_WEBHOOK_URL", ""),
|
||||
)
|
||||
self._last_15m_check_ts: int = 0 # 중복 체크 방지
|
||||
self._last_poll_ts: float = 0 # 마지막 폴링 시각
|
||||
|
||||
async def run(self):
|
||||
"""메인 루프: 30초 폴링 → 15m 캔들 close 감지 → 신호 판정."""
|
||||
logger.info(f"[MTFBot] 시작: {self.symbol} (Dry-run OOS 모드)")
|
||||
|
||||
await self.fetcher.initialize()
|
||||
|
||||
# 초기 상태 출력
|
||||
meta_state = self.meta.get_market_state()
|
||||
atr = self.meta.get_current_atr()
|
||||
logger.info(f"[MTFBot] 초기 상태: Meta={meta_state}, ATR={atr}")
|
||||
self.notifier.notify_info(
|
||||
f"**[MTF Dry-run] 봇 시작**\n"
|
||||
f"심볼: `{self.symbol}` | Meta: `{meta_state}` | ATR: `{atr:.6f}`" if atr else
|
||||
f"**[MTF Dry-run] 봇 시작**\n심볼: `{self.symbol}` | Meta: `{meta_state}` | ATR: N/A"
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(self.LOOP_INTERVAL)
|
||||
|
||||
try:
|
||||
# 데이터 폴링 (30초마다)
|
||||
now_mono = _time.monotonic()
|
||||
if now_mono - self._last_poll_ts >= self.POLL_INTERVAL:
|
||||
await self._poll_and_update()
|
||||
self._last_poll_ts = now_mono
|
||||
|
||||
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
|
||||
|
||||
# 15m 캔들 close 감지
|
||||
if TimeframeSync.is_15m_candle_closed(now_ms):
|
||||
if now_ms - self._last_15m_check_ts > 60_000: # 1분 이내 중복 방지
|
||||
self._last_15m_check_ts = now_ms
|
||||
await self._poll_and_update() # 최신 데이터 보장
|
||||
await self._on_15m_close()
|
||||
|
||||
# 포지션 보유 중이면 SL/TP 모니터링
|
||||
if self.executor.current_position is not None:
|
||||
self._check_sl_tp()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MTFBot] 루프 에러: {e}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("[MTFBot] 종료 시그널 수신")
|
||||
finally:
|
||||
await self.fetcher.close()
|
||||
logger.info("[MTFBot] 종료 완료")
|
||||
|
||||
async def _poll_and_update(self):
|
||||
"""데이터 폴링 업데이트."""
|
||||
# 15m
|
||||
raw_15m = await self.fetcher.fetch_ohlcv(self.symbol, "15m", limit=3)
|
||||
for candle in raw_15m:
|
||||
if candle[0] > self.fetcher._last_15m_ts:
|
||||
self.fetcher.klines_15m.append(candle)
|
||||
self.fetcher._last_15m_ts = candle[0]
|
||||
|
||||
# 1h
|
||||
raw_1h = await self.fetcher.fetch_ohlcv(self.symbol, "1h", limit=3)
|
||||
for candle in raw_1h:
|
||||
if candle[0] > self.fetcher._last_1h_ts:
|
||||
self.fetcher.klines_1h.append(candle)
|
||||
self.fetcher._last_1h_ts = candle[0]
|
||||
|
||||
async def _on_15m_close(self):
|
||||
"""15m 캔들 종료 시 신호 판정."""
|
||||
df_15m = self.fetcher.get_15m_dataframe()
|
||||
meta_state = self.meta.get_market_state()
|
||||
atr = self.meta.get_current_atr()
|
||||
|
||||
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
last_close = float(df_15m.iloc[-1]["close"]) if df_15m is not None and len(df_15m) > 0 else 0
|
||||
pos_info = self.executor.current_position or "없음"
|
||||
|
||||
# Heartbeat: 15분마다 무조건 출력 (메타 지표 포함)
|
||||
meta_info = self.meta.get_meta_info()
|
||||
adx_val = meta_info.get("adx")
|
||||
ema50_val = meta_info.get("ema50")
|
||||
ema200_val = meta_info.get("ema200")
|
||||
adx_str = f"{adx_val:.2f}" if adx_val is not None else "N/A"
|
||||
ema50_str = f"{ema50_val:.4f}" if ema50_val is not None else "N/A"
|
||||
ema200_str = f"{ema200_val:.4f}" if ema200_val is not None else "N/A"
|
||||
atr_str = f"{atr:.6f}" if atr else "N/A"
|
||||
logger.info(
|
||||
f"[Heartbeat] 15m 마감 ({now_str}) | Meta: {meta_state} | "
|
||||
f"ADX: {adx_str} | EMA50: {ema50_str} | EMA200: {ema200_str} | "
|
||||
f"ATR: {atr_str} | Close: {last_close:.4f} | Pos: {pos_info}"
|
||||
)
|
||||
|
||||
signal = self.trigger.generate_signal(df_15m, meta_state)
|
||||
info = self.trigger.get_trigger_info()
|
||||
|
||||
if signal != "HOLD":
|
||||
logger.info(f"[MTFBot] 신호: {signal} | {info.get('reason', '')}")
|
||||
current_price = last_close
|
||||
result = self.executor.execute(signal, current_price, atr)
|
||||
if result:
|
||||
logger.info(f"[MTFBot] 거래 기록: {result}")
|
||||
side = result["action"]
|
||||
sl_dist = abs(result["entry_price"] - result["sl_price"])
|
||||
tp_dist = abs(result["tp_price"] - result["entry_price"])
|
||||
self.notifier.notify_info(
|
||||
f"**[MTF Dry-run] 가상 {side} 진입**\n"
|
||||
f"진입가: `{result['entry_price']:.4f}` | ATR: `{result['atr']:.6f}`\n"
|
||||
f"SL: `{result['sl_price']:.4f}` ({sl_dist:.4f}) | "
|
||||
f"TP: `{result['tp_price']:.4f}` ({tp_dist:.4f})\n"
|
||||
f"R:R = `1:{result['risk_reward']}` | Meta: `{meta_state}`\n"
|
||||
f"사유: {info.get('reason', '')}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"[MTFBot] HOLD | {info.get('reason', '')}")
|
||||
|
||||
def _check_sl_tp(self):
|
||||
"""현재 가격으로 SL/TP 도달 여부 확인 (15m 캔들 high/low 기반)."""
|
||||
df_15m = self.fetcher.get_15m_dataframe()
|
||||
if df_15m is None or len(df_15m) < 1:
|
||||
return
|
||||
|
||||
last = df_15m.iloc[-1]
|
||||
pos = self.executor.current_position
|
||||
sl = self.executor._sl_price
|
||||
tp = self.executor._tp_price
|
||||
entry = self.executor._entry_price
|
||||
|
||||
if pos is None or sl is None or tp is None:
|
||||
return
|
||||
|
||||
hit_sl = hit_tp = False
|
||||
if pos == "LONG":
|
||||
hit_sl = last["low"] <= sl
|
||||
hit_tp = last["high"] >= tp
|
||||
else:
|
||||
hit_sl = last["high"] >= sl
|
||||
hit_tp = last["low"] <= tp
|
||||
|
||||
if hit_sl and hit_tp:
|
||||
exit_price = sl
|
||||
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
|
||||
pnl_bps = pnl * 10000
|
||||
logger.info(f"[MTFBot] SL+TP 동시 히트 → SL 우선 청산 | PnL: {pnl_bps:+.1f}bps")
|
||||
self.executor.close_position(f"SL 히트 ({exit_price:.4f})", exit_price, pnl_bps)
|
||||
self.notifier.notify_info(
|
||||
f"**[MTF Dry-run] {pos} SL 청산**\n"
|
||||
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
|
||||
f"PnL: `{pnl_bps:+.1f}bps`"
|
||||
)
|
||||
elif hit_sl:
|
||||
exit_price = sl
|
||||
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
|
||||
pnl_bps = pnl * 10000
|
||||
logger.info(f"[MTFBot] SL 히트 | 청산가: {exit_price:.4f} | PnL: {pnl_bps:+.1f}bps")
|
||||
self.executor.close_position(f"SL 히트 ({exit_price:.4f})", exit_price, pnl_bps)
|
||||
self.notifier.notify_info(
|
||||
f"**[MTF Dry-run] {pos} SL 청산**\n"
|
||||
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
|
||||
f"PnL: `{pnl_bps:+.1f}bps`"
|
||||
)
|
||||
elif hit_tp:
|
||||
exit_price = tp
|
||||
pnl = (exit_price - entry) / entry if pos == "LONG" else (entry - exit_price) / entry
|
||||
pnl_bps = pnl * 10000
|
||||
logger.info(f"[MTFBot] TP 히트 | 청산가: {exit_price:.4f} | PnL: {pnl_bps:+.1f}bps")
|
||||
self.executor.close_position(f"TP 히트 ({exit_price:.4f})", exit_price, pnl_bps)
|
||||
self.notifier.notify_info(
|
||||
f"**[MTF Dry-run] {pos} TP 청산**\n"
|
||||
f"진입: `{entry:.4f}` → 청산: `{exit_price:.4f}`\n"
|
||||
f"PnL: `{pnl_bps:+.1f}bps`"
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 검증 테스트
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
async def test_module_1_2():
|
||||
"""Module 1 & 2 검증 테스트."""
|
||||
print("=" * 60)
|
||||
print(" MTF Bot Module 1 & 2 검증 테스트")
|
||||
print("=" * 60)
|
||||
|
||||
# ── 1. TimeframeSync 검증 ──
|
||||
print("\n[1] TimeframeSync 검증")
|
||||
# 2026-01-01 01:00:03 UTC (1h 캔들 close 직후)
|
||||
ts_1h_close = int(datetime(2026, 1, 1, 1, 0, 3, tzinfo=timezone.utc).timestamp() * 1000)
|
||||
# 2026-01-01 00:15:04 UTC (15m 캔들 close 직후)
|
||||
ts_15m_close = int(datetime(2026, 1, 1, 0, 15, 4, tzinfo=timezone.utc).timestamp() * 1000)
|
||||
# 2026-01-01 00:15:00 UTC (정각 — 아직 딜레이 전)
|
||||
ts_too_early = int(datetime(2026, 1, 1, 0, 15, 0, tzinfo=timezone.utc).timestamp() * 1000)
|
||||
# 2026-01-01 00:15:10 UTC (너무 늦음)
|
||||
ts_too_late = int(datetime(2026, 1, 1, 0, 15, 10, tzinfo=timezone.utc).timestamp() * 1000)
|
||||
# 2026-01-01 00:07:03 UTC (15m 경계 아님)
|
||||
ts_not_boundary = int(datetime(2026, 1, 1, 0, 7, 3, tzinfo=timezone.utc).timestamp() * 1000)
|
||||
|
||||
assert TimeframeSync.is_1h_candle_closed(ts_1h_close) is True, "1h close 판별 실패"
|
||||
assert TimeframeSync.is_15m_candle_closed(ts_15m_close) is True, "15m close 판별 실패"
|
||||
assert TimeframeSync.is_15m_candle_closed(ts_too_early) is False, "정각(0초)에 True 반환"
|
||||
assert TimeframeSync.is_15m_candle_closed(ts_too_late) is False, "10초에 True 반환"
|
||||
assert TimeframeSync.is_15m_candle_closed(ts_not_boundary) is False, "비경계 시점에 True 반환"
|
||||
assert TimeframeSync.is_1h_candle_closed(ts_15m_close) is False, "15분에 1h close True 반환"
|
||||
print(" ✓ TimeframeSync: second 2~5 범위에서만 True 반환 확인")
|
||||
|
||||
# ── 2. DataFetcher 초기화 ──
|
||||
print("\n[2] DataFetcher 초기화")
|
||||
fetcher = DataFetcher(symbol="XRP/USDT:USDT")
|
||||
try:
|
||||
await fetcher.initialize()
|
||||
|
||||
assert len(fetcher.klines_15m) == 200, f"15m 캔들 {len(fetcher.klines_15m)}개 (200 예상)"
|
||||
assert len(fetcher.klines_1h) == 200, f"1h 캔들 {len(fetcher.klines_1h)}개 (200 예상)"
|
||||
print(f" ✓ 초기화 완료: 15m={len(fetcher.klines_15m)}개, 1h={len(fetcher.klines_1h)}개")
|
||||
|
||||
# ── 3. [:-1] 슬라이싱 검증 ──
|
||||
print("\n[3] get_1h_dataframe_completed() [:-1] 검증")
|
||||
df_1h = fetcher.get_1h_dataframe_completed()
|
||||
assert df_1h is not None, "1h DataFrame이 None"
|
||||
assert len(df_1h) == 199, f"1h completed 캔들 {len(df_1h)}개 (199 예상)"
|
||||
|
||||
# 마지막 완성 봉의 timestamp < 현재 진행 중 봉의 timestamp
|
||||
last_completed_ts = df_1h.index[-1]
|
||||
last_raw_ts = pd.to_datetime(fetcher.klines_1h[-1][0], unit="ms", utc=True)
|
||||
assert last_completed_ts < last_raw_ts, "completed 봉이 진행 중 봉을 포함"
|
||||
print(f" ✓ 1h completed: {len(df_1h)}개 (200 - 1 = 199, 미완성 봉 제외 확인)")
|
||||
print(f" 마지막 완성 봉: {last_completed_ts}")
|
||||
print(f" 진행 중 봉: {last_raw_ts} (제외됨)")
|
||||
|
||||
# 15m DataFrame 검증
|
||||
df_15m = fetcher.get_15m_dataframe()
|
||||
assert df_15m is not None and len(df_15m) == 200
|
||||
print(f" ✓ 15m DataFrame: {len(df_15m)}개")
|
||||
|
||||
# ── 4. MetaFilter 검증 ──
|
||||
print("\n[4] MetaFilter 검증")
|
||||
meta = MetaFilter(fetcher)
|
||||
|
||||
state = meta.get_market_state()
|
||||
assert state in ("LONG_ALLOWED", "SHORT_ALLOWED", "WAIT"), f"비정상 상태: {state}"
|
||||
print(f" ✓ MetaFilter 상태: {state}")
|
||||
|
||||
atr = meta.get_current_atr()
|
||||
assert atr is not None and atr > 0, f"ATR 비정상: {atr}"
|
||||
print(f" ✓ ATR: {atr:.6f} (> 0 확인)")
|
||||
|
||||
info = meta.get_meta_info()
|
||||
print(f" ✓ Meta Info: {info}")
|
||||
|
||||
# ATR 범위 검증 (XRP 기준 0.0001 ~ 0.1)
|
||||
assert 0.0001 <= atr <= 0.1, f"ATR 범위 이탈: {atr}"
|
||||
print(f" ✓ ATR 범위 정상: 0.0001 <= {atr:.6f} <= 0.1")
|
||||
|
||||
finally:
|
||||
await fetcher.close()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" 모든 검증 통과 ✓")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
async def test_module_3_4():
|
||||
"""
|
||||
Module 3 + 4 통합 테스트.
|
||||
|
||||
검증 항목:
|
||||
[Module 3 - TriggerStrategy]
|
||||
1. 신호 생성: 'EXECUTE_LONG' | 'EXECUTE_SHORT' | 'HOLD' 중 하나 반환
|
||||
2. EMA15: NaN 아님, 양수, 현실적 범위
|
||||
3. Vol_SMA20: NaN 아님, 양수
|
||||
4. vol_ratio: 0.0 ~ 2.0+ 범위 내
|
||||
5. 3캔들 시퀀스: t-2, t-1, t 인덱싱 정확성
|
||||
6. meta_state 필터: 'LONG_ALLOWED'에서만 LONG, 'SHORT_ALLOWED'에서만 SHORT
|
||||
|
||||
[Module 4 - ExecutionManager]
|
||||
7. 포지션 중복 방지
|
||||
8. SL/TP 계산: ATR * 1.5 (SL), ATR * 2.3 (TP)
|
||||
9. Dry-run 로그 출력
|
||||
10. 청산 후 재진입 가능
|
||||
"""
|
||||
print("=" * 60)
|
||||
print(" MTF Bot Module 3 & 4 통합 테스트")
|
||||
print("=" * 60)
|
||||
|
||||
# ── DataFetcher로 실제 데이터 로드 ──
|
||||
fetcher = DataFetcher(symbol="XRP/USDT:USDT")
|
||||
try:
|
||||
await fetcher.initialize()
|
||||
|
||||
df_15m = fetcher.get_15m_dataframe()
|
||||
assert df_15m is not None and len(df_15m) >= 25, "15m 데이터 부족"
|
||||
|
||||
meta = MetaFilter(fetcher)
|
||||
meta_state = meta.get_market_state()
|
||||
atr = meta.get_current_atr()
|
||||
print(f"\n[환경] MetaFilter: {meta_state} | ATR: {atr}")
|
||||
|
||||
# ── [Module 3] TriggerStrategy 검증 ──
|
||||
print("\n[1] TriggerStrategy 신호 생성")
|
||||
trigger = TriggerStrategy()
|
||||
|
||||
# 테스트 1: 실제 데이터로 신호 생성
|
||||
signal = trigger.generate_signal(df_15m, meta_state)
|
||||
assert signal in ("EXECUTE_LONG", "EXECUTE_SHORT", "HOLD"), f"비정상 신호: {signal}"
|
||||
print(f" ✓ 신호: {signal}")
|
||||
|
||||
info = trigger.get_trigger_info()
|
||||
print(f" ✓ Trigger Info: {info}")
|
||||
|
||||
# 테스트 2: 지표 값 검증
|
||||
if "ema15_t" in info:
|
||||
assert not pd.isna(info["ema15_t"]) and info["ema15_t"] > 0, "EMA15 비정상"
|
||||
assert not pd.isna(info["vol_sma20_t2"]) and info["vol_sma20_t2"] > 0, "Vol SMA20 비정상"
|
||||
assert 0 <= info["vol_ratio"] <= 100, f"vol_ratio 비정상: {info['vol_ratio']}"
|
||||
print(f" ✓ EMA15(t): {info['ema15_t']:.4f}")
|
||||
print(f" ✓ Vol SMA20(t-2): {info['vol_sma20_t2']:.0f}")
|
||||
print(f" ✓ Vol ratio: {info['vol_ratio']:.4f} ({'고갈' if info['vol_ratio'] < 0.5 else '정상'})")
|
||||
|
||||
# 테스트 3: meta_state=WAIT → 무조건 HOLD
|
||||
signal_wait = trigger.generate_signal(df_15m, "WAIT")
|
||||
assert signal_wait == "HOLD", "WAIT 상태에서 HOLD 아닌 신호 발생"
|
||||
print(f" ✓ meta_state=WAIT → {signal_wait}")
|
||||
|
||||
# 테스트 4: 데이터 부족 → HOLD
|
||||
signal_short = trigger.generate_signal(df_15m.iloc[:10], "LONG_ALLOWED")
|
||||
assert signal_short == "HOLD", "데이터 부족에서 HOLD 아닌 신호 발생"
|
||||
print(f" ✓ 데이터 부족(10행) → {signal_short}")
|
||||
|
||||
# 테스트 5: None DataFrame → HOLD
|
||||
signal_none = trigger.generate_signal(None, "LONG_ALLOWED")
|
||||
assert signal_none == "HOLD"
|
||||
print(f" ✓ None DataFrame → HOLD")
|
||||
|
||||
# ── [Module 4] ExecutionManager 검증 ──
|
||||
print(f"\n[2] ExecutionManager 검증")
|
||||
executor = ExecutionManager()
|
||||
|
||||
# 테스트 6: HOLD → None
|
||||
result = executor.execute("HOLD", 2.5, 0.01)
|
||||
assert result is None, "HOLD에서 주문 실행됨"
|
||||
print(f" ✓ HOLD → None")
|
||||
|
||||
# 테스트 7: ATR 무효 → None
|
||||
result = executor.execute("EXECUTE_LONG", 2.5, None)
|
||||
assert result is None, "ATR=None에서 주문 실행됨"
|
||||
result = executor.execute("EXECUTE_LONG", 2.5, 0)
|
||||
assert result is None, "ATR=0에서 주문 실행됨"
|
||||
print(f" ✓ ATR 무효 → None")
|
||||
|
||||
# 테스트 8: 정상 LONG 주문
|
||||
print(f"\n [LONG 가상 주문 테스트]")
|
||||
test_atr = 0.01
|
||||
result = executor.execute("EXECUTE_LONG", 2.5340, test_atr)
|
||||
assert result is not None, "정상 주문이 None 반환"
|
||||
assert result["action"] == "LONG"
|
||||
assert abs(result["sl_price"] - (2.5340 - 0.01 * 1.5)) < 1e-8, "SL 계산 오류"
|
||||
assert abs(result["tp_price"] - (2.5340 + 0.01 * 2.3)) < 1e-8, "TP 계산 오류"
|
||||
assert result["risk_reward"] == 1.53, f"R:R 오류: {result['risk_reward']}"
|
||||
print(f" ✓ LONG 주문: entry={result['entry_price']}, SL={result['sl_price']:.4f}, TP={result['tp_price']:.4f}")
|
||||
print(f" ✓ R:R = 1:{result['risk_reward']}")
|
||||
|
||||
# 테스트 9: 포지션 중복 방지
|
||||
result_dup = executor.execute("EXECUTE_SHORT", 2.5000, test_atr)
|
||||
assert result_dup is None, "중복 포지션 허용됨"
|
||||
assert executor.current_position == "LONG", "포지션 상태 변경됨"
|
||||
print(f" ✓ 중복 차단: LONG 포지션 중 SHORT 신호 → None")
|
||||
|
||||
# 테스트 10: 청산 후 재진입
|
||||
executor.close_position("테스트 청산")
|
||||
assert executor.current_position is None, "청산 후 포지션 잔존"
|
||||
print(f" ✓ 청산 완료, 포지션=None")
|
||||
|
||||
# 테스트 11: SHORT 주문
|
||||
print(f"\n [SHORT 가상 주문 테스트]")
|
||||
result_short = executor.execute("EXECUTE_SHORT", 2.5340, test_atr)
|
||||
assert result_short is not None
|
||||
assert result_short["action"] == "SHORT"
|
||||
assert abs(result_short["sl_price"] - (2.5340 + 0.01 * 1.5)) < 1e-8, "SHORT SL 오류"
|
||||
assert abs(result_short["tp_price"] - (2.5340 - 0.01 * 2.3)) < 1e-8, "SHORT TP 오류"
|
||||
print(f" ✓ SHORT 주문: entry={result_short['entry_price']}, SL={result_short['sl_price']:.4f}, TP={result_short['tp_price']:.4f}")
|
||||
|
||||
executor.close_position("테스트 종료")
|
||||
|
||||
# 테스트 12: 빈 포지션 청산 → 에러 없이 처리
|
||||
executor.close_position("이미 청산됨")
|
||||
print(f" ✓ 빈 포지션 청산 → 에러 없음")
|
||||
|
||||
finally:
|
||||
await fetcher.close()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" Module 3 & 4 모든 검증 통과 ✓")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
async def test_all():
|
||||
"""Module 1~4 전체 검증."""
|
||||
await test_module_1_2()
|
||||
print("\n")
|
||||
await test_module_3_4()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_all())
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
@@ -5,14 +6,28 @@ from loguru import logger
|
||||
class DiscordNotifier:
|
||||
"""Discord 웹훅으로 거래 알림을 전송하는 노티파이어."""
|
||||
|
||||
def __init__(self, webhook_url: str):
|
||||
def __init__(self, webhook_url: str, testnet: bool = False):
|
||||
self.webhook_url = webhook_url
|
||||
self._enabled = bool(webhook_url)
|
||||
self._testnet = testnet
|
||||
|
||||
def _send(self, content: str) -> None:
|
||||
"""알림 전송. 이벤트 루프 내에서는 백그라운드 스레드로 실행하여 블로킹 방지."""
|
||||
if self._testnet:
|
||||
content = f"[TESTNET] {content}"
|
||||
if not self._enabled:
|
||||
logger.debug("Discord 웹훅 URL 미설정 - 알림 건너뜀")
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
fut = loop.run_in_executor(None, self._send_sync, content)
|
||||
fut.add_done_callback(
|
||||
lambda f: f.exception() and logger.warning(f"Discord 전송 실패: {f.exception()}")
|
||||
)
|
||||
except RuntimeError:
|
||||
self._send_sync(content)
|
||||
|
||||
def _send_sync(self, content: str) -> None:
|
||||
try:
|
||||
resp = httpx.post(
|
||||
self.webhook_url,
|
||||
|
||||
@@ -11,18 +11,20 @@ class RiskManager:
|
||||
self.initial_balance: float = 0.0
|
||||
self.open_positions: dict[str, str] = {} # {symbol: side}
|
||||
self._lock = asyncio.Lock()
|
||||
self._entry_lock = asyncio.Lock() # 동시 진입 시 잔고 레이스 방지
|
||||
|
||||
def is_trading_allowed(self) -> bool:
|
||||
async def is_trading_allowed(self) -> bool:
|
||||
"""일일 최대 손실 초과 시 거래 중단"""
|
||||
if self.initial_balance <= 0:
|
||||
async with self._lock:
|
||||
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
|
||||
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:
|
||||
"""포지션 오픈 가능 여부 (전체 한도 + 중복 진입 + 동일 방향 제한)"""
|
||||
@@ -52,14 +54,16 @@ class RiskManager:
|
||||
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")
|
||||
async def record_pnl(self, pnl: float):
|
||||
async with self._lock:
|
||||
self.daily_pnl += pnl
|
||||
logger.info(f"오늘 누적 PnL: {self.daily_pnl:.4f} USDT")
|
||||
|
||||
def reset_daily(self):
|
||||
async def reset_daily(self):
|
||||
"""매일 자정 초기화"""
|
||||
self.daily_pnl = 0.0
|
||||
logger.info("일일 PnL 초기화")
|
||||
async with self._lock:
|
||||
self.daily_pnl = 0.0
|
||||
logger.info("일일 PnL 초기화")
|
||||
|
||||
def set_base_balance(self, balance: float) -> None:
|
||||
"""봇 시작 시 기준 잔고 설정"""
|
||||
|
||||
@@ -12,9 +12,10 @@ class UserDataStream:
|
||||
"""
|
||||
Binance Futures User Data Stream을 구독하여 주문 체결 이벤트를 처리한다.
|
||||
|
||||
- python-binance BinanceSocketManager의 내장 keepalive 활용
|
||||
- 매 재연결마다 AsyncClient + BinanceSocketManager를 새로 생성 (listenKey 무효화 대응)
|
||||
- 네트워크 단절 시 무한 재연결 루프
|
||||
- ORDER_TRADE_UPDATE 이벤트에서 지정 심볼의 청산 주문만 필터링하여 콜백 호출
|
||||
- 부분 체결(PARTIALLY_FILLED) 시 rp/commission을 누적하여 최종 FILLED에서 합산 콜백
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -24,23 +25,29 @@ class UserDataStream:
|
||||
):
|
||||
self._symbol = symbol.upper()
|
||||
self._on_order_filled = on_order_filled
|
||||
# 부분 체결 누적용: order_id → {rp, commission}
|
||||
self._partial_fills: dict[int, dict[str, float]] = {}
|
||||
|
||||
async def start(self, api_key: str, api_secret: str) -> None:
|
||||
async def start(self, api_key: str, api_secret: str, testnet: bool = False) -> None:
|
||||
"""User Data Stream 메인 루프 — 봇 종료 시까지 실행."""
|
||||
client = await AsyncClient.create(
|
||||
api_key=api_key,
|
||||
api_secret=api_secret,
|
||||
)
|
||||
bm = BinanceSocketManager(client)
|
||||
try:
|
||||
await self._run_loop(bm)
|
||||
finally:
|
||||
await client.close_connection()
|
||||
await self._run_loop(api_key, api_secret, testnet)
|
||||
|
||||
async def _run_loop(self, bm: BinanceSocketManager) -> None:
|
||||
"""연결 → 재연결 무한 루프. BinanceSocketManager가 listenKey keepalive를 내부 처리한다."""
|
||||
async def _run_loop(self, api_key: str, api_secret: str, testnet: bool = False) -> None:
|
||||
"""연결 → 재연결 무한 루프.
|
||||
|
||||
매 재연결마다 AsyncClient + BinanceSocketManager를 새로 생성한다.
|
||||
keepalive ping timeout 후 기존 BinanceSocketManager의 listenKey가
|
||||
무효화되면 재사용 시 이벤트를 수신하지 못하는 "조용한 실패"가 발생하므로,
|
||||
반드시 새 인스턴스를 만들어야 한다.
|
||||
"""
|
||||
while True:
|
||||
client = await AsyncClient.create(
|
||||
api_key=api_key,
|
||||
api_secret=api_secret,
|
||||
demo=testnet,
|
||||
)
|
||||
try:
|
||||
bm = BinanceSocketManager(client)
|
||||
async with bm.futures_user_socket() as stream:
|
||||
logger.info(f"User Data Stream 연결 완료 (심볼 필터: {self._symbol})")
|
||||
while True:
|
||||
@@ -57,6 +64,10 @@ class UserDataStream:
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("User Data Stream 정상 종료")
|
||||
try:
|
||||
await client.close_connection()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
@@ -64,7 +75,13 @@ class UserDataStream:
|
||||
f"User Data Stream 끊김: {e} — "
|
||||
f"{_RECONNECT_DELAY}초 후 재연결"
|
||||
)
|
||||
await asyncio.sleep(_RECONNECT_DELAY)
|
||||
finally:
|
||||
try:
|
||||
await client.close_connection()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(_RECONNECT_DELAY)
|
||||
|
||||
async def _handle_message(self, msg: dict) -> None:
|
||||
"""ORDER_TRADE_UPDATE 이벤트에서 청산 주문을 필터링하여 콜백을 호출한다."""
|
||||
@@ -77,23 +94,66 @@ class UserDataStream:
|
||||
if order.get("s", "") != self._symbol:
|
||||
return
|
||||
|
||||
# x: Execution Type, X: Order Status
|
||||
if order.get("x") != "TRADE" or order.get("X") != "FILLED":
|
||||
logger.info(
|
||||
f"[{self._symbol}] UDS 원본: s={order.get('s')} o={order.get('o')} "
|
||||
f"ot={order.get('ot')} x={order.get('x')} X={order.get('X')} "
|
||||
f"R={order.get('R')} S={order.get('S')} ap={order.get('ap')} "
|
||||
f"rp={order.get('rp')}"
|
||||
)
|
||||
|
||||
# x: Execution Type — TRADE만 처리
|
||||
if order.get("x") != "TRADE":
|
||||
return
|
||||
|
||||
order_type = order.get("o", "")
|
||||
order_status = order.get("X", "")
|
||||
order_type = order.get("ot", order.get("o", ""))
|
||||
is_reduce = order.get("R", False)
|
||||
realized_pnl = float(order.get("rp", "0"))
|
||||
order_id = order.get("i", 0)
|
||||
|
||||
# 청산 주문 판별: reduceOnly이거나, TP/SL 타입이거나, rp != 0
|
||||
is_close = is_reduce or order_type in _CLOSE_ORDER_TYPES or realized_pnl != 0
|
||||
# 청산 주문 판별: reduceOnly이거나 TP/SL 타입
|
||||
is_close = is_reduce or order_type in _CLOSE_ORDER_TYPES
|
||||
if not is_close:
|
||||
return
|
||||
|
||||
commission = abs(float(order.get("n", "0")))
|
||||
logger.info(
|
||||
f"[{self._symbol}] 청산 주문 상세: "
|
||||
f"type={order_type}, status={order_status}, "
|
||||
f"reduce={is_reduce}, id={order_id}"
|
||||
)
|
||||
|
||||
fill_rp = float(order.get("rp", "0"))
|
||||
fill_commission = abs(float(order.get("n", "0")))
|
||||
|
||||
if order_status == "PARTIALLY_FILLED":
|
||||
# 부분 체결: rp와 commission을 누적
|
||||
if order_id not in self._partial_fills:
|
||||
self._partial_fills[order_id] = {"rp": 0.0, "commission": 0.0}
|
||||
self._partial_fills[order_id]["rp"] += fill_rp
|
||||
self._partial_fills[order_id]["commission"] += fill_commission
|
||||
logger.debug(
|
||||
f"[{self._symbol}] 부분 체결 누적 (order_id={order_id}): "
|
||||
f"rp={fill_rp:+.4f}, commission={fill_commission:.4f}"
|
||||
)
|
||||
return
|
||||
|
||||
if order_status != "FILLED":
|
||||
return
|
||||
|
||||
# 최종 체결: 이전 부분 체결분 합산
|
||||
accumulated = self._partial_fills.pop(order_id, {"rp": 0.0, "commission": 0.0})
|
||||
realized_pnl = accumulated["rp"] + fill_rp
|
||||
commission = accumulated["commission"] + fill_commission
|
||||
|
||||
net_pnl = realized_pnl - commission
|
||||
exit_price = float(order.get("ap", "0"))
|
||||
|
||||
if exit_price == 0.0:
|
||||
logger.warning(
|
||||
f"[{self._symbol}] 청산 이벤트에서 exit_price=0.0 — "
|
||||
f"ap 필드 누락 가능. 청산 처리 스킵 (rp={realized_pnl:+.4f})"
|
||||
)
|
||||
return
|
||||
|
||||
if order_type == "TAKE_PROFIT_MARKET":
|
||||
close_reason = "TP"
|
||||
elif order_type == "STOP_MARKET":
|
||||
|
||||
@@ -19,6 +19,7 @@ def config():
|
||||
"NOTION_TOKEN": "secret_test",
|
||||
"NOTION_DATABASE_ID": "db_test",
|
||||
"DISCORD_WEBHOOK_URL": "",
|
||||
"BINANCE_TESTNET": "false",
|
||||
})
|
||||
return Config()
|
||||
|
||||
@@ -34,6 +35,7 @@ def sample_df():
|
||||
"low": close * 0.995,
|
||||
"close": close,
|
||||
"volume": np.random.randint(100000, 1000000, n).astype(float),
|
||||
"atr": np.full(n, 0.005),
|
||||
})
|
||||
|
||||
|
||||
@@ -84,7 +86,7 @@ async def test_bot_processes_signal(config, sample_df):
|
||||
bot.exchange.MIN_NOTIONAL = 5.0
|
||||
|
||||
bot.risk = MagicMock()
|
||||
bot.risk.is_trading_allowed.return_value = True
|
||||
bot.risk.is_trading_allowed = AsyncMock(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
|
||||
@@ -92,7 +94,7 @@ async def test_bot_processes_signal(config, sample_df):
|
||||
with patch("src.bot.Indicators") as MockInd:
|
||||
mock_ind = MagicMock()
|
||||
mock_ind.calculate_all.return_value = sample_df
|
||||
mock_ind.get_signal.return_value = "LONG"
|
||||
mock_ind.get_signal.return_value = ("LONG", {"long": 3, "short": 0, "vol_surge": True, "adx": 30.0, "hold_reason": ""})
|
||||
mock_ind.get_atr_stop.return_value = (0.48, 0.56)
|
||||
MockInd.return_value = mock_ind
|
||||
await bot.process_candle(sample_df)
|
||||
@@ -108,10 +110,14 @@ async def test_close_and_reenter_calls_open_when_ml_passes(config, sample_df):
|
||||
bot._open_position = AsyncMock()
|
||||
bot.risk = MagicMock()
|
||||
bot.risk.can_open_new_position = AsyncMock(return_value=True)
|
||||
bot.risk.close_position = AsyncMock()
|
||||
bot.ml_filter = MagicMock()
|
||||
bot.ml_filter.is_model_loaded.return_value = True
|
||||
bot.ml_filter.should_enter.return_value = True
|
||||
|
||||
# 콜백 대기를 건너뛰도록 Event 미리 설정
|
||||
bot._close_event.set()
|
||||
|
||||
position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"}
|
||||
await bot._close_and_reenter(position, "SHORT", sample_df)
|
||||
|
||||
@@ -129,10 +135,13 @@ async def test_close_and_reenter_skips_open_when_ml_blocks(config, sample_df):
|
||||
bot._open_position = AsyncMock()
|
||||
bot.risk = MagicMock()
|
||||
bot.risk.can_open_new_position = AsyncMock(return_value=True)
|
||||
bot.risk.close_position = AsyncMock()
|
||||
bot.ml_filter = MagicMock()
|
||||
bot.ml_filter.is_model_loaded.return_value = True
|
||||
bot.ml_filter.should_enter.return_value = False
|
||||
|
||||
bot._close_event.set()
|
||||
|
||||
position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"}
|
||||
await bot._close_and_reenter(position, "SHORT", sample_df)
|
||||
|
||||
@@ -150,6 +159,9 @@ async def test_close_and_reenter_skips_open_when_max_positions_reached(config, s
|
||||
bot._open_position = AsyncMock()
|
||||
bot.risk = MagicMock()
|
||||
bot.risk.can_open_new_position = AsyncMock(return_value=False)
|
||||
bot.risk.close_position = AsyncMock()
|
||||
|
||||
bot._close_event.set()
|
||||
|
||||
position = {"positionAmt": "100", "entryPrice": "0.5", "markPrice": "0.52"}
|
||||
await bot._close_and_reenter(position, "SHORT", sample_df)
|
||||
@@ -178,7 +190,7 @@ async def test_process_candle_calls_close_and_reenter_on_reverse_signal(config,
|
||||
with patch("src.bot.Indicators") as MockInd:
|
||||
mock_ind = MagicMock()
|
||||
mock_ind.calculate_all.return_value = sample_df
|
||||
mock_ind.get_signal.return_value = "SHORT" # 현재 LONG 포지션에 반대 시그널
|
||||
mock_ind.get_signal.return_value = ("SHORT", {"long": 0, "short": 3, "vol_surge": True, "adx": 30.0, "hold_reason": ""}) # 현재 LONG 포지션에 반대 시그널
|
||||
MockInd.return_value = mock_ind
|
||||
await bot.process_candle(sample_df)
|
||||
|
||||
@@ -207,7 +219,7 @@ async def test_process_candle_passes_raw_signal_to_close_and_reenter_even_if_ml_
|
||||
with patch("src.bot.Indicators") as MockInd:
|
||||
mock_ind = MagicMock()
|
||||
mock_ind.calculate_all.return_value = sample_df
|
||||
mock_ind.get_signal.return_value = "SHORT"
|
||||
mock_ind.get_signal.return_value = ("SHORT", {"long": 0, "short": 3, "vol_surge": True, "adx": 30.0, "hold_reason": ""})
|
||||
MockInd.return_value = mock_ind
|
||||
await bot.process_candle(sample_df)
|
||||
|
||||
@@ -234,7 +246,7 @@ async def test_process_candle_fetches_oi_and_funding(config, sample_df):
|
||||
bot.exchange.get_funding_rate = AsyncMock(return_value=0.0001)
|
||||
|
||||
bot.risk = MagicMock()
|
||||
bot.risk.is_trading_allowed.return_value = True
|
||||
bot.risk.is_trading_allowed = AsyncMock(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
|
||||
@@ -243,7 +255,7 @@ async def test_process_candle_fetches_oi_and_funding(config, sample_df):
|
||||
with patch("src.bot.Indicators") as mock_ind_cls:
|
||||
mock_ind = MagicMock()
|
||||
mock_ind.calculate_all.return_value = sample_df
|
||||
mock_ind.get_signal.return_value = "LONG"
|
||||
mock_ind.get_signal.return_value = ("LONG", {"long": 3, "short": 0, "vol_surge": True, "adx": 30.0, "hold_reason": ""})
|
||||
mock_ind_cls.return_value = mock_ind
|
||||
|
||||
with patch("src.bot.build_features_aligned") as mock_build:
|
||||
@@ -266,7 +278,7 @@ def test_bot_has_oi_history_deque(config):
|
||||
with patch("src.bot.BinanceFuturesClient"):
|
||||
bot = TradingBot(config)
|
||||
assert isinstance(bot._oi_history, deque)
|
||||
assert bot._oi_history.maxlen == 5
|
||||
assert bot._oi_history.maxlen == 96
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -48,3 +48,28 @@ def test_config_max_same_direction_default():
|
||||
"""동일 방향 최대 수 기본값 2."""
|
||||
cfg = Config()
|
||||
assert cfg.max_same_direction == 2
|
||||
|
||||
|
||||
def test_config_rejects_zero_leverage():
|
||||
"""LEVERAGE=0은 ValueError."""
|
||||
os.environ["LEVERAGE"] = "0"
|
||||
with pytest.raises(ValueError, match="LEVERAGE"):
|
||||
Config()
|
||||
os.environ["LEVERAGE"] = "10" # 복원
|
||||
|
||||
|
||||
def test_config_rejects_invalid_margin_ratio():
|
||||
"""MARGIN_MAX_RATIO가 0이면 ValueError."""
|
||||
os.environ["MARGIN_MAX_RATIO"] = "0"
|
||||
with pytest.raises(ValueError, match="MARGIN_MAX_RATIO"):
|
||||
Config()
|
||||
os.environ["MARGIN_MAX_RATIO"] = "0.50" # 복원
|
||||
|
||||
|
||||
def test_config_rejects_min_gt_max_margin():
|
||||
"""MARGIN_MIN > MAX이면 ValueError."""
|
||||
os.environ["MARGIN_MIN_RATIO"] = "0.80"
|
||||
os.environ["MARGIN_MAX_RATIO"] = "0.50"
|
||||
with pytest.raises(ValueError, match="MARGIN_MIN_RATIO"):
|
||||
Config()
|
||||
os.environ["MARGIN_MIN_RATIO"] = "0.20" # 복원
|
||||
|
||||
@@ -6,10 +6,11 @@ import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "dashboard", "api"))
|
||||
|
||||
# DB_PATH를 테스트용 임시 파일로 설정 (import 전에)
|
||||
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
os.environ["DB_PATH"] = _tmp_db.name
|
||||
_tmp_db.close()
|
||||
# DB_PATH와 DASHBOARD_RESET_KEY를 테스트용으로 설정 (import 전에)
|
||||
_tmp_dir = tempfile.mkdtemp()
|
||||
_tmp_db_path = os.path.join(_tmp_dir, "test_dashboard.db")
|
||||
os.environ["DB_PATH"] = _tmp_db_path
|
||||
os.environ["DASHBOARD_RESET_KEY"] = "test-reset-key"
|
||||
|
||||
import dashboard_api # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
@@ -90,9 +91,18 @@ def setup_db():
|
||||
"INSERT INTO candles(symbol,ts,price,signal) VALUES(?,?,?,?)",
|
||||
("TRXUSDT", "2026-03-06 00:00:00", 0.23, "SHORT"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO daily_pnl(symbol,date,cumulative_pnl,trade_count,wins,losses) VALUES(?,?,?,?,?,?)",
|
||||
("TRXUSDT", "2026-03-05", 10.0, 1, 1, 0),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
yield
|
||||
# cleanup
|
||||
try:
|
||||
os.unlink(db_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
client = TestClient(dashboard_api.app)
|
||||
@@ -122,8 +132,10 @@ def test_get_position_by_symbol():
|
||||
def test_get_trades_by_symbol():
|
||||
r = client.get("/api/trades?symbol=TRXUSDT")
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()["trades"]) == 1
|
||||
assert r.json()["trades"][0]["symbol"] == "TRXUSDT"
|
||||
data = r.json()
|
||||
assert len(data["trades"]) == 1
|
||||
assert data["trades"][0]["symbol"] == "TRXUSDT"
|
||||
assert data["total"] == 1
|
||||
|
||||
|
||||
def test_get_candles_by_symbol():
|
||||
@@ -142,3 +154,77 @@ def test_get_stats_by_symbol():
|
||||
r = client.get("/api/stats?symbol=TRXUSDT")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["total_trades"] == 1
|
||||
|
||||
|
||||
# ── M6: 누락된 테스트 추가 ──────────────────────────────────────
|
||||
|
||||
def test_health():
|
||||
r = client.get("/api/health")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["candles_count"] >= 0
|
||||
|
||||
|
||||
def test_daily():
|
||||
r = client.get("/api/daily?symbol=TRXUSDT")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data["daily"]) == 1
|
||||
assert data["daily"][0]["net_pnl"] == 10.0
|
||||
|
||||
|
||||
def test_daily_all():
|
||||
r = client.get("/api/daily")
|
||||
assert r.status_code == 200
|
||||
assert "daily" in r.json()
|
||||
|
||||
|
||||
def test_reset_requires_api_key():
|
||||
"""C1: API key 없이 reset 호출 시 403."""
|
||||
r = client.post("/api/reset")
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_reset_wrong_api_key():
|
||||
"""C1: 잘못된 API key로 reset 호출 시 403."""
|
||||
r = client.post("/api/reset", headers={"X-API-Key": "wrong-key"})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_reset_with_valid_key():
|
||||
"""C1+C2: 올바른 API key로 reset 호출 시 성공."""
|
||||
r = client.post("/api/reset", headers={"X-API-Key": "test-reset-key"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
|
||||
# DB가 비워졌는지 확인
|
||||
r2 = client.get("/api/trades")
|
||||
assert r2.json()["total"] == 0
|
||||
|
||||
|
||||
def test_trades_offset_validation():
|
||||
"""I2: 음수 offset은 422 에러."""
|
||||
r = client.get("/api/trades?offset=-1")
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_trades_pagination():
|
||||
"""M6: 페이지네이션 동작 확인."""
|
||||
r = client.get("/api/trades?limit=1&offset=0")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data["trades"]) <= 1
|
||||
assert "total" in data
|
||||
|
||||
|
||||
def test_health_error_no_detail_leak():
|
||||
"""I6: health에서 에러 시 내부 경로 미노출."""
|
||||
# 일시적으로 DB 경로를 존재하지 않는 곳으로 설정
|
||||
original = dashboard_api.DB_PATH
|
||||
dashboard_api.DB_PATH = "/nonexistent/path/db.sqlite"
|
||||
r = client.get("/api/health")
|
||||
dashboard_api.DB_PATH = original
|
||||
data = r.json()
|
||||
assert data["status"] == "error"
|
||||
assert "/nonexistent" not in data.get("detail", "")
|
||||
|
||||
@@ -73,25 +73,6 @@ def test_generate_dataset_vectorized_with_btc_eth_has_21_feature_cols():
|
||||
assert "label" in result.columns
|
||||
|
||||
|
||||
def test_matches_original_generate_dataset(sample_df):
|
||||
"""벡터화 버전과 기존 버전의 샘플 수가 유사해야 한다.
|
||||
|
||||
벡터화 버전은 전체 시계열로 지표를 1회 계산하고, 기존 버전은 61행 슬라이딩
|
||||
윈도우로 매번 재계산한다. EMA 등 지수 이동평균은 초기값에 따라 수렴 속도가
|
||||
달라지므로 두 방식의 신호 수는 완전히 동일하지 않을 수 있다. ±50% 범위를
|
||||
허용한다.
|
||||
"""
|
||||
from scripts.train_model import generate_dataset
|
||||
orig = generate_dataset(sample_df, n_jobs=1)
|
||||
vec = generate_dataset_vectorized(sample_df)
|
||||
if len(orig) == 0:
|
||||
assert len(vec) == 0
|
||||
return
|
||||
ratio = len(vec) / len(orig)
|
||||
assert 0.5 <= ratio <= 2.0, (
|
||||
f"샘플 수 차이가 너무 큼: 벡터화={len(vec)}, 기존={len(orig)}, 비율={ratio:.2f}"
|
||||
)
|
||||
|
||||
|
||||
def test_epsilon_no_division_by_zero():
|
||||
"""bb_range=0, close=0, vol_ma20=0 극단값에서 nan/inf가 발생하지 않아야 한다."""
|
||||
@@ -203,11 +184,11 @@ def test_rs_zero_denominator():
|
||||
signal_arr = _calc_signals(d)
|
||||
feat = _calc_features_vectorized(d, signal_arr, btc_df=btc_df, eth_df=eth_df)
|
||||
|
||||
assert "xrp_btc_rs" in feat.columns, "xrp_btc_rs 컬럼이 있어야 함"
|
||||
assert not feat["xrp_btc_rs"].isin([np.inf, -np.inf]).any(), \
|
||||
"xrp_btc_rs에 inf가 있으면 안 됨"
|
||||
assert not feat["xrp_btc_rs"].isna().all(), \
|
||||
"xrp_btc_rs가 전부 nan이면 안 됨"
|
||||
assert "primary_btc_rs" in feat.columns, "primary_btc_rs 컬럼이 있어야 함"
|
||||
assert not feat["primary_btc_rs"].isin([np.inf, -np.inf]).any(), \
|
||||
"primary_btc_rs에 inf가 있으면 안 됨"
|
||||
assert not feat["primary_btc_rs"].isna().all(), \
|
||||
"primary_btc_rs가 전부 nan이면 안 됨"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import threading
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from src.exchange import BinanceFuturesClient
|
||||
@@ -23,6 +24,9 @@ def client():
|
||||
c = BinanceFuturesClient.__new__(BinanceFuturesClient)
|
||||
c.config = config
|
||||
c.symbol = config.symbol
|
||||
c._qty_precision = 1
|
||||
c._price_precision = 4
|
||||
c._api_lock = threading.Lock()
|
||||
return c
|
||||
|
||||
|
||||
@@ -39,6 +43,9 @@ def exchange():
|
||||
c.config = config
|
||||
c.symbol = config.symbol
|
||||
c.client = MagicMock()
|
||||
c._qty_precision = 1
|
||||
c._price_precision = 4
|
||||
c._api_lock = threading.Lock()
|
||||
return c
|
||||
|
||||
|
||||
|
||||
@@ -66,10 +66,11 @@ def test_adx_filter_blocks_low_adx(sample_df):
|
||||
df.loc[df.index[-1], "volume"] = df.loc[df.index[-1], "vol_ma20"] * 3
|
||||
df["adx"] = 15.0
|
||||
# 기본 adx_threshold=25이므로 ADX=15은 HOLD
|
||||
signal = ind.get_signal(df)
|
||||
signal, detail = ind.get_signal(df)
|
||||
assert signal == "HOLD"
|
||||
assert "ADX" in detail["hold_reason"]
|
||||
# adx_threshold=0이면 ADX 필터 비활성화 → LONG
|
||||
signal = ind.get_signal(df, adx_threshold=0)
|
||||
signal, detail = ind.get_signal(df, adx_threshold=0)
|
||||
assert signal == "LONG"
|
||||
|
||||
|
||||
@@ -78,13 +79,15 @@ def test_adx_nan_falls_through(sample_df):
|
||||
ind = Indicators(sample_df)
|
||||
df = ind.calculate_all()
|
||||
df["adx"] = float("nan")
|
||||
signal = ind.get_signal(df)
|
||||
signal, detail = ind.get_signal(df)
|
||||
# NaN이면 차단하지 않고 기존 로직 실행 → LONG/SHORT/HOLD 중 하나
|
||||
assert signal in ("LONG", "SHORT", "HOLD")
|
||||
assert detail["adx"] is None
|
||||
|
||||
|
||||
def test_signal_returns_direction(sample_df):
|
||||
ind = Indicators(sample_df)
|
||||
df = ind.calculate_all()
|
||||
signal = ind.get_signal(df)
|
||||
signal, detail = ind.get_signal(df)
|
||||
assert signal in ("LONG", "SHORT", "HOLD")
|
||||
assert "long" in detail and "short" in detail
|
||||
|
||||
@@ -48,7 +48,7 @@ def test_build_features_rs_zero_when_btc_ret_zero():
|
||||
btc_df["close"] = 50000.0 # 모든 캔들 동일
|
||||
eth_df = _make_df(10, base_price=3000.0)
|
||||
features = build_features(xrp_df, "LONG", btc_df=btc_df, eth_df=eth_df)
|
||||
assert features["xrp_btc_rs"] == 0.0
|
||||
assert features["primary_btc_rs"] == 0.0
|
||||
|
||||
def test_feature_cols_has_24_items():
|
||||
"""Legacy test — updated to 26 after OI derived features added."""
|
||||
|
||||
@@ -29,6 +29,7 @@ def test_no_model_should_enter_returns_true(tmp_path):
|
||||
assert f.should_enter(features) is True
|
||||
|
||||
|
||||
@patch.dict("os.environ", {"NO_ML_FILTER": "false"})
|
||||
def test_should_enter_above_threshold():
|
||||
"""확률 >= 0.60 이면 True"""
|
||||
f = MLFilter(threshold=0.60)
|
||||
@@ -40,6 +41,7 @@ def test_should_enter_above_threshold():
|
||||
assert f.should_enter(features) is True
|
||||
|
||||
|
||||
@patch.dict("os.environ", {"NO_ML_FILTER": "false"})
|
||||
def test_should_enter_below_threshold():
|
||||
"""확률 < 0.60 이면 False"""
|
||||
f = MLFilter(threshold=0.60)
|
||||
|
||||
162
tests/test_ml_pipeline_fixes.py
Normal file
162
tests/test_ml_pipeline_fixes.py
Normal file
@@ -0,0 +1,162 @@
|
||||
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_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)}"
|
||||
|
||||
|
||||
def test_sltp_params_are_passed_through(signal_df):
|
||||
"""SL/TP 배수가 generate_dataset_vectorized에 전달되어야 한다."""
|
||||
# 파라미터가 수용되는지(TypeError 없이) 확인하는 것이 핵심
|
||||
# negative_ratio=0으로 시그널 샘플만 비교 (HOLD 노이즈 제거)
|
||||
r1 = generate_dataset_vectorized(
|
||||
signal_df, atr_sl_mult=1.5, atr_tp_mult=2.0,
|
||||
adx_threshold=0, volume_multiplier=1.5, negative_ratio=0,
|
||||
)
|
||||
r2 = generate_dataset_vectorized(
|
||||
signal_df, atr_sl_mult=2.0, atr_tp_mult=2.0,
|
||||
adx_threshold=0, volume_multiplier=1.5, negative_ratio=0,
|
||||
)
|
||||
# 두 결과 모두 DataFrame이어야 한다
|
||||
assert isinstance(r1, pd.DataFrame)
|
||||
assert isinstance(r2, pd.DataFrame)
|
||||
# 신호가 충분히 많을 경우, 다른 SL 배수는 레이블 분포에 영향을 줄 수 있다
|
||||
# 소규모 데이터에서는 동일한 결과가 나올 수 있으므로 50개 이상일 때만 검증
|
||||
if len(r1) > 50 and len(r2) > 50:
|
||||
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 파라미터 미지정 시 기본값(2.0, 2.0)으로 동작해야 한다."""
|
||||
r_default = generate_dataset_vectorized(
|
||||
signal_df, adx_threshold=0, volume_multiplier=1.5, negative_ratio=0,
|
||||
)
|
||||
r_explicit = generate_dataset_vectorized(
|
||||
signal_df, atr_sl_mult=2.0, atr_tp_mult=2.0,
|
||||
adx_threshold=0, volume_multiplier=1.5, negative_ratio=0,
|
||||
)
|
||||
if len(r_default) > 0:
|
||||
assert len(r_default) == len(r_explicit)
|
||||
assert (r_default["label"].values == r_explicit["label"].values).all()
|
||||
|
||||
|
||||
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 = []
|
||||
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,
|
||||
)}
|
||||
|
||||
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']}"
|
||||
|
||||
|
||||
def test_mlx_no_double_normalization():
|
||||
"""MLXFilter.fit()에 normalize=False를 전달하면 내부 정규화를 건너뛰어야 한다."""
|
||||
pytest.importorskip("mlx.core")
|
||||
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)
|
||||
|
||||
assert np.allclose(model._mean, 0.0), "normalize=False시 mean은 0이어야 한다"
|
||||
assert np.allclose(model._std, 1.0), "normalize=False시 std는 1이어야 한다"
|
||||
|
||||
|
||||
def test_walk_forward_purged_gap():
|
||||
"""Walk-Forward 검증에서 학습/검증 사이에 LOOKAHEAD 만큼의 gap이 존재해야 한다."""
|
||||
from src.dataset_builder import LOOKAHEAD
|
||||
|
||||
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
|
||||
val_end = val_start + step
|
||||
if val_end > n:
|
||||
break
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
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"
|
||||
423
tests/test_mtf_bot.py
Normal file
423
tests/test_mtf_bot.py
Normal file
@@ -0,0 +1,423 @@
|
||||
"""
|
||||
MTF Pullback Bot 유닛 테스트
|
||||
─────────────────────────────
|
||||
합성 데이터 기반, 외부 API 호출 없음.
|
||||
"""
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from src.mtf_bot import (
|
||||
DataFetcher,
|
||||
ExecutionManager,
|
||||
MetaFilter,
|
||||
TriggerStrategy,
|
||||
)
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_1h_df():
|
||||
"""EMA50/200, ADX, ATR 계산에 충분한 250개 1h 합성 캔들."""
|
||||
np.random.seed(42)
|
||||
n = 250
|
||||
# 완만한 상승 추세 (EMA50 > EMA200이 되도록)
|
||||
close = np.cumsum(np.random.randn(n) * 0.001 + 0.0005) + 2.0
|
||||
high = close + np.abs(np.random.randn(n)) * 0.005
|
||||
low = close - np.abs(np.random.randn(n)) * 0.005
|
||||
open_ = close + np.random.randn(n) * 0.001
|
||||
|
||||
# 완성된 캔들 timestamp (1h 간격, 과거 시점)
|
||||
base_ts = pd.Timestamp("2026-01-01", tz="UTC")
|
||||
timestamps = pd.date_range(start=base_ts, periods=n, freq="1h")
|
||||
|
||||
df = pd.DataFrame({
|
||||
"open": open_,
|
||||
"high": high,
|
||||
"low": low,
|
||||
"close": close,
|
||||
"volume": np.random.randint(100000, 1000000, n).astype(float),
|
||||
}, index=timestamps)
|
||||
df.index.name = "timestamp"
|
||||
return df
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_15m_df():
|
||||
"""TriggerStrategy용 50개 15m 합성 캔들."""
|
||||
np.random.seed(99)
|
||||
n = 50
|
||||
close = np.cumsum(np.random.randn(n) * 0.001) + 0.5
|
||||
high = close + np.abs(np.random.randn(n)) * 0.003
|
||||
low = close - np.abs(np.random.randn(n)) * 0.003
|
||||
open_ = close + np.random.randn(n) * 0.001
|
||||
|
||||
base_ts = pd.Timestamp("2026-01-01", tz="UTC")
|
||||
timestamps = pd.date_range(start=base_ts, periods=n, freq="15min")
|
||||
|
||||
df = pd.DataFrame({
|
||||
"open": open_,
|
||||
"high": high,
|
||||
"low": low,
|
||||
"close": close,
|
||||
"volume": np.random.randint(100000, 1000000, n).astype(float),
|
||||
}, index=timestamps)
|
||||
df.index.name = "timestamp"
|
||||
return df
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Test 1: _remove_incomplete_candle
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestRemoveIncompleteCandle:
|
||||
"""DataFetcher._remove_incomplete_candle 정적 메서드 테스트."""
|
||||
|
||||
def test_removes_incomplete_15m_candle(self):
|
||||
"""현재 15m 슬롯에 해당하는 미완성 캔들은 제거되어야 한다."""
|
||||
now_ms = int(time.time() * 1000)
|
||||
current_slot_ms = (now_ms // (900 * 1000)) * (900 * 1000)
|
||||
|
||||
# 완성 캔들 2개 + 미완성 캔들 1개
|
||||
timestamps = [
|
||||
pd.Timestamp(current_slot_ms - 1800_000, unit="ms", tz="UTC"), # 2슬롯 전
|
||||
pd.Timestamp(current_slot_ms - 900_000, unit="ms", tz="UTC"), # 1슬롯 전
|
||||
pd.Timestamp(current_slot_ms, unit="ms", tz="UTC"), # 현재 슬롯 (미완성)
|
||||
]
|
||||
df = pd.DataFrame({
|
||||
"open": [1.0, 1.1, 1.2],
|
||||
"high": [1.05, 1.15, 1.25],
|
||||
"low": [0.95, 1.05, 1.15],
|
||||
"close": [1.02, 1.12, 1.22],
|
||||
"volume": [100.0, 200.0, 300.0],
|
||||
}, index=timestamps)
|
||||
|
||||
result = DataFetcher._remove_incomplete_candle(df, interval_sec=900)
|
||||
assert len(result) == 2, f"미완성 캔들 제거 실패: {len(result)}개 (2개 예상)"
|
||||
|
||||
def test_keeps_all_completed_candles(self):
|
||||
"""모든 캔들이 완성된 경우 제거하지 않아야 한다."""
|
||||
now_ms = int(time.time() * 1000)
|
||||
current_slot_ms = (now_ms // (900 * 1000)) * (900 * 1000)
|
||||
|
||||
# 모두 과거 슬롯의 완성 캔들
|
||||
timestamps = [
|
||||
pd.Timestamp(current_slot_ms - 2700_000, unit="ms", tz="UTC"),
|
||||
pd.Timestamp(current_slot_ms - 1800_000, unit="ms", tz="UTC"),
|
||||
pd.Timestamp(current_slot_ms - 900_000, unit="ms", tz="UTC"),
|
||||
]
|
||||
df = pd.DataFrame({
|
||||
"open": [1.0, 1.1, 1.2],
|
||||
"high": [1.05, 1.15, 1.25],
|
||||
"low": [0.95, 1.05, 1.15],
|
||||
"close": [1.02, 1.12, 1.22],
|
||||
"volume": [100.0, 200.0, 300.0],
|
||||
}, index=timestamps)
|
||||
|
||||
result = DataFetcher._remove_incomplete_candle(df, interval_sec=900)
|
||||
assert len(result) == 3, f"완성 캔들 유지 실패: {len(result)}개 (3개 예상)"
|
||||
|
||||
def test_empty_dataframe(self):
|
||||
"""빈 DataFrame 입력 시 빈 DataFrame 반환."""
|
||||
df = pd.DataFrame(columns=["open", "high", "low", "close", "volume"])
|
||||
result = DataFetcher._remove_incomplete_candle(df, interval_sec=900)
|
||||
assert result.empty
|
||||
|
||||
def test_1h_interval(self):
|
||||
"""1h 간격에서도 정상 동작."""
|
||||
now_ms = int(time.time() * 1000)
|
||||
current_slot_ms = (now_ms // (3600 * 1000)) * (3600 * 1000)
|
||||
|
||||
timestamps = [
|
||||
pd.Timestamp(current_slot_ms - 7200_000, unit="ms", tz="UTC"),
|
||||
pd.Timestamp(current_slot_ms - 3600_000, unit="ms", tz="UTC"),
|
||||
pd.Timestamp(current_slot_ms, unit="ms", tz="UTC"), # 현재 슬롯 (미완성)
|
||||
]
|
||||
df = pd.DataFrame({
|
||||
"open": [1.0, 1.1, 1.2],
|
||||
"high": [1.05, 1.15, 1.25],
|
||||
"low": [0.95, 1.05, 1.15],
|
||||
"close": [1.02, 1.12, 1.22],
|
||||
"volume": [100.0, 200.0, 300.0],
|
||||
}, index=timestamps)
|
||||
|
||||
result = DataFetcher._remove_incomplete_candle(df, interval_sec=3600)
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Test 2: MetaFilter
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestMetaFilter:
|
||||
"""MetaFilter 상태 판별 로직 테스트."""
|
||||
|
||||
def _make_fetcher_with_df(self, df_1h):
|
||||
"""Mock DataFetcher를 생성하여 특정 1h DataFrame을 반환하도록 설정."""
|
||||
fetcher = DataFetcher.__new__(DataFetcher)
|
||||
fetcher.klines_15m = []
|
||||
fetcher.klines_1h = []
|
||||
fetcher.data_fetcher = None
|
||||
# get_1h_dataframe_completed 을 직접 패치
|
||||
fetcher.get_1h_dataframe_completed = lambda: df_1h
|
||||
return fetcher
|
||||
|
||||
def test_wait_when_adx_below_threshold(self, sample_1h_df):
|
||||
"""ADX < 20이면 WAIT 상태."""
|
||||
import pandas_ta as ta
|
||||
|
||||
df = sample_1h_df.copy()
|
||||
# 변동성이 없는 flat 데이터 → ADX가 낮을 가능성 높음
|
||||
df["close"] = 2.0 # 완전 flat
|
||||
df["high"] = 2.001
|
||||
df["low"] = 1.999
|
||||
df["open"] = 2.0
|
||||
|
||||
fetcher = self._make_fetcher_with_df(df)
|
||||
meta = MetaFilter(fetcher)
|
||||
state = meta.get_market_state()
|
||||
assert state == "WAIT", f"Flat 데이터에서 WAIT 아닌 상태: {state}"
|
||||
|
||||
def test_long_allowed_when_uptrend(self):
|
||||
"""EMA50 > EMA200 + ADX > 20이면 LONG_ALLOWED."""
|
||||
np.random.seed(10)
|
||||
n = 250
|
||||
# 강한 상승 추세
|
||||
close = np.linspace(1.0, 3.0, n) + np.random.randn(n) * 0.01
|
||||
high = close + 0.02
|
||||
low = close - 0.02
|
||||
open_ = close - 0.005
|
||||
|
||||
base_ts = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
timestamps = pd.date_range(start=base_ts, periods=n, freq="1h")
|
||||
|
||||
df = pd.DataFrame({
|
||||
"open": open_, "high": high, "low": low,
|
||||
"close": close, "volume": np.ones(n) * 500000,
|
||||
}, index=timestamps)
|
||||
|
||||
fetcher = self._make_fetcher_with_df(df)
|
||||
meta = MetaFilter(fetcher)
|
||||
state = meta.get_market_state()
|
||||
assert state == "LONG_ALLOWED", f"강한 상승 추세에서 LONG_ALLOWED 아닌 상태: {state}"
|
||||
|
||||
def test_short_allowed_when_downtrend(self):
|
||||
"""EMA50 < EMA200 + ADX > 20이면 SHORT_ALLOWED."""
|
||||
np.random.seed(20)
|
||||
n = 250
|
||||
# 강한 하락 추세
|
||||
close = np.linspace(3.0, 1.0, n) + np.random.randn(n) * 0.01
|
||||
high = close + 0.02
|
||||
low = close - 0.02
|
||||
open_ = close + 0.005
|
||||
|
||||
base_ts = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
timestamps = pd.date_range(start=base_ts, periods=n, freq="1h")
|
||||
|
||||
df = pd.DataFrame({
|
||||
"open": open_, "high": high, "low": low,
|
||||
"close": close, "volume": np.ones(n) * 500000,
|
||||
}, index=timestamps)
|
||||
|
||||
fetcher = self._make_fetcher_with_df(df)
|
||||
meta = MetaFilter(fetcher)
|
||||
state = meta.get_market_state()
|
||||
assert state == "SHORT_ALLOWED", f"강한 하락 추세에서 SHORT_ALLOWED 아닌 상태: {state}"
|
||||
|
||||
def test_indicator_caching(self, sample_1h_df):
|
||||
"""동일 캔들에 대해 _calc_indicators가 캐시를 재사용하는지 확인."""
|
||||
fetcher = self._make_fetcher_with_df(sample_1h_df)
|
||||
meta = MetaFilter(fetcher)
|
||||
|
||||
# 첫 호출: 캐시 없음
|
||||
df1 = meta._calc_indicators(sample_1h_df)
|
||||
ts1 = meta._cache_timestamp
|
||||
|
||||
# 두 번째 호출: 동일 DataFrame → 캐시 히트
|
||||
df2 = meta._calc_indicators(sample_1h_df)
|
||||
assert df1 is df2, "동일 데이터에 대해 캐시가 재사용되지 않음"
|
||||
assert meta._cache_timestamp == ts1
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Test 3: TriggerStrategy
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestTriggerStrategy:
|
||||
"""15m 3-candle pullback 시퀀스 감지 테스트."""
|
||||
|
||||
def test_hold_when_meta_wait(self, sample_15m_df):
|
||||
"""meta_state=WAIT이면 항상 HOLD."""
|
||||
trigger = TriggerStrategy()
|
||||
signal = trigger.generate_signal(sample_15m_df, "WAIT")
|
||||
assert signal == "HOLD"
|
||||
|
||||
def test_hold_when_insufficient_data(self):
|
||||
"""데이터가 25개 미만이면 HOLD."""
|
||||
trigger = TriggerStrategy()
|
||||
small_df = pd.DataFrame({
|
||||
"open": [1.0] * 10,
|
||||
"high": [1.1] * 10,
|
||||
"low": [0.9] * 10,
|
||||
"close": [1.0] * 10,
|
||||
"volume": [100.0] * 10,
|
||||
})
|
||||
signal = trigger.generate_signal(small_df, "LONG_ALLOWED")
|
||||
assert signal == "HOLD"
|
||||
|
||||
def test_long_pullback_signal(self):
|
||||
"""LONG 풀백 시퀀스: t-1 EMA 아래 이탈 + 거래량 고갈 + t EMA 복귀."""
|
||||
np.random.seed(42)
|
||||
n = 30
|
||||
# 기본 상승 추세
|
||||
close = np.linspace(1.0, 1.1, n)
|
||||
high = close + 0.005
|
||||
low = close - 0.005
|
||||
open_ = close - 0.001
|
||||
volume = np.ones(n) * 100000
|
||||
|
||||
# t-1 (인덱스 -2): EMA 아래로 이탈 + 거래량 고갈
|
||||
close[-2] = close[-3] - 0.02 # EMA 아래로 이탈
|
||||
volume[-2] = 5000 # 매우 낮은 거래량
|
||||
|
||||
# t (인덱스 -1): EMA 위로 복귀
|
||||
close[-1] = close[-3] + 0.01
|
||||
|
||||
base_ts = pd.Timestamp("2026-01-01", tz="UTC")
|
||||
timestamps = pd.date_range(start=base_ts, periods=n, freq="15min")
|
||||
|
||||
df = pd.DataFrame({
|
||||
"open": open_, "high": high, "low": low,
|
||||
"close": close, "volume": volume,
|
||||
}, index=timestamps)
|
||||
|
||||
trigger = TriggerStrategy()
|
||||
signal = trigger.generate_signal(df, "LONG_ALLOWED")
|
||||
# 풀백 조건 충족 여부는 EMA 계산 결과에 따라 다를 수 있으므로
|
||||
# 최소한 valid signal을 반환하는지 확인
|
||||
assert signal in ("EXECUTE_LONG", "HOLD")
|
||||
|
||||
def test_short_pullback_signal(self):
|
||||
"""SHORT 풀백 시퀀스: t-1 EMA 위로 이탈 + 거래량 고갈 + t EMA 아래 복귀."""
|
||||
np.random.seed(42)
|
||||
n = 30
|
||||
# 하락 추세
|
||||
close = np.linspace(1.1, 1.0, n)
|
||||
high = close + 0.005
|
||||
low = close - 0.005
|
||||
open_ = close + 0.001
|
||||
volume = np.ones(n) * 100000
|
||||
|
||||
# t-1: EMA 위로 이탈 + 거래량 고갈
|
||||
close[-2] = close[-3] + 0.02
|
||||
volume[-2] = 5000
|
||||
|
||||
# t: EMA 아래로 복귀
|
||||
close[-1] = close[-3] - 0.01
|
||||
|
||||
base_ts = pd.Timestamp("2026-01-01", tz="UTC")
|
||||
timestamps = pd.date_range(start=base_ts, periods=n, freq="15min")
|
||||
|
||||
df = pd.DataFrame({
|
||||
"open": open_, "high": high, "low": low,
|
||||
"close": close, "volume": volume,
|
||||
}, index=timestamps)
|
||||
|
||||
trigger = TriggerStrategy()
|
||||
signal = trigger.generate_signal(df, "SHORT_ALLOWED")
|
||||
assert signal in ("EXECUTE_SHORT", "HOLD")
|
||||
|
||||
def test_trigger_info_populated(self, sample_15m_df):
|
||||
"""generate_signal 후 get_trigger_info가 비어있지 않아야 한다."""
|
||||
trigger = TriggerStrategy()
|
||||
trigger.generate_signal(sample_15m_df, "LONG_ALLOWED")
|
||||
info = trigger.get_trigger_info()
|
||||
assert "signal" in info or "reason" in info
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Test 4: ExecutionManager (SL/TP 계산)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestExecutionManager:
|
||||
"""ExecutionManager SL/TP 계산 및 포지션 관리 테스트."""
|
||||
|
||||
def test_long_sl_tp_calculation(self):
|
||||
"""LONG 진입 시 SL = entry - ATR*1.5, TP = entry + ATR*2.3."""
|
||||
em = ExecutionManager(symbol="XRPUSDT")
|
||||
entry = 2.0
|
||||
atr = 0.01
|
||||
|
||||
result = em.execute("EXECUTE_LONG", entry, atr)
|
||||
assert result is not None
|
||||
assert result["action"] == "LONG"
|
||||
|
||||
expected_sl = entry - (atr * 1.5)
|
||||
expected_tp = entry + (atr * 2.3)
|
||||
assert abs(result["sl_price"] - expected_sl) < 1e-8, f"SL: {result['sl_price']} != {expected_sl}"
|
||||
assert abs(result["tp_price"] - expected_tp) < 1e-8, f"TP: {result['tp_price']} != {expected_tp}"
|
||||
|
||||
def test_short_sl_tp_calculation(self):
|
||||
"""SHORT 진입 시 SL = entry + ATR*1.5, TP = entry - ATR*2.3."""
|
||||
em = ExecutionManager(symbol="XRPUSDT")
|
||||
entry = 2.0
|
||||
atr = 0.01
|
||||
|
||||
result = em.execute("EXECUTE_SHORT", entry, atr)
|
||||
assert result is not None
|
||||
assert result["action"] == "SHORT"
|
||||
|
||||
expected_sl = entry + (atr * 1.5)
|
||||
expected_tp = entry - (atr * 2.3)
|
||||
assert abs(result["sl_price"] - expected_sl) < 1e-8
|
||||
assert abs(result["tp_price"] - expected_tp) < 1e-8
|
||||
|
||||
def test_hold_returns_none(self):
|
||||
"""HOLD 신호는 None 반환."""
|
||||
em = ExecutionManager(symbol="XRPUSDT")
|
||||
result = em.execute("HOLD", 2.0, 0.01)
|
||||
assert result is None
|
||||
|
||||
def test_duplicate_position_blocked(self):
|
||||
"""이미 포지션이 있으면 중복 진입 차단."""
|
||||
em = ExecutionManager(symbol="XRPUSDT")
|
||||
em.execute("EXECUTE_LONG", 2.0, 0.01)
|
||||
|
||||
result = em.execute("EXECUTE_SHORT", 2.1, 0.01)
|
||||
assert result is None, "포지션 중복 차단 실패"
|
||||
|
||||
def test_reentry_after_close(self):
|
||||
"""청산 후 재진입 가능."""
|
||||
em = ExecutionManager(symbol="XRPUSDT")
|
||||
em.execute("EXECUTE_LONG", 2.0, 0.01)
|
||||
em.close_position("test", exit_price=2.01, pnl_bps=50)
|
||||
|
||||
result = em.execute("EXECUTE_SHORT", 2.05, 0.01)
|
||||
assert result is not None, "청산 후 재진입 실패"
|
||||
assert result["action"] == "SHORT"
|
||||
|
||||
def test_invalid_atr_blocked(self):
|
||||
"""ATR이 None/0/NaN이면 주문 차단."""
|
||||
em = ExecutionManager(symbol="XRPUSDT")
|
||||
|
||||
assert em.execute("EXECUTE_LONG", 2.0, None) is None
|
||||
assert em.execute("EXECUTE_LONG", 2.0, 0) is None
|
||||
assert em.execute("EXECUTE_LONG", 2.0, float("nan")) is None
|
||||
|
||||
def test_risk_reward_ratio(self):
|
||||
"""R:R 비율이 올바르게 계산되는지 확인."""
|
||||
em = ExecutionManager(symbol="XRPUSDT")
|
||||
result = em.execute("EXECUTE_LONG", 2.0, 0.01)
|
||||
# TP/SL = 2.3/1.5 = 1.533...
|
||||
expected_rr = round(2.3 / 1.5, 2)
|
||||
assert result["risk_reward"] == expected_rr
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import pytest
|
||||
import os
|
||||
from src.risk_manager import RiskManager
|
||||
@@ -15,18 +16,20 @@ def config():
|
||||
return Config()
|
||||
|
||||
|
||||
def test_max_drawdown_check(config):
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_drawdown_check(config):
|
||||
rm = RiskManager(config, max_daily_loss_pct=0.05)
|
||||
rm.daily_pnl = -60.0
|
||||
rm.initial_balance = 1000.0
|
||||
assert rm.is_trading_allowed() is False
|
||||
assert await rm.is_trading_allowed() is False
|
||||
|
||||
|
||||
def test_trading_allowed_normal(config):
|
||||
@pytest.mark.asyncio
|
||||
async def test_trading_allowed_normal(config):
|
||||
rm = RiskManager(config, max_daily_loss_pct=0.05)
|
||||
rm.daily_pnl = -10.0
|
||||
rm.initial_balance = 1000.0
|
||||
assert rm.is_trading_allowed() is True
|
||||
assert await rm.is_trading_allowed() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -135,3 +138,31 @@ async def test_max_positions_global_limit(shared_risk):
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_daily_with_lock(shared_risk):
|
||||
"""reset_daily가 lock 하에서 PnL을 초기화한다."""
|
||||
await shared_risk.close_position("DUMMY", 5.0) # dummy 기록
|
||||
shared_risk.open_positions.clear() # clean up
|
||||
assert shared_risk.daily_pnl == 5.0
|
||||
await shared_risk.reset_daily()
|
||||
assert shared_risk.daily_pnl == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entry_lock_serializes_access(shared_risk):
|
||||
"""_entry_lock이 동시 접근을 직렬화하는지 확인."""
|
||||
order = []
|
||||
|
||||
async def simulated_entry(name: str):
|
||||
async with shared_risk._entry_lock:
|
||||
order.append(f"{name}_start")
|
||||
await asyncio.sleep(0.05)
|
||||
order.append(f"{name}_end")
|
||||
|
||||
await asyncio.gather(simulated_entry("A"), simulated_entry("B"))
|
||||
# 직렬화 확인: A_start, A_end, B_start, B_end 또는 B_start, B_end, A_start, A_end
|
||||
assert order[0].endswith("_start")
|
||||
assert order[1].endswith("_end")
|
||||
assert order[0][0] == order[1][0] # 같은 이름으로 시작/끝
|
||||
|
||||
@@ -48,33 +48,56 @@ def test_run_backtest_returns_summary():
|
||||
assert result["summary"]["total_trades"] == 27
|
||||
|
||||
|
||||
def test_parse_live_trades_extracts_entries(tmp_path):
|
||||
"""봇 로그에서 진입/청산 패턴을 파싱하여 트레이드 리스트를 반환."""
|
||||
from scripts.weekly_report import parse_live_trades
|
||||
def test_fetch_live_trades_from_api():
|
||||
"""대시보드 API에서 청산 트레이드를 가져오는지 확인."""
|
||||
from scripts.weekly_report import fetch_live_trades
|
||||
|
||||
log_content = """2026-03-01 10:00:00.000 | INFO | src.bot:process_candle:42 - [XRPUSDT] LONG 진입: 가격=2.5000, 수량=100.0, SL=2.4000, TP=2.7000
|
||||
2026-03-01 10:15:00.000 | INFO | src.bot:process_candle:42 - [XRPUSDT] 신호: HOLD | 현재가: 2.5500 USDT
|
||||
2026-03-01 12:00:00.000 | INFO | src.user_data_stream:_handle_order:80 - [XRPUSDT] 청산 감지(TAKE_PROFIT): exit=2.7000, rp=20.0000, commission=0.2160, net_pnl=19.5680
|
||||
"""
|
||||
log_file = tmp_path / "bot.log"
|
||||
log_file.write_text(log_content)
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"trades": [
|
||||
{"symbol": "XRPUSDT", "direction": "LONG", "net_pnl": 19.568,
|
||||
"commission": 0.216, "status": "CLOSED"},
|
||||
],
|
||||
"total": 1,
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("scripts.weekly_report.httpx.get", return_value=mock_response):
|
||||
trades = fetch_live_trades("http://test:8000")
|
||||
|
||||
trades = parse_live_trades(str(log_file), days=7)
|
||||
assert len(trades) == 1
|
||||
assert trades[0]["symbol"] == "XRPUSDT"
|
||||
assert trades[0]["side"] == "LONG"
|
||||
assert trades[0]["net_pnl"] == pytest.approx(19.568)
|
||||
assert trades[0]["close_reason"] == "TAKE_PROFIT"
|
||||
|
||||
|
||||
def test_parse_live_trades_empty_log(tmp_path):
|
||||
"""로그 파일이 없으면 빈 리스트 반환."""
|
||||
from scripts.weekly_report import parse_live_trades
|
||||
def test_fetch_live_trades_api_failure():
|
||||
"""API 실패 시 빈 리스트 반환."""
|
||||
from scripts.weekly_report import fetch_live_trades
|
||||
|
||||
with patch("scripts.weekly_report.httpx.get", side_effect=Exception("connection refused")):
|
||||
trades = fetch_live_trades("http://unreachable:8000")
|
||||
|
||||
trades = parse_live_trades(str(tmp_path / "nonexistent.log"), days=7)
|
||||
assert trades == []
|
||||
|
||||
|
||||
def test_fetch_live_stats_from_api():
|
||||
"""대시보드 API에서 전체 통계를 가져오는지 확인."""
|
||||
from scripts.weekly_report import fetch_live_stats
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"total_trades": 15, "wins": 9, "losses": 6,
|
||||
"total_pnl": 42.5, "total_fees": 3.2,
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("scripts.weekly_report.httpx.get", return_value=mock_response):
|
||||
stats = fetch_live_stats("http://test:8000")
|
||||
|
||||
assert stats["total_trades"] == 15
|
||||
assert stats["wins"] == 9
|
||||
|
||||
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
|
||||
@@ -239,33 +262,40 @@ def test_generate_report_orchestration(tmp_path):
|
||||
from scripts.weekly_report import generate_report
|
||||
from unittest.mock import patch
|
||||
|
||||
# 합산 지표는 개별 트레이드에서 직접 계산되므로 mock에 트레이드 포함
|
||||
mock_trades = [
|
||||
{"net_pnl": 20.0, "entry_fee": 1.0, "exit_fee": 1.0, "exit_time": "2025-06-10 12:00:00"},
|
||||
{"net_pnl": 15.0, "entry_fee": 1.0, "exit_fee": 1.0, "exit_time": "2025-06-11 12:00:00"},
|
||||
{"net_pnl": -10.0, "entry_fee": 1.0, "exit_fee": 1.0, "exit_time": "2025-06-12 12:00:00"},
|
||||
]
|
||||
mock_bt_result = {
|
||||
"summary": {
|
||||
"profit_factor": 1.24, "win_rate": 45.0,
|
||||
"max_drawdown_pct": 12.0, "total_trades": 88,
|
||||
"total_pnl": 379.0, "return_pct": 37.9,
|
||||
"avg_win": 20.0, "avg_loss": -10.0,
|
||||
"sharpe_ratio": 33.0, "total_fees": 5.0,
|
||||
"max_drawdown_pct": 12.0, "total_trades": 3,
|
||||
"total_pnl": 25.0, "return_pct": 2.5,
|
||||
"avg_win": 17.5, "avg_loss": -10.0,
|
||||
"sharpe_ratio": 33.0, "total_fees": 6.0,
|
||||
"close_reasons": {},
|
||||
},
|
||||
"folds": [], "trades": [],
|
||||
"folds": [], "trades": mock_trades,
|
||||
}
|
||||
|
||||
with patch("scripts.weekly_report.run_backtest", return_value=mock_bt_result):
|
||||
with patch("scripts.weekly_report.parse_live_trades", return_value=[]):
|
||||
with patch("scripts.weekly_report.load_trend", return_value={
|
||||
"pf": [1.31], "win_rate": [48.0], "mdd": [9.0], "pf_declining_3w": False,
|
||||
}):
|
||||
report = generate_report(
|
||||
symbols=["XRPUSDT"],
|
||||
report_dir=str(tmp_path),
|
||||
log_path=str(tmp_path / "bot.log"),
|
||||
report_date=date(2026, 3, 7),
|
||||
)
|
||||
with patch("scripts.weekly_report.fetch_live_stats", return_value={"total_trades": 0, "wins": 0, "total_pnl": 0}):
|
||||
with patch("scripts.weekly_report.fetch_live_trades", return_value=[]):
|
||||
with patch("scripts.weekly_report.load_trend", return_value={
|
||||
"pf": [1.31], "win_rate": [48.0], "mdd": [9.0], "pf_declining_3w": False,
|
||||
}):
|
||||
report = generate_report(
|
||||
symbols=["XRPUSDT"],
|
||||
report_dir=str(tmp_path),
|
||||
report_date=date(2026, 3, 7),
|
||||
)
|
||||
|
||||
assert report["date"] == "2026-03-07"
|
||||
# PF는 avg_win/avg_loss에서 재계산됨 (GP=40*20=800, GL=48*10=480 → 1.67)
|
||||
assert report["backtest"]["summary"]["profit_factor"] == 1.67
|
||||
# PF는 개별 트레이드에서 직접 계산: GP=35, GL=10 → 3.5
|
||||
assert report["backtest"]["summary"]["profit_factor"] == 3.5
|
||||
assert report["backtest"]["summary"]["total_trades"] == 3
|
||||
assert report["sweep"] is None # PF >= 1.0이면 스윕 안 함
|
||||
|
||||
|
||||
@@ -280,3 +310,32 @@ def test_save_report_creates_json(tmp_path):
|
||||
assert len(saved) == 1
|
||||
loaded = json.loads(saved[0].read_text())
|
||||
assert loaded["date"] == "2026-03-07"
|
||||
|
||||
|
||||
def test_generate_quantstats_report_creates_html(tmp_path):
|
||||
"""트레이드 데이터로 quantstats HTML 리포트를 생성."""
|
||||
from scripts.weekly_report import generate_quantstats_report
|
||||
|
||||
trades = [
|
||||
{"exit_time": "2026-03-01 12:00:00", "net_pnl": 5.0},
|
||||
{"exit_time": "2026-03-02 15:00:00", "net_pnl": -2.0},
|
||||
{"exit_time": "2026-03-03 09:00:00", "net_pnl": 8.0},
|
||||
{"exit_time": "2026-03-04 18:00:00", "net_pnl": -1.5},
|
||||
{"exit_time": "2026-03-05 10:00:00", "net_pnl": 3.0},
|
||||
]
|
||||
output = str(tmp_path / "test_report.html")
|
||||
result = generate_quantstats_report(trades, output)
|
||||
|
||||
assert result is not None
|
||||
assert Path(result).exists()
|
||||
content = Path(result).read_text()
|
||||
assert "CoinTrader" in content
|
||||
|
||||
|
||||
def test_generate_quantstats_report_empty_trades(tmp_path):
|
||||
"""트레이드가 없으면 None 반환."""
|
||||
from scripts.weekly_report import generate_quantstats_report
|
||||
|
||||
output = str(tmp_path / "empty.html")
|
||||
result = generate_quantstats_report([], output)
|
||||
assert result is None
|
||||
|
||||
Reference in New Issue
Block a user