diff --git a/docs/plans/2026-03-01-15m-timeframe-upgrade.md b/docs/plans/2026-03-01-15m-timeframe-upgrade.md new file mode 100644 index 0000000..4075f75 --- /dev/null +++ b/docs/plans/2026-03-01-15m-timeframe-upgrade.md @@ -0,0 +1,376 @@ +# 15분봉 타임프레임 업그레이드 구현 계획 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 1분봉 파이프라인 전체를 15분봉으로 전환하고, LOOKAHEAD=24(6시간 뷰)로 조정해 모델 AUC를 0.49~0.50 구간에서 0.53+ 이상으로 개선한다. + +**Architecture:** 데이터 수집(fetch_history.py) → 데이터셋 빌더(dataset_builder.py) → 학습 스크립트(train_model.py, train_mlx_model.py) → 실시간 봇(bot.py, data_stream.py) 순서로 파라미터를 변경한다. 각 레이어는 `interval` 문자열과 `LOOKAHEAD` 상수만 수정하면 되며 피처 구조는 그대로 유지한다. + +**Tech Stack:** Python, LightGBM, pandas, binance-python-client, pytest + +--- + +## 변경 요약 + +| 파일 | 변경 내용 | +|------|-----------| +| `src/dataset_builder.py` | `LOOKAHEAD 90→24`, `WARMUP 60→60` (유지) | +| `scripts/train_model.py` | `LOOKAHEAD 60→24`, `--data` 기본값 `combined_1m→combined_15m` | +| `scripts/train_mlx_model.py` | `--data` 기본값 `combined_1m→combined_15m` | +| `scripts/fetch_history.py` | `--interval` 기본값 `1m→15m`, `--output` 기본값 반영 | +| `scripts/train_and_deploy.sh` | `--interval 1m→15m`, 파일명 `1m→15m` | +| `src/bot.py` | `interval="1m"→"15m"` | +| `src/data_stream.py` | `buffer_size` 기본값 `200→200` (유지, 15분봉 200개=50시간 충분) | + +--- + +## Task 1: dataset_builder.py — LOOKAHEAD 상수 변경 + +**Files:** +- Modify: `src/dataset_builder.py:14-17` + +**Step 1: 현재 상수 확인** + +```bash +head -20 src/dataset_builder.py +``` + +Expected: `LOOKAHEAD = 90`, `WARMUP = 60` + +**Step 2: 상수 변경** + +`src/dataset_builder.py` 14번째 줄: +```python +# 변경 전 +LOOKAHEAD = 90 +ATR_SL_MULT = 1.5 +ATR_TP_MULT = 2.0 +WARMUP = 60 + +# 변경 후 +LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 뷰 +ATR_SL_MULT = 1.5 +ATR_TP_MULT = 2.0 +WARMUP = 60 # 15분봉 기준 60캔들 = 15시간 (지표 안정화 충분) +``` + +**Step 3: 변경 확인** + +```bash +head -20 src/dataset_builder.py +``` + +Expected: `LOOKAHEAD = 24` + +--- + +## Task 2: train_model.py — LOOKAHEAD 상수 및 기본 데이터 경로 변경 + +**Files:** +- Modify: `scripts/train_model.py:56-61`, `scripts/train_model.py:360` + +**Step 1: 현재 상수 확인** + +```bash +sed -n '55,62p' scripts/train_model.py +sed -n '358,362p' scripts/train_model.py +``` + +Expected: `LOOKAHEAD = 60`, `--data default="data/combined_1m.parquet"` + +**Step 2: LOOKAHEAD 변경** + +`scripts/train_model.py` 56번째 줄: +```python +# 변경 전 +LOOKAHEAD = 60 + +# 변경 후 +LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 (dataset_builder.py와 동기화) +``` + +**Step 3: --data 기본값 변경** + +`scripts/train_model.py` 360번째 줄 근처 `argparse` 부분: +```python +# 변경 전 +parser.add_argument("--data", default="data/combined_1m.parquet") + +# 변경 후 +parser.add_argument("--data", default="data/combined_15m.parquet") +``` + +**Step 4: 변경 확인** + +```bash +grep -n "LOOKAHEAD\|combined_" scripts/train_model.py +``` + +Expected: `LOOKAHEAD = 24`, `combined_15m.parquet` + +--- + +## Task 3: train_mlx_model.py — 기본 데이터 경로 변경 + +**Files:** +- Modify: `scripts/train_mlx_model.py:149` + +**Step 1: 현재 기본값 확인** + +```bash +grep -n "combined_" scripts/train_mlx_model.py +``` + +Expected: `default="data/combined_1m.parquet"` + +**Step 2: 기본값 변경** + +`scripts/train_mlx_model.py` 149번째 줄: +```python +# 변경 전 +parser.add_argument("--data", default="data/combined_1m.parquet") + +# 변경 후 +parser.add_argument("--data", default="data/combined_15m.parquet") +``` + +**Step 3: 변경 확인** + +```bash +grep -n "combined_" scripts/train_mlx_model.py +``` + +Expected: `combined_15m.parquet` + +--- + +## Task 4: fetch_history.py — 기본 interval 및 output 변경 + +**Files:** +- Modify: `scripts/fetch_history.py:114-118` + +**Step 1: 현재 argparse 기본값 확인** + +```bash +sed -n '112,120p' scripts/fetch_history.py +``` + +Expected: `--interval default="1m"`, `--output default="data/xrpusdt_1m.parquet"` + +**Step 2: 기본값 변경** + +```python +# 변경 전 +parser.add_argument("--interval", default="1m") +parser.add_argument("--days", type=int, default=90) +parser.add_argument("--output", default="data/xrpusdt_1m.parquet") + +# 변경 후 +parser.add_argument("--interval", default="15m") +parser.add_argument("--days", type=int, default=365) +parser.add_argument("--output", default="data/xrpusdt_15m.parquet") +``` + +**Step 3: 변경 확인** + +```bash +grep -n "interval\|output\|days" scripts/fetch_history.py | grep "default" +``` + +Expected: `default="15m"`, `default=365`, `default="data/xrpusdt_15m.parquet"` + +--- + +## Task 5: train_and_deploy.sh — interval 및 파일명 변경 + +**Files:** +- Modify: `scripts/train_and_deploy.sh:26-43` + +**Step 1: 현재 스크립트 확인** + +```bash +cat scripts/train_and_deploy.sh +``` + +**Step 2: 스크립트 변경** + +```bash +# 변경 전 (26~32번째 줄) +echo "=== [1/3] 데이터 수집 (XRP + BTC + ETH 3심볼, 1년치) ===" +python scripts/fetch_history.py \ + --symbols XRPUSDT BTCUSDT ETHUSDT \ + --interval 1m \ + --days 365 \ + --output data/xrpusdt_1m.parquet +# 결과: data/combined_1m.parquet (타임스탬프 기준 병합) + +# 변경 후 +echo "=== [1/3] 데이터 수집 (XRP + BTC + ETH 3심볼, 1년치) ===" +python scripts/fetch_history.py \ + --symbols XRPUSDT BTCUSDT ETHUSDT \ + --interval 15m \ + --days 365 \ + --output data/xrpusdt_15m.parquet +# 결과: data/combined_15m.parquet (타임스탬프 기준 병합) +``` + +```bash +# 변경 전 (38~43번째 줄) + python scripts/train_mlx_model.py --data data/combined_1m.parquet --decay "$DECAY" +else + echo " 백엔드: LightGBM (CPU), decay=${DECAY}" + python scripts/train_model.py --data data/combined_1m.parquet --decay "$DECAY" + +# 변경 후 + python scripts/train_mlx_model.py --data data/combined_15m.parquet --decay "$DECAY" +else + echo " 백엔드: LightGBM (CPU), decay=${DECAY}" + python scripts/train_model.py --data data/combined_15m.parquet --decay "$DECAY" +``` + +**Step 3: 변경 확인** + +```bash +grep -n "1m\|15m" scripts/train_and_deploy.sh +``` + +Expected: 모든 `1m` 참조가 `15m`으로 변경됨 + +--- + +## Task 6: bot.py — 실시간 스트림 interval 변경 + +**Files:** +- Modify: `src/bot.py:22-25` + +**Step 1: 현재 interval 확인** + +```bash +grep -n "interval" src/bot.py +``` + +Expected: `interval="1m"` (MultiSymbolStream 생성자) + +**Step 2: interval 변경** + +`src/bot.py` 21~25번째 줄: +```python +# 변경 전 +self.stream = MultiSymbolStream( + symbols=[config.symbol, "BTCUSDT", "ETHUSDT"], + interval="1m", + on_candle=self._on_candle_closed, +) + +# 변경 후 +self.stream = MultiSymbolStream( + symbols=[config.symbol, "BTCUSDT", "ETHUSDT"], + interval="15m", + on_candle=self._on_candle_closed, +) +``` + +**Step 3: 변경 확인** + +```bash +grep -n "interval" src/bot.py +``` + +Expected: `interval="15m"` + +--- + +## Task 7: 전체 변경 검증 + +**Step 1: 모든 `1m` 하드코딩 잔재 확인** + +```bash +grep -rn '"1m"' src/ scripts/ +``` + +Expected: 결과 없음 (모두 `"15m"`으로 변경됨) + +**Step 2: LOOKAHEAD 동기화 확인** + +```bash +grep -rn "LOOKAHEAD" src/ scripts/ +``` + +Expected: +- `src/dataset_builder.py`: `LOOKAHEAD = 24` +- `scripts/train_model.py`: `LOOKAHEAD = 24` + +**Step 3: combined 파일명 일관성 확인** + +```bash +grep -rn "combined_" src/ scripts/ +``` + +Expected: 모두 `combined_15m` 참조 + +**Step 4: 파이프라인 드라이런 (데이터 없이 import 테스트)** + +```bash +python -c " +from src.dataset_builder import LOOKAHEAD, ATR_SL_MULT, ATR_TP_MULT, WARMUP +assert LOOKAHEAD == 24, f'LOOKAHEAD={LOOKAHEAD}' +print(f'OK: LOOKAHEAD={LOOKAHEAD}, ATR_SL={ATR_SL_MULT}, ATR_TP={ATR_TP_MULT}, WARMUP={WARMUP}') +" +``` + +Expected: `OK: LOOKAHEAD=24, ATR_SL=1.5, ATR_TP=2.0, WARMUP=60` + +--- + +## Task 8: 데이터 수집 및 Walk-Forward 검증 실행 + +> 이 태스크는 실제 바이낸스 API 키와 네트워크가 필요합니다. + +**Step 1: 15분봉 데이터 수집** + +```bash +python scripts/fetch_history.py \ + --symbols XRPUSDT BTCUSDT ETHUSDT \ + --interval 15m \ + --days 365 \ + --output data/xrpusdt_15m.parquet +``` + +Expected: `data/combined_15m.parquet` 생성, 약 35,040행 (365일 × 96캔들/일) + +**Step 2: Walk-Forward AUC 측정 (기준선 확인)** + +```bash +python scripts/train_model.py \ + --data data/combined_15m.parquet \ + --wf \ + --wf-splits 5 +``` + +Expected: Walk-Forward 평균 AUC가 0.53 이상이면 개선 확인 + +**Step 3: 정식 학습 및 모델 저장** + +```bash +python scripts/train_model.py \ + --data data/combined_15m.parquet \ + --decay 2.0 +``` + +Expected: `models/lgbm_filter.pkl` 저장, 기존 모델은 `lgbm_filter_prev.pkl`로 백업 + +--- + +## 롤백 방법 + +15분봉 모델이 기대에 미치지 못할 경우: + +```bash +# 기존 1분봉 모델 복원 +cp models/lgbm_filter_prev.pkl models/lgbm_filter.pkl + +# 코드는 git으로 복원 +git checkout src/dataset_builder.py scripts/train_model.py \ + scripts/train_mlx_model.py scripts/fetch_history.py \ + scripts/train_and_deploy.sh src/bot.py +``` diff --git a/models/mlx_filter.meta.npz b/models/mlx_filter.meta.npz index 0c02e5e..a8dbf26 100644 Binary files a/models/mlx_filter.meta.npz and b/models/mlx_filter.meta.npz differ diff --git a/models/mlx_filter.npz b/models/mlx_filter.npz index 73097f0..760b255 100644 Binary files a/models/mlx_filter.npz and b/models/mlx_filter.npz differ diff --git a/models/mlx_filter.onnx b/models/mlx_filter.onnx new file mode 100644 index 0000000..2b9c4fa Binary files /dev/null and b/models/mlx_filter.onnx differ diff --git a/models/training_log.json b/models/training_log.json index 6fd7291..99a3c06 100644 --- a/models/training_log.json +++ b/models/training_log.json @@ -135,5 +135,32 @@ "features": 21, "time_weight_decay": 3.0, "model_path": "models/lgbm_filter.pkl" + }, + { + "date": "2026-03-01T22:12:06.299119", + "backend": "mlx", + "auc": 0.5746, + "samples": 533, + "train_sec": 0.2, + "time_weight_decay": 2.0, + "model_path": "models/mlx_filter.weights" + }, + { + "date": "2026-03-01T22:13:20.434893", + "backend": "mlx", + "auc": 0.5663, + "samples": 533, + "train_sec": 0.2, + "time_weight_decay": 2.0, + "model_path": "models/mlx_filter.weights" + }, + { + "date": "2026-03-01T22:15:43.163315", + "backend": "lgbm", + "auc": 0.5581, + "samples": 533, + "features": 21, + "time_weight_decay": 2.0, + "model_path": "models/lgbm_filter.pkl" } ] \ No newline at end of file diff --git a/scripts/fetch_history.py b/scripts/fetch_history.py index a5355d4..9f33d5b 100644 --- a/scripts/fetch_history.py +++ b/scripts/fetch_history.py @@ -113,9 +113,9 @@ def main(): ) parser.add_argument("--symbols", nargs="+", default=["XRPUSDT"]) parser.add_argument("--symbol", default=None, help="단일 심볼 (--symbols 미사용 시)") - parser.add_argument("--interval", default="1m") - parser.add_argument("--days", type=int, default=90) - parser.add_argument("--output", default="data/xrpusdt_1m.parquet") + parser.add_argument("--interval", default="15m") + parser.add_argument("--days", type=int, default=365) + parser.add_argument("--output", default="data/combined_15m.parquet") args = parser.parse_args() # 하위 호환: --symbol 단독 사용 시 symbols로 통합 diff --git a/scripts/train_and_deploy.sh b/scripts/train_and_deploy.sh index f84260b..13420a6 100755 --- a/scripts/train_and_deploy.sh +++ b/scripts/train_and_deploy.sh @@ -26,20 +26,19 @@ cd "$PROJECT_ROOT" echo "=== [1/3] 데이터 수집 (XRP + BTC + ETH 3심볼, 1년치) ===" python scripts/fetch_history.py \ --symbols XRPUSDT BTCUSDT ETHUSDT \ - --interval 1m \ + --interval 15m \ --days 365 \ - --output data/xrpusdt_1m.parquet -# 결과: data/combined_1m.parquet (타임스탬프 기준 병합) + --output data/combined_15m.parquet echo "" echo "=== [2/3] 모델 학습 (21개 피처: XRP 13 + BTC/ETH 상관관계 8) ===" DECAY="${TIME_WEIGHT_DECAY:-2.0}" if [ "$BACKEND" = "mlx" ]; then echo " 백엔드: MLX (Apple Silicon GPU), decay=${DECAY}" - python scripts/train_mlx_model.py --data data/combined_1m.parquet --decay "$DECAY" + python scripts/train_mlx_model.py --data data/combined_15m.parquet --decay "$DECAY" else echo " 백엔드: LightGBM (CPU), decay=${DECAY}" - python scripts/train_model.py --data data/combined_1m.parquet --decay "$DECAY" + python scripts/train_model.py --data data/combined_15m.parquet --decay "$DECAY" fi echo "" diff --git a/scripts/train_mlx_model.py b/scripts/train_mlx_model.py index a6ca1f0..9efa2a2 100644 --- a/scripts/train_mlx_model.py +++ b/scripts/train_mlx_model.py @@ -146,7 +146,7 @@ def train_mlx(data_path: str, time_weight_decay: float = 2.0) -> float: def main(): parser = argparse.ArgumentParser() - parser.add_argument("--data", default="data/combined_1m.parquet") + parser.add_argument("--data", default="data/combined_15m.parquet") parser.add_argument( "--decay", type=float, default=2.0, help="시간 가중치 감쇠 강도 (0=균등, 2.0=최신이 ~7.4배 높음)", diff --git a/scripts/train_model.py b/scripts/train_model.py index f2e4fa3..5850a5b 100644 --- a/scripts/train_model.py +++ b/scripts/train_model.py @@ -53,7 +53,7 @@ def _cgroup_cpu_count() -> int: return cpu_count() -LOOKAHEAD = 60 +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") @@ -357,7 +357,7 @@ def walk_forward_auc( def main(): parser = argparse.ArgumentParser() - parser.add_argument("--data", default="data/combined_1m.parquet") + parser.add_argument("--data", default="data/combined_15m.parquet") parser.add_argument( "--decay", type=float, default=2.0, help="시간 가중치 감쇠 강도 (0=균등, 2.0=최신이 ~7.4배 높음)", diff --git a/src/bot.py b/src/bot.py index 02f7bee..4dfeea8 100644 --- a/src/bot.py +++ b/src/bot.py @@ -20,7 +20,7 @@ class TradingBot: self.current_trade_side: str | None = None # "LONG" | "SHORT" self.stream = MultiSymbolStream( symbols=[config.symbol, "BTCUSDT", "ETHUSDT"], - interval="1m", + interval="15m", on_candle=self._on_candle_closed, ) diff --git a/src/data_stream.py b/src/data_stream.py index 5d1f0ef..7fe9065 100644 --- a/src/data_stream.py +++ b/src/data_stream.py @@ -5,13 +5,21 @@ import pandas as pd from binance import AsyncClient, BinanceSocketManager from loguru import logger +# 15분봉 기준 EMA50 안정화에 필요한 최소 캔들 수. +# EMA50=50, StochRSI(14,14,3,3)=44, MACD(12,26,9)=33 중 최댓값에 여유분 추가. +_MIN_CANDLES_FOR_SIGNAL = 100 + +# 초기 구동 시 REST API로 가져올 과거 캔들 수. +# 15분봉 200개 = 50시간치 — EMA50(12.5h) 대비 4배 여유. +_PRELOAD_LIMIT = 200 + class KlineStream: def __init__( self, symbol: str, - interval: str = "1m", + interval: str = "15m", buffer_size: int = 200, on_candle: Callable = None, ): @@ -40,13 +48,13 @@ class KlineStream: self.on_candle(candle) def get_dataframe(self) -> pd.DataFrame | None: - if len(self.buffer) < 50: + if len(self.buffer) < _MIN_CANDLES_FOR_SIGNAL: return None df = pd.DataFrame(list(self.buffer)) df.set_index("timestamp", inplace=True) return df - async def _preload_history(self, client: AsyncClient, limit: int = 200): + async def _preload_history(self, client: AsyncClient, limit: int = _PRELOAD_LIMIT): """REST API로 과거 캔들 데이터를 버퍼에 미리 채운다.""" logger.info(f"과거 캔들 {limit}개 로드 중...") klines = await client.futures_klines( @@ -96,7 +104,7 @@ class MultiSymbolStream: def __init__( self, symbols: list[str], - interval: str = "1m", + interval: str = "15m", buffer_size: int = 200, on_candle: Callable = None, ): @@ -142,13 +150,13 @@ class MultiSymbolStream: def get_dataframe(self, symbol: str) -> pd.DataFrame | None: key = symbol.lower() buf = self.buffers.get(key) - if buf is None or len(buf) < 50: + if buf is None or len(buf) < _MIN_CANDLES_FOR_SIGNAL: return None df = pd.DataFrame(list(buf)) df.set_index("timestamp", inplace=True) return df - async def _preload_history(self, client: AsyncClient, limit: int = 200): + async def _preload_history(self, client: AsyncClient, limit: int = _PRELOAD_LIMIT): """REST API로 모든 심볼의 과거 캔들을 버퍼에 미리 채운다.""" for symbol in self.symbols: logger.info(f"{symbol.upper()} 과거 캔들 {limit}개 로드 중...") diff --git a/src/dataset_builder.py b/src/dataset_builder.py index fb451d4..9605a29 100644 --- a/src/dataset_builder.py +++ b/src/dataset_builder.py @@ -11,10 +11,10 @@ import pandas_ta as ta from src.ml_features import FEATURE_COLS -LOOKAHEAD = 90 +LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 뷰 ATR_SL_MULT = 1.5 ATR_TP_MULT = 2.0 -WARMUP = 60 # 지표 안정화에 필요한 최소 행 수 +WARMUP = 60 # 15분봉 기준 60캔들 = 15시간 (지표 안정화 충분) def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame: diff --git a/src/ml_filter.py b/src/ml_filter.py index 97e9034..9b69c52 100644 --- a/src/ml_filter.py +++ b/src/ml_filter.py @@ -44,6 +44,11 @@ class MLFilter: self._try_load() def _try_load(self): + # 로드 여부와 무관하게 두 파일의 현재 mtime을 항상 기록한다. + # 이렇게 해야 로드하지 않은 쪽 파일이 나중에 변경됐을 때만 리로드가 트리거된다. + self._loaded_onnx_mtime = _mtime(self._onnx_path) + self._loaded_lgbm_mtime = _mtime(self._lgbm_path) + # ONNX 우선 시도 if self._onnx_path.exists(): try: @@ -53,8 +58,6 @@ class MLFilter: providers=["CPUExecutionProvider"], ) self._lgbm_model = None - self._loaded_onnx_mtime = _mtime(self._onnx_path) - self._loaded_lgbm_mtime = 0.0 logger.info( f"ML 필터 로드: ONNX ({self._onnx_path}) " f"| 임계값={self._threshold}" @@ -68,8 +71,6 @@ class MLFilter: if self._lgbm_path.exists(): try: self._lgbm_model = joblib.load(self._lgbm_path) - self._loaded_lgbm_mtime = _mtime(self._lgbm_path) - self._loaded_onnx_mtime = 0.0 logger.info( f"ML 필터 로드: LightGBM ({self._lgbm_path}) " f"| 임계값={self._threshold}"