feat: implement 15-minute timeframe upgrade for model training and data processing

- Introduced a new markdown document detailing the plan to transition the entire pipeline from a 1-minute to a 15-minute timeframe, aiming to improve model AUC from 0.49-0.50 to over 0.53.
- Updated key parameters across multiple scripts, including `LOOKAHEAD` adjustments and default data paths to reflect the new 15-minute interval.
- Modified data fetching and training scripts to ensure compatibility with the new timeframe, including changes in `fetch_history.py`, `train_model.py`, and `train_and_deploy.sh`.
- Enhanced the bot's data stream configuration to operate on a 15-minute interval, ensuring real-time data processing aligns with the new model training strategy.
- Updated training logs to capture new model performance metrics under the revised timeframe.
This commit is contained in:
21in7
2026-03-01 22:16:15 +09:00
parent a6697e7cca
commit 4245d7cdbf
13 changed files with 435 additions and 24 deletions

View File

@@ -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
```

Binary file not shown.

Binary file not shown.

BIN
models/mlx_filter.onnx Normal file

Binary file not shown.

View File

@@ -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"
}
]

View File

@@ -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로 통합

View File

@@ -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 ""

View File

@@ -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배 높음)",

View File

@@ -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배 높음)",

View File

@@ -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,
)

View File

@@ -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}개 로드 중...")

View File

@@ -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:

View File

@@ -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}"