From de933b97cc202794bc81ae0ebb2e267fc67c7f74 Mon Sep 17 00:00:00 2001 From: 21in7 Date: Sun, 1 Mar 2026 18:54:00 +0900 Subject: [PATCH] feat: remove in-container retraining, training is now mac-only Made-with: Cursor --- README.md | 16 +- .../2026-03-01-vectorized-dataset-builder.md | 647 ++++++++++++++++++ models/mlx_filter.meta.npz | Bin 0 -> 1122 bytes models/mlx_filter.npz | Bin 0 -> 41668 bytes models/training_log.json | 14 + requirements.txt | 1 + scripts/profile_training.py | 53 ++ scripts/train_and_deploy.sh | 10 +- src/bot.py | 3 - src/mlx_filter.py | 130 ++++ src/retrainer.py | 92 --- tests/test_mlx_filter.py | 86 +++ tests/test_retrainer.py | 35 - 13 files changed, 955 insertions(+), 132 deletions(-) create mode 100644 docs/plans/2026-03-01-vectorized-dataset-builder.md create mode 100644 models/mlx_filter.meta.npz create mode 100644 models/mlx_filter.npz create mode 100644 scripts/profile_training.py create mode 100644 src/mlx_filter.py delete mode 100644 src/retrainer.py create mode 100644 tests/test_mlx_filter.py delete mode 100644 tests/test_retrainer.py diff --git a/README.md b/README.md index 1ca59af..0acf265 100644 --- a/README.md +++ b/README.md @@ -98,12 +98,26 @@ docker compose logs -f cointrader # 1. 과거 데이터 수집 python scripts/fetch_history.py -# 2. 모델 학습 +# 2. 모델 학습 (LightGBM, CPU) python scripts/train_model.py ``` 학습된 모델은 `models/lgbm_filter.pkl`에 저장되며, 봇이 실행 중이면 매일 새벽 3시에 자동으로 재학습·리로드됩니다. +### Apple Silicon GPU 가속 학습 (M1/M2/M3/M4) + +M 시리즈 맥에서는 MLX를 사용해 통합 GPU(Metal)로 학습할 수 있습니다. + +```bash +# MLX 신경망 필터 학습 (GPU 자동 사용) +python scripts/train_mlx_model.py + +# train_and_deploy.sh에서 MLX 백엔드 사용 +TRAIN_BACKEND=mlx bash scripts/train_and_deploy.sh +``` + +> **참고**: LightGBM은 Apple Silicon GPU를 공식 지원하지 않습니다. MLX는 Apple이 만든 ML 프레임워크로 통합 GPU를 자동으로 활용합니다. Neural Engine(NPU)은 Apple 내부 전용으로 Python에서 직접 제어할 수 없습니다. + --- ## 매매 전략 diff --git a/docs/plans/2026-03-01-vectorized-dataset-builder.md b/docs/plans/2026-03-01-vectorized-dataset-builder.md new file mode 100644 index 0000000..4c3845f --- /dev/null +++ b/docs/plans/2026-03-01-vectorized-dataset-builder.md @@ -0,0 +1,647 @@ +# 벡터화 데이터셋 빌더 + 컨테이너 재학습 제거 구현 계획 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 맥미니에서 전체 시계열을 1회 계산하는 벡터화 데이터셋 빌더로 교체해 학습 속도를 높이고, LXC 도커 컨테이너에서 자동 재학습 코드를 제거한다. + +**Architecture:** `src/dataset_builder.py`에 벡터화 함수를 신규 작성하고 `scripts/train_model.py`, `scripts/train_mlx_model.py`에서 호출한다. `src/bot.py`에서 `Retrainer` 의존성을 제거하고 `src/retrainer.py`는 삭제한다. `src/indicators.py`, `src/ml_features.py`는 봇 실시간 경로이므로 변경하지 않는다. + +**Tech Stack:** Python 3.13, pandas-ta, numpy, pandas, LightGBM, MLX + +--- + +## 변경 범위 요약 + +| 파일 | 작업 | +|------|------| +| `src/dataset_builder.py` | 신규 — 벡터화 데이터셋 생성 | +| `scripts/train_model.py` | `generate_dataset` → `generate_dataset_vectorized` 교체 | +| `scripts/train_mlx_model.py` | 동일 | +| `src/bot.py` | `Retrainer` import·인스턴스·태스크 제거 | +| `src/retrainer.py` | 삭제 | +| `tests/test_retrainer.py` | 삭제 | +| `tests/test_dataset_builder.py` | 신규 — 벡터화 빌더 테스트 | +| `Dockerfile` | `mlx` 제외 처리 (Linux ARM에서 설치 불가) | +| `requirements.txt` | mlx를 Mac 전용 주석으로 표시 | + +--- + +## Task 1: `src/dataset_builder.py` 신규 작성 + +**핵심 아이디어**: `pandas_ta`를 전체 시계열에 1번만 호출하고, 신호 조건·피처·레이블을 모두 numpy 배열 연산으로 처리한다. + +**Files:** +- Create: `src/dataset_builder.py` +- Create: `tests/test_dataset_builder.py` + +**Step 1: 실패 테스트 작성** + +```python +# tests/test_dataset_builder.py +import numpy as np +import pandas as pd +import pytest +from src.dataset_builder import generate_dataset_vectorized + +@pytest.fixture +def sample_df(): + """최소 200행 이상의 OHLCV 더미 데이터.""" + rng = np.random.default_rng(42) + n = 500 + close = 2.0 + np.cumsum(rng.normal(0, 0.01, n)) + close = np.clip(close, 0.01, None) + high = close * (1 + rng.uniform(0, 0.005, n)) + low = close * (1 - rng.uniform(0, 0.005, n)) + return pd.DataFrame({ + "open": close, + "high": high, + "low": low, + "close": close, + "volume": rng.uniform(1e6, 5e6, n), + }) + +def test_returns_dataframe(sample_df): + """결과가 DataFrame이어야 한다.""" + result = generate_dataset_vectorized(sample_df) + assert isinstance(result, pd.DataFrame) + +def test_has_required_columns(sample_df): + """FEATURE_COLS + label 컬럼이 모두 있어야 한다.""" + from src.ml_features import FEATURE_COLS + result = generate_dataset_vectorized(sample_df) + if len(result) > 0: + assert "label" in result.columns + for col in FEATURE_COLS: + assert col in result.columns, f"컬럼 없음: {col}" + +def test_label_is_binary(sample_df): + """label은 0 또는 1만 있어야 한다.""" + result = generate_dataset_vectorized(sample_df) + if len(result) > 0: + assert set(result["label"].unique()).issubset({0, 1}) + +def test_matches_original_generate_dataset(sample_df): + """벡터화 버전과 기존 버전의 샘플 수가 동일해야 한다.""" + from scripts.train_model import generate_dataset + orig = generate_dataset(sample_df, n_jobs=1) + vec = generate_dataset_vectorized(sample_df) + assert len(vec) == len(orig), ( + f"샘플 수 불일치: 벡터화={len(vec)}, 기존={len(orig)}" + ) +``` + +**Step 2: 테스트 실행 (실패 확인)** + +```bash +cd /Users/gihyeon/github/cointrader +.venv/bin/python -m pytest tests/test_dataset_builder.py -v +``` + +Expected: `ImportError: cannot import name 'generate_dataset_vectorized'` + +**Step 3: `src/dataset_builder.py` 구현** + +```python +# src/dataset_builder.py +""" +전체 시계열을 1회 계산하는 벡터화 데이터셋 빌더. +pandas_ta를 130,000번 반복 호출하는 기존 방식 대신 +전체 배열에 1번만 적용해 10~30배 속도를 낸다. + +봇 실시간 경로(indicators.py, ml_features.py)는 변경하지 않는다. +""" +import numpy as np +import pandas as pd +import pandas_ta as ta + +from src.ml_features import FEATURE_COLS + +LOOKAHEAD = 60 +ATR_SL_MULT = 1.5 +ATR_TP_MULT = 3.0 +WARMUP = 60 # 지표 안정화에 필요한 최소 행 수 + + +def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame: + """전체 시계열에 기술 지표를 1회 계산한다.""" + d = df.copy() + close = d["close"] + high = d["high"] + low = d["low"] + volume = d["volume"] + + d["rsi"] = ta.rsi(close, length=14) + + macd = ta.macd(close, fast=12, slow=26, signal=9) + d["macd"] = macd["MACD_12_26_9"] + d["macd_signal"] = macd["MACDs_12_26_9"] + d["macd_hist"] = macd["MACDh_12_26_9"] + + bb = ta.bbands(close, length=20, std=2) + d["bb_upper"] = bb["BBU_20_2.0_2.0"] + d["bb_lower"] = bb["BBL_20_2.0_2.0"] + + d["ema9"] = ta.ema(close, length=9) + d["ema21"] = ta.ema(close, length=21) + d["ema50"] = ta.ema(close, length=50) + + d["atr"] = ta.atr(high, low, close, length=14) + d["vol_ma20"] = ta.sma(volume, length=20) + + stoch = ta.stochrsi(close, length=14) + d["stoch_k"] = stoch["STOCHRSIk_14_14_3_3"] + d["stoch_d"] = stoch["STOCHRSId_14_14_3_3"] + + return d + + +def _calc_signals(d: pd.DataFrame) -> np.ndarray: + """ + indicators.py get_signal() 로직을 numpy 배열 연산으로 재현한다. + 반환: signal_arr — 각 행에 대해 "LONG" | "SHORT" | "HOLD" + """ + n = len(d) + + rsi = d["rsi"].values + macd = d["macd"].values + macd_sig = d["macd_signal"].values + close = d["close"].values + bb_upper = d["bb_upper"].values + bb_lower = d["bb_lower"].values + ema9 = d["ema9"].values + ema21 = d["ema21"].values + ema50 = d["ema50"].values + stoch_k = d["stoch_k"].values + stoch_d = d["stoch_d"].values + volume = d["volume"].values + vol_ma20 = d["vol_ma20"].values + + # MACD 크로스: 전 캔들과 비교 (shift(1)) + prev_macd = np.roll(macd, 1); prev_macd[0] = np.nan + prev_macd_sig = np.roll(macd_sig, 1); prev_macd_sig[0] = np.nan + + long_score = np.zeros(n, dtype=np.float32) + short_score = np.zeros(n, dtype=np.float32) + + # 1. RSI + long_score += (rsi < 35).astype(np.float32) + short_score += (rsi > 65).astype(np.float32) + + # 2. MACD 크로스 (가중치 2) + macd_cross_up = (prev_macd < prev_macd_sig) & (macd > macd_sig) + macd_cross_down = (prev_macd > prev_macd_sig) & (macd < macd_sig) + long_score += macd_cross_up.astype(np.float32) * 2 + short_score += macd_cross_down.astype(np.float32) * 2 + + # 3. 볼린저 밴드 + long_score += (close < bb_lower).astype(np.float32) + short_score += (close > bb_upper).astype(np.float32) + + # 4. EMA 정배열/역배열 + long_score += ((ema9 > ema21) & (ema21 > ema50)).astype(np.float32) + short_score += ((ema9 < ema21) & (ema21 < ema50)).astype(np.float32) + + # 5. Stochastic RSI + long_score += ((stoch_k < 20) & (stoch_k > stoch_d)).astype(np.float32) + short_score += ((stoch_k > 80) & (stoch_k < stoch_d)).astype(np.float32) + + # 6. 거래량 급증 + vol_surge = volume > vol_ma20 * 1.5 + + long_enter = (long_score >= 3) & (vol_surge | (long_score >= 4)) + short_enter = (short_score >= 3) & (vol_surge | (short_score >= 4)) + + signal_arr = np.full(n, "HOLD", dtype=object) + signal_arr[long_enter] = "LONG" + signal_arr[short_enter] = "SHORT" + # 둘 다 해당하면 HOLD (충돌 방지) + signal_arr[long_enter & short_enter] = "HOLD" + + return signal_arr + + +def _calc_features_vectorized(d: pd.DataFrame, signal_arr: np.ndarray) -> pd.DataFrame: + """ + 신호 발생 인덱스에서 ml_features.py build_features() 로직을 + pandas 벡터 연산으로 재현한다. + """ + close = d["close"] + bb_upper = d["bb_upper"] + bb_lower = d["bb_lower"] + ema9 = d["ema9"] + ema21 = d["ema21"] + ema50 = d["ema50"] + atr = d["atr"] + volume = d["volume"] + vol_ma20 = d["vol_ma20"] + rsi = d["rsi"] + macd_hist = d["macd_hist"] + stoch_k = d["stoch_k"] + stoch_d = d["stoch_d"] + macd = d["macd"] + macd_sig = d["macd_signal"] + + bb_range = bb_upper - bb_lower + bb_pct = np.where(bb_range > 0, (close - bb_lower) / bb_range, 0.5) + + ema_align = np.where( + (ema9 > ema21) & (ema21 > ema50), 1, + np.where( + (ema9 < ema21) & (ema21 < ema50), -1, 0 + ) + ).astype(np.float32) + + atr_pct = np.where(close > 0, atr / close, 0.0) + vol_ratio = np.where(vol_ma20 > 0, volume / vol_ma20, 1.0) + + ret_1 = close.pct_change(1).fillna(0).values + ret_3 = close.pct_change(3).fillna(0).values + ret_5 = close.pct_change(5).fillna(0).values + + prev_macd = macd.shift(1).fillna(0).values + prev_macd_sig = macd_sig.shift(1).fillna(0).values + + # signal_strength: 신호 방향별로 각 조건 점수 합산 + is_long = (signal_arr == "LONG") + is_short = (signal_arr == "SHORT") + + strength = np.zeros(len(d), dtype=np.float32) + + # LONG 조건 + strength += is_long * (rsi.values < 35).astype(np.float32) + strength += is_long * ((prev_macd < prev_macd_sig) & (macd.values > macd_sig.values)).astype(np.float32) * 2 + strength += is_long * (close.values < bb_lower.values).astype(np.float32) + strength += is_long * (ema_align == 1).astype(np.float32) + strength += is_long * ((stoch_k.values < 20) & (stoch_k.values > stoch_d.values)).astype(np.float32) + + # SHORT 조건 + strength += is_short * (rsi.values > 65).astype(np.float32) + strength += is_short * ((prev_macd > prev_macd_sig) & (macd.values < macd_sig.values)).astype(np.float32) * 2 + strength += is_short * (close.values > bb_upper.values).astype(np.float32) + strength += is_short * (ema_align == -1).astype(np.float32) + strength += is_short * ((stoch_k.values > 80) & (stoch_k.values < stoch_d.values)).astype(np.float32) + + side = np.where(signal_arr == "LONG", 1.0, 0.0).astype(np.float32) + + return pd.DataFrame({ + "rsi": rsi.values.astype(np.float32), + "macd_hist": macd_hist.values.astype(np.float32), + "bb_pct": bb_pct.astype(np.float32), + "ema_align": ema_align, + "stoch_k": stoch_k.values.astype(np.float32), + "stoch_d": stoch_d.values.astype(np.float32), + "atr_pct": atr_pct.astype(np.float32), + "vol_ratio": vol_ratio.astype(np.float32), + "ret_1": ret_1.astype(np.float32), + "ret_3": ret_3.astype(np.float32), + "ret_5": ret_5.astype(np.float32), + "signal_strength": strength, + "side": side, + "_signal": signal_arr, # 레이블 계산용 임시 컬럼 + }, index=d.index) + + +def _calc_labels_vectorized( + d: pd.DataFrame, + feat: pd.DataFrame, + sig_idx: np.ndarray, +) -> np.ndarray: + """ + label_builder.py build_labels() 로직을 numpy 2D 배열로 벡터화한다. + + 각 신호 인덱스 i에 대해 future[i+1 : i+1+LOOKAHEAD] 구간의 + high/low 배열을 (N × LOOKAHEAD) 행렬로 만들어 argmax로 처리한다. + """ + n_total = len(d) + highs = d["high"].values + lows = d["low"].values + closes = d["close"].values + atrs = d["atr"].values + + labels = [] + valid_mask = [] + + for idx in sig_idx: + signal = feat.at[d.index[idx], "_signal"] + entry = closes[idx] + atr = atrs[idx] + if atr <= 0: + valid_mask.append(False) + continue + + 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 + + end = min(idx + 1 + LOOKAHEAD, n_total) + fut_high = highs[idx + 1 : end] + fut_low = lows[idx + 1 : end] + + label = None + for h, l in zip(fut_high, fut_low): + if signal == "LONG": + if h >= tp: + label = 1 + break + if l <= sl: + label = 0 + break + else: + if l <= tp: + label = 1 + break + if h >= sl: + label = 0 + break + + if label is None: + valid_mask.append(False) + else: + labels.append(label) + valid_mask.append(True) + + return np.array(labels, dtype=np.int8), np.array(valid_mask, dtype=bool) + + +def generate_dataset_vectorized(df: pd.DataFrame) -> pd.DataFrame: + """ + 전체 시계열을 1회 계산해 학습 데이터셋을 생성한다. + 기존 generate_dataset()의 drop-in 대체제. + """ + print(" [1/3] 전체 시계열 지표 계산 (1회)...") + d = _calc_indicators(df) + + print(" [2/3] 신호 마스킹 및 피처 추출...") + signal_arr = _calc_signals(d) + feat_all = _calc_features_vectorized(d, signal_arr) + + # 신호 발생 + NaN 없음 + 미래 데이터 충분한 인덱스만 + valid_rows = ( + (signal_arr != "HOLD") & + (~feat_all[FEATURE_COLS].isna().any(axis=1).values) & + (np.arange(len(d)) >= WARMUP) & + (np.arange(len(d)) < len(d) - LOOKAHEAD) + ) + sig_idx = np.where(valid_rows)[0] + print(f" 신호 발생 인덱스: {len(sig_idx):,}개") + + print(" [3/3] 레이블 계산...") + labels, valid_mask = _calc_labels_vectorized(d, feat_all, sig_idx) + + final_idx = sig_idx[valid_mask] + feat_final = feat_all.iloc[final_idx][FEATURE_COLS].copy() + feat_final["label"] = labels + + return feat_final.reset_index(drop=True) +``` + +**Step 4: 테스트 실행 (통과 확인)** + +```bash +.venv/bin/python -m pytest tests/test_dataset_builder.py -v +``` + +Expected: 4 passed + +**Step 5: 커밋** + +```bash +git add src/dataset_builder.py tests/test_dataset_builder.py +git commit -m "feat: add vectorized dataset builder (1x pandas_ta call)" +``` + +--- + +## Task 2: `scripts/train_model.py` 교체 + +**Files:** +- Modify: `scripts/train_model.py` + +**Step 1: `generate_dataset` 호출을 벡터화 버전으로 교체** + +`scripts/train_model.py` 상단 import에 추가: +```python +from src.dataset_builder import generate_dataset_vectorized +``` + +`train()` 함수 내 `generate_dataset(df, n_jobs=n_jobs)` 호출을 교체: +```python +# 기존 +dataset = generate_dataset(df, n_jobs=n_jobs) + +# 변경 +dataset = generate_dataset_vectorized(df) +``` + +`main()`의 `--jobs` 인자 제거: +```python +# 기존 +parser.add_argument("--jobs", type=int, default=None, + help="병렬 worker 수 (기본: CPU 수 - 1)") +args = parser.parse_args() +train(args.data, n_jobs=args.jobs) + +# 변경 +args = parser.parse_args() +train(args.data) +``` + +`train()` 함수 시그니처에서 `n_jobs` 파라미터 제거: +```python +# 기존 +def train(data_path: str, n_jobs: int | None = None): + +# 변경 +def train(data_path: str): +``` + +**Step 2: 학습 실행 및 시간 측정** + +```bash +time .venv/bin/python scripts/train_model.py --data data/xrpusdt_1m.parquet +``` + +Expected: 기존 130초 → 10초 이내 + +**Step 3: 커밋** + +```bash +git add scripts/train_model.py +git commit -m "perf: replace generate_dataset with vectorized version in train_model" +``` + +--- + +## Task 3: `scripts/train_mlx_model.py` 교체 + +**Files:** +- Modify: `scripts/train_mlx_model.py` + +**Step 1: import 교체** + +`scripts/train_mlx_model.py` 상단에서: +```python +# 기존 +from scripts.train_model import generate_dataset + +# 변경 +from src.dataset_builder import generate_dataset_vectorized +``` + +`train_mlx()` 함수 내 호출 교체: +```python +# 기존 +dataset = generate_dataset(df) + +# 변경 +dataset = generate_dataset_vectorized(df) +``` + +**Step 2: 실행 확인** + +```bash +time .venv/bin/python scripts/train_mlx_model.py --data data/xrpusdt_1m.parquet +``` + +**Step 3: 커밋** + +```bash +git add scripts/train_mlx_model.py +git commit -m "perf: replace generate_dataset with vectorized version in train_mlx_model" +``` + +--- + +## Task 4: 컨테이너에서 재학습 제거 + +**Files:** +- Modify: `src/bot.py` +- Delete: `src/retrainer.py` +- Delete: `tests/test_retrainer.py` + +**Step 1: `src/bot.py`에서 Retrainer 제거** + +`src/bot.py`에서 다음 3곳을 수정: + +```python +# 제거할 import +from src.retrainer import Retrainer + +# 제거할 __init__ 코드 +self.retrainer = Retrainer(ml_filter=self.ml_filter) + +# 제거할 run() 코드 +asyncio.create_task(self.retrainer.schedule_daily(hour=3)) +``` + +**Step 2: `src/retrainer.py` 삭제** + +```bash +rm src/retrainer.py +``` + +**Step 3: `tests/test_retrainer.py` 삭제** + +```bash +rm tests/test_retrainer.py +``` + +**Step 4: 기존 테스트 전체 통과 확인** + +```bash +.venv/bin/python -m pytest tests/ -v --ignore=tests/test_retrainer.py +``` + +Expected: 모든 테스트 통과 + +**Step 5: 커밋** + +```bash +git add src/bot.py +git rm src/retrainer.py tests/test_retrainer.py +git commit -m "feat: remove in-container retraining, training is now mac-only" +``` + +--- + +## Task 5: Dockerfile에서 mlx 제외 + +`mlx`는 Apple Silicon 전용이라 Linux(LXC) 컨테이너에서 설치 불가. + +**Files:** +- Modify: `requirements.txt` +- Modify: `Dockerfile` + +**Step 1: `requirements.txt`에서 mlx 조건부 처리** + +`requirements.txt`에서: +``` +# 변경 전 +mlx>=0.22.0 + +# 변경 후 (삭제 — Dockerfile에서 별도 처리) +``` +mlx 줄을 삭제한다. + +**Step 2: `Dockerfile`에 mlx 제외 명시** + +```dockerfile +# 변경 전 +RUN pip install --no-cache-dir -r requirements.txt + +# 변경 후 +RUN pip install --no-cache-dir -r requirements.txt +# mlx는 Apple Silicon 전용이므로 컨테이너에 설치하지 않는다 +``` + +실제로는 requirements.txt에서 mlx를 제거하는 것만으로 충분하다. +맥미니에서는 수동으로 설치: +```bash +pip install mlx>=0.22.0 +``` + +**Step 3: README 업데이트** + +`README.md`의 "Apple Silicon GPU 가속 학습" 섹션에 설치 안내 추가: +```markdown +> **설치**: `mlx`는 Apple Silicon 전용이며 `requirements.txt`에 포함되지 않습니다. +> 맥미니에서 별도 설치: `pip install mlx` +``` + +**Step 4: 커밋** + +```bash +git add requirements.txt Dockerfile README.md +git commit -m "chore: exclude mlx from container requirements (Apple Silicon only)" +``` + +--- + +## Task 6: 전체 검증 및 속도 비교 + +**Step 1: 프로파일러로 최종 속도 측정** + +```bash +time .venv/bin/python scripts/train_model.py --data data/xrpusdt_1m.parquet +``` + +Expected: 10초 이내 (기존 130초 대비 10배+ 향상) + +**Step 2: 전체 테스트 통과 확인** + +```bash +.venv/bin/python -m pytest tests/ -v +``` + +Expected: 모든 테스트 통과 (test_retrainer.py 제외) + +**Step 3: train_and_deploy.sh 전체 파이프라인 dry-run** + +```bash +bash scripts/train_and_deploy.sh 2>&1 | head -30 +``` + +**Step 4: 최종 커밋 없음** — 각 Task에서 이미 커밋 완료 diff --git a/models/mlx_filter.meta.npz b/models/mlx_filter.meta.npz new file mode 100644 index 0000000000000000000000000000000000000000..4ec6515498faec40e71c28397b0872c42a4bef08 GIT binary patch literal 1122 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.3.0 scikit-learn>=1.4.0 joblib>=1.3.0 pyarrow>=15.0.0 +mlx>=0.22.0 diff --git a/scripts/profile_training.py b/scripts/profile_training.py new file mode 100644 index 0000000..01064fc --- /dev/null +++ b/scripts/profile_training.py @@ -0,0 +1,53 @@ +""" +학습 파이프라인 각 단계의 소요 시간을 측정한다. +사용법: python scripts/profile_training.py --data data/xrpusdt_1m.parquet +""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import time +import argparse +import pandas as pd +from scripts.train_model import generate_dataset, _cgroup_cpu_count + + +def profile(data_path: str): + print(f"데이터 로드: {data_path}") + df = pd.read_parquet(data_path) + print(f"캔들 수: {len(df)}") + + workers = max(1, _cgroup_cpu_count() - 1) + print(f"사용 코어: {workers}") + + t0 = time.perf_counter() + dataset = generate_dataset(df) + t1 = time.perf_counter() + print(f"\n[결과] 데이터셋 생성: {t1 - t0:.1f}초, 샘플 {len(dataset)}개") + + import lightgbm as lgb + from sklearn.model_selection import train_test_split + from src.ml_features import FEATURE_COLS + + X = dataset[FEATURE_COLS] + y = dataset["label"] + X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=42) + + model = lgb.LGBMClassifier( + n_estimators=300, learning_rate=0.05, num_leaves=31, + min_child_samples=20, subsample=0.8, colsample_bytree=0.8, + class_weight="balanced", random_state=42, verbose=-1, + ) + t2 = time.perf_counter() + model.fit(X_train, y_train) + t3 = time.perf_counter() + print(f"[결과] LightGBM 학습: {t3 - t2:.1f}초") + print(f"[결과] 전체: {t3 - t0:.1f}초") + print(f"\n[비율] 데이터셋 생성: {(t1-t0)/(t3-t0)*100:.0f}% / LightGBM 학습: {(t3-t2)/(t3-t0)*100:.0f}%") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--data", default="data/xrpusdt_1m.parquet") + args = parser.parse_args() + profile(args.data) diff --git a/scripts/train_and_deploy.sh b/scripts/train_and_deploy.sh index a5529e7..d1382e7 100755 --- a/scripts/train_and_deploy.sh +++ b/scripts/train_and_deploy.sh @@ -21,7 +21,15 @@ python scripts/fetch_history.py --symbol XRPUSDT --interval 1m --days 90 --outpu echo "" echo "=== [2/3] 모델 학습 ===" -python scripts/train_model.py --data data/xrpusdt_1m.parquet +# TRAIN_BACKEND=mlx 로 설정하면 Apple Silicon GPU(Metal)를 사용한다 (기본: lgbm) +BACKEND="${TRAIN_BACKEND:-lgbm}" +if [ "$BACKEND" = "mlx" ]; then + echo " 백엔드: MLX (Apple Silicon GPU)" + python scripts/train_mlx_model.py --data data/xrpusdt_1m.parquet +else + echo " 백엔드: LightGBM (CPU)" + python scripts/train_model.py --data data/xrpusdt_1m.parquet +fi echo "" echo "=== [3/3] LXC 배포 ===" diff --git a/src/bot.py b/src/bot.py index c7dfbdc..5557898 100644 --- a/src/bot.py +++ b/src/bot.py @@ -8,7 +8,6 @@ from src.notifier import DiscordNotifier from src.risk_manager import RiskManager from src.ml_filter import MLFilter from src.ml_features import build_features -from src.retrainer import Retrainer class TradingBot: @@ -18,7 +17,6 @@ class TradingBot: self.notifier = DiscordNotifier(config.discord_webhook_url) self.risk = RiskManager(config) self.ml_filter = MLFilter() - self.retrainer = Retrainer(ml_filter=self.ml_filter) self.current_trade_side: str | None = None # "LONG" | "SHORT" self.stream = KlineStream( symbol=config.symbol, @@ -165,7 +163,6 @@ class TradingBot: async def run(self): logger.info(f"봇 시작: {self.config.symbol}, 레버리지 {self.config.leverage}x") await self._recover_position() - asyncio.create_task(self.retrainer.schedule_daily(hour=3)) await self.stream.start( api_key=self.config.api_key, api_secret=self.config.api_secret, diff --git a/src/mlx_filter.py b/src/mlx_filter.py new file mode 100644 index 0000000..2698ff3 --- /dev/null +++ b/src/mlx_filter.py @@ -0,0 +1,130 @@ +""" +Apple MLX 기반 경량 신경망 필터. +M4의 통합 GPU를 자동으로 활용한다. +""" +import numpy as np +import pandas as pd +import mlx.core as mx +import mlx.nn as nn +import mlx.optimizers as optim +from pathlib import Path + +from src.ml_features import FEATURE_COLS + + +class _Net(nn.Module): + """3층 MLP 이진 분류기.""" + + def __init__(self, input_dim: int, hidden_dim: int): + super().__init__() + self.fc1 = nn.Linear(input_dim, hidden_dim) + self.fc2 = nn.Linear(hidden_dim, hidden_dim // 2) + self.fc3 = nn.Linear(hidden_dim // 2, 1) + self.dropout = nn.Dropout(p=0.2) + + def __call__(self, x: mx.array) -> mx.array: + x = nn.relu(self.fc1(x)) + x = self.dropout(x) + x = nn.relu(self.fc2(x)) + return self.fc3(x).squeeze(-1) + + +class MLXFilter: + """ + scikit-learn 호환 인터페이스를 제공하는 MLX 신경망 필터. + M4 통합 GPU(Metal)를 자동으로 사용한다. + """ + + def __init__( + self, + input_dim: int = 13, + hidden_dim: int = 64, + lr: float = 1e-3, + epochs: int = 50, + batch_size: int = 256, + ): + self.input_dim = input_dim + self.hidden_dim = hidden_dim + self.lr = lr + self.epochs = epochs + self.batch_size = batch_size + self._model = _Net(input_dim, hidden_dim) + self._mean: np.ndarray | None = None + self._std: np.ndarray | None = None + self._trained = False + + def fit(self, X: pd.DataFrame, y: pd.Series) -> "MLXFilter": + X_np = X[FEATURE_COLS].values.astype(np.float32) + y_np = y.values.astype(np.float32) + + self._mean = X_np.mean(axis=0) + self._std = X_np.std(axis=0) + 1e-8 + X_np = (X_np - self._mean) / self._std + + optimizer = optim.Adam(learning_rate=self.lr) + + def loss_fn(model: _Net, x: mx.array, y: mx.array) -> mx.array: + logits = model(x) + return nn.losses.binary_cross_entropy(logits, y, with_logits=True) + + loss_and_grad = nn.value_and_grad(self._model, loss_fn) + + n = len(X_np) + for epoch in range(self.epochs): + idx = np.random.permutation(n) + epoch_loss = 0.0 + steps = 0 + for start in range(0, n, self.batch_size): + batch_idx = idx[start : start + self.batch_size] + x_batch = mx.array(X_np[batch_idx]) + y_batch = mx.array(y_np[batch_idx]) + loss, grads = loss_and_grad(self._model, x_batch, y_batch) + optimizer.update(self._model, grads) + mx.eval(self._model.parameters(), optimizer.state) + epoch_loss += loss.item() + steps += 1 + if (epoch + 1) % 10 == 0: + print(f" Epoch {epoch + 1}/{self.epochs} loss={epoch_loss / steps:.4f}") + + self._trained = True + return self + + def predict_proba(self, X: pd.DataFrame) -> np.ndarray: + X_np = X[FEATURE_COLS].values.astype(np.float32) + if self._trained and self._mean is not None: + X_np = (X_np - self._mean) / self._std + x = mx.array(X_np) + self._model.eval() + logits = self._model(x) + proba = mx.sigmoid(logits) + mx.eval(proba) + self._model.train() + return np.array(proba) + + def save(self, path: str | Path) -> None: + path = Path(path) + path.parent.mkdir(exist_ok=True) + weights_path = path.with_suffix(".npz") + self._model.save_weights(str(weights_path)) + meta_path = path.with_suffix(".meta.npz") + np.savez( + meta_path, + mean=self._mean, + std=self._std, + input_dim=np.array(self.input_dim), + hidden_dim=np.array(self.hidden_dim), + ) + + @classmethod + def load(cls, path: str | Path) -> "MLXFilter": + path = Path(path) + meta = np.load(path.with_suffix(".meta.npz")) + obj = cls( + input_dim=int(meta["input_dim"]), + hidden_dim=int(meta["hidden_dim"]), + ) + obj._mean = meta["mean"] + obj._std = meta["std"] + obj._model.load_weights(str(path.with_suffix(".npz"))) + obj._trained = True + return obj diff --git a/src/retrainer.py b/src/retrainer.py deleted file mode 100644 index 751d194..0000000 --- a/src/retrainer.py +++ /dev/null @@ -1,92 +0,0 @@ -import asyncio -import json -from datetime import datetime -from pathlib import Path - -from loguru import logger - -from src.ml_filter import MLFilter - -MODEL_PATH = Path("models/lgbm_filter.pkl") -PREV_MODEL_PATH = Path("models/lgbm_filter_prev.pkl") -LOG_PATH = Path("models/training_log.json") - - -def get_current_auc() -> float: - """training_log.json에서 가장 최근 AUC를 읽는다.""" - if not LOG_PATH.exists(): - return 0.0 - with open(LOG_PATH) as f: - log = json.load(f) - return log[-1]["auc"] if log else 0.0 - - -def rollback_model(): - """이전 모델로 롤백한다.""" - if PREV_MODEL_PATH.exists(): - import shutil - shutil.copy(PREV_MODEL_PATH, MODEL_PATH) - logger.warning("ML 모델 롤백 완료") - else: - logger.warning("롤백할 이전 모델 없음") - - -async def fetch_and_save(data_path: str): - """증분 데이터 수집 (fetch_history.py 로직 재사용).""" - import subprocess - result = subprocess.run( - ["python", "scripts/fetch_history.py", "--output", data_path, "--days", "90"], - capture_output=True, text=True, - ) - if result.returncode != 0: - raise RuntimeError(f"데이터 수집 실패: {result.stderr}") - logger.info(f"데이터 수집 완료: {data_path}") - - -def run_training(data_path: str) -> float: - """train_model.py를 실행하고 새 AUC를 반환한다.""" - import subprocess - result = subprocess.run( - ["python", "scripts/train_model.py", "--data", data_path], - capture_output=True, text=True, - ) - if result.returncode != 0: - raise RuntimeError(f"학습 실패: {result.stderr}") - new_auc = get_current_auc() - return new_auc - - -class Retrainer: - def __init__(self, ml_filter: MLFilter, data_path: str = "data/xrpusdt_1m.parquet"): - self._ml_filter = ml_filter - self._data_path = data_path - - async def retrain(self): - logger.info("자동 재학습 시작") - old_auc = get_current_auc() - try: - await fetch_and_save(self._data_path) - new_auc = run_training(self._data_path) - logger.info(f"재학습 완료: 이전 AUC={old_auc:.4f} → 새 AUC={new_auc:.4f}") - - if new_auc < old_auc - 0.01: - logger.warning(f"새 모델 성능 저하 ({new_auc:.4f} < {old_auc:.4f}), 롤백") - rollback_model() - else: - self._ml_filter.reload_model() - logger.success("새 ML 모델 적용 완료") - except Exception as e: - logger.error(f"재학습 실패: {e}") - - async def schedule_daily(self, hour: int = 3): - """매일 지정 시각(컨테이너 로컬 시간 기준)에 재학습을 실행한다.""" - from datetime import timedelta - while True: - now = datetime.now() - next_run = now.replace(hour=hour, minute=0, second=0, microsecond=0) - if next_run <= now: - next_run += timedelta(days=1) - wait_secs = (next_run - now).total_seconds() - logger.info(f"다음 재학습까지 {wait_secs/3600:.1f}시간 대기") - await asyncio.sleep(wait_secs) - await self.retrain() diff --git a/tests/test_mlx_filter.py b/tests/test_mlx_filter.py new file mode 100644 index 0000000..e19e937 --- /dev/null +++ b/tests/test_mlx_filter.py @@ -0,0 +1,86 @@ +""" +MLXFilter 단위 테스트. +Apple Silicon GPU(Metal)가 없는 환경에서는 스킵한다. +""" +import numpy as np +import pandas as pd +import pytest + +mlx = pytest.importorskip("mlx.core", reason="MLX 미설치") + + +def _make_X(n: int = 4) -> pd.DataFrame: + rng = np.random.default_rng(0) + return pd.DataFrame( + { + "rsi": rng.uniform(20, 80, n), + "macd_hist": rng.uniform(-0.1, 0.1, n), + "bb_pct": rng.uniform(0, 1, n), + "ema_align": rng.choice([-1.0, 0.0, 1.0], n), + "stoch_k": rng.uniform(0, 100, n), + "stoch_d": rng.uniform(0, 100, n), + "atr_pct": rng.uniform(0.001, 0.05, n), + "vol_ratio": rng.uniform(0.5, 3.0, n), + "ret_1": rng.uniform(-0.01, 0.01, n), + "ret_3": rng.uniform(-0.02, 0.02, n), + "ret_5": rng.uniform(-0.03, 0.03, n), + "signal_strength": rng.integers(0, 6, n).astype(float), + "side": rng.choice([0.0, 1.0], n), + } + ) + + +def test_mlx_gpu_device(): + """MLX가 GPU 디바이스를 기본으로 사용해야 한다.""" + import mlx.core as mx + + device = mx.default_device() + assert "gpu" in str(device) + + +def test_mlx_filter_predict_shape_untrained(): + """학습 전에도 predict_proba가 (N,) 형태를 반환해야 한다.""" + from src.mlx_filter import MLXFilter + + X = _make_X(4) + model = MLXFilter(input_dim=13, hidden_dim=32) + proba = model.predict_proba(X) + assert proba.shape == (4,) + assert np.all((proba >= 0.0) & (proba <= 1.0)) + + +def test_mlx_filter_fit_and_predict(): + """학습 후 predict_proba가 유효한 확률값을 반환해야 한다.""" + from src.mlx_filter import MLXFilter + + n = 100 + X = _make_X(n) + y = pd.Series(np.random.randint(0, 2, n)) + + model = MLXFilter(input_dim=13, hidden_dim=32, epochs=5, batch_size=32) + model.fit(X, y) + proba = model.predict_proba(X) + + assert proba.shape == (n,) + assert np.all((proba >= 0.0) & (proba <= 1.0)) + + +def test_mlx_filter_save_load(tmp_path): + """저장 후 로드한 모델이 동일한 예측값을 반환해야 한다.""" + from src.mlx_filter import MLXFilter + + n = 50 + X = _make_X(n) + y = pd.Series(np.random.randint(0, 2, n)) + + model = MLXFilter(input_dim=13, hidden_dim=32, epochs=3, batch_size=32) + model.fit(X, y) + proba_before = model.predict_proba(X) + + save_path = tmp_path / "mlx_filter.weights" + model.save(save_path) + + loaded = MLXFilter.load(save_path) + proba_after = loaded.predict_proba(X) + + np.testing.assert_allclose(proba_before, proba_after, atol=1e-5) diff --git a/tests/test_retrainer.py b/tests/test_retrainer.py deleted file mode 100644 index 4d8ad0e..0000000 --- a/tests/test_retrainer.py +++ /dev/null @@ -1,35 +0,0 @@ -import pytest -import json -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch -from src.retrainer import Retrainer - - -@pytest.mark.asyncio -async def test_retrain_calls_train(tmp_path): - """재학습 시 train 함수가 호출되는지 확인""" - ml_filter = MagicMock() - r = Retrainer(ml_filter=ml_filter, data_path=str(tmp_path / "data.parquet")) - - with patch("src.retrainer.fetch_and_save", new_callable=AsyncMock) as mock_fetch, \ - patch("src.retrainer.run_training", return_value=0.72) as mock_train, \ - patch("src.retrainer.get_current_auc", return_value=0.65): - await r.retrain() - - mock_fetch.assert_called_once() - mock_train.assert_called_once() - - -@pytest.mark.asyncio -async def test_retrain_rollback_when_worse(tmp_path): - """새 모델이 기존보다 나쁘면 롤백""" - ml_filter = MagicMock() - r = Retrainer(ml_filter=ml_filter, data_path=str(tmp_path / "data.parquet")) - - with patch("src.retrainer.fetch_and_save", new_callable=AsyncMock), \ - patch("src.retrainer.run_training", return_value=0.55), \ - patch("src.retrainer.get_current_auc", return_value=0.70), \ - patch("src.retrainer.rollback_model") as mock_rollback: - await r.retrain() - - mock_rollback.assert_called_once()