Compare commits

..

8 Commits

Author SHA1 Message Date
21in7
0f6a22fcb5 feat: MLX 임계값 탐색을 정밀도 우선(recall>=0.15 조건부)으로 변경
Made-with: Cursor
2026-03-01 23:54:38 +09:00
21in7
aa413f4d7c feat: LightGBM 임계값 탐색을 정밀도 우선(recall>=0.15 조건부)으로 변경
Made-with: Cursor
2026-03-01 23:54:13 +09:00
21in7
6ae0f9d81b fix: MLXFilter fit/predict에 nan-safe 정규화 적용 (nanmean + nan_to_num)
Made-with: Cursor
2026-03-01 23:53:49 +09:00
21in7
820d8e0213 refactor: 분모 연산을 1e-8 epsilon 패턴으로 통일
Made-with: Cursor
2026-03-01 23:52:59 +09:00
21in7
417b8e3c6a feat: OI/펀딩비 결측 구간을 np.nan으로 마스킹 (0.0 → nan)
Made-with: Cursor
2026-03-01 23:52:19 +09:00
21in7
3b7ee3e890 chore: .worktrees/ gitignore에 추가
Made-with: Cursor
2026-03-01 23:50:18 +09:00
21in7
24d3ba9411 feat: enhance data fetching and model training with OI and funding rate integration
- Updated `fetch_history.py` to collect open interest (OI) and funding rate data from Binance, improving the dataset for model training.
- Modified `train_and_deploy.sh` to include options for OI and funding rate collection during data fetching.
- Enhanced `dataset_builder.py` to incorporate OI change and funding rate features with rolling z-score normalization.
- Updated training logs to reflect new metrics and features, ensuring comprehensive tracking of model performance.
- Adjusted feature columns in `ml_features.py` to include OI and funding rate for improved model robustness.
2026-03-01 22:25:38 +09:00
21in7
4245d7cdbf 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.
2026-03-01 22:16:15 +09:00
19 changed files with 1424 additions and 58 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ logs/
venv/ venv/
models/*.pkl models/*.pkl
data/*.parquet data/*.parquet
.worktrees/

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

View File

@@ -0,0 +1,463 @@
# OI NaN 마스킹 / 분모 epsilon / 정밀도 우선 임계값 구현 계획
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** OI 데이터 결측 구간을 np.nan으로 처리하고, 분모 연산을 1e-8 패턴으로 통일하며, 임계값 탐색을 정밀도 우선(최소 재현율 조건부)으로 변경한다.
**Architecture:**
- `dataset_builder.py`: OI/펀딩비 nan 마스킹 + 분모 epsilon 통일 + `_rolling_zscore`의 nan-safe 처리
- `mlx_filter.py`: `fit()` 정규화 시 `np.nanmean`/`np.nanstd` + `nan_to_num` 적용
- `train_model.py`: 임계값 탐색 함수를 `precision_recall_curve` 기반으로 교체
- `train_mlx_model.py`: 동일한 임계값 탐색 함수 적용
**Tech Stack:** numpy, pandas, scikit-learn(precision_recall_curve), lightgbm, mlx
---
### Task 1: `dataset_builder.py` — OI/펀딩비 nan 마스킹
**Files:**
- Modify: `src/dataset_builder.py:261-268`
- Test: `tests/test_dataset_builder.py`
**Step 1: 기존 테스트 실행 (기준선 확인)**
```bash
python -m pytest tests/test_dataset_builder.py -v
```
Expected: 기존 테스트 전부 PASS (변경 전 기준선)
**Step 2: OI nan 마스킹 테스트 작성**
`tests/test_dataset_builder.py`에 아래 테스트 추가:
```python
def test_oi_nan_masking_no_column():
"""oi_change 컬럼이 없으면 전체가 nan이어야 한다."""
import numpy as np
import pandas as pd
from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators
# 최소한의 OHLCV 데이터 (지표 계산에 충분한 길이)
n = 100
np.random.seed(0)
df = pd.DataFrame({
"open": np.random.uniform(1, 2, n),
"high": np.random.uniform(2, 3, n),
"low": np.random.uniform(0.5, 1, n),
"close": np.random.uniform(1, 2, n),
"volume": np.random.uniform(1000, 5000, n),
})
d = _calc_indicators(df)
sig = _calc_signals(d)
feat = _calc_features_vectorized(d, sig)
# oi_change 컬럼이 없으면 oi_change 피처는 전부 nan이어야 함
# (rolling zscore 후에도 nan이 전파되어야 함)
assert feat["oi_change"].isna().all(), "oi_change 컬럼 없을 때 전부 nan이어야 함"
def test_oi_nan_masking_with_zeros():
"""oi_change 컬럼이 있어도 0.0 구간은 nan으로 마스킹되어야 한다."""
import numpy as np
import pandas as pd
from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators
n = 100
np.random.seed(0)
df = pd.DataFrame({
"open": np.random.uniform(1, 2, n),
"high": np.random.uniform(2, 3, n),
"low": np.random.uniform(0.5, 1, n),
"close": np.random.uniform(1, 2, n),
"volume": np.random.uniform(1000, 5000, n),
"oi_change": np.concatenate([np.zeros(50), np.random.uniform(-0.1, 0.1, 50)]),
})
d = _calc_indicators(df)
sig = _calc_signals(d)
feat = _calc_features_vectorized(d, sig)
# 앞 50개 구간은 0이었으므로 nan으로 마스킹 → rolling zscore 후에도 nan 전파
# 뒤 50개 구간은 실제 값이 있으므로 일부는 유한값이어야 함
assert feat["oi_change"].iloc[50:].notna().any(), "실제 OI 값 구간에 유한값이 있어야 함"
```
**Step 3: 테스트 실행 (FAIL 확인)**
```bash
python -m pytest tests/test_dataset_builder.py::test_oi_nan_masking_no_column tests/test_dataset_builder.py::test_oi_nan_masking_with_zeros -v
```
Expected: FAIL (현재 0.0으로 채우므로 isna().all()이 False)
**Step 4: `dataset_builder.py` 수정**
`src/dataset_builder.py` 261~268줄을 아래로 교체:
```python
# OI 변화율 / 펀딩비 피처
# 컬럼 없으면 전체 nan, 있으면 0.0 구간(데이터 미제공 구간)을 nan으로 마스킹
# LightGBM은 nan을 자체 처리; MLX는 fit()에서 nanmean/nanstd + nan_to_num 처리
if "oi_change" in d.columns:
oi_raw = np.where(d["oi_change"].values == 0.0, np.nan, d["oi_change"].values)
else:
oi_raw = np.full(len(d), np.nan)
if "funding_rate" in d.columns:
fr_raw = np.where(d["funding_rate"].values == 0.0, np.nan, d["funding_rate"].values)
else:
fr_raw = np.full(len(d), np.nan)
result["oi_change"] = _rolling_zscore(oi_raw.astype(np.float64))
result["funding_rate"] = _rolling_zscore(fr_raw.astype(np.float64))
```
**Step 5: `_rolling_zscore` nan-safe 처리 확인 및 수정**
`src/dataset_builder.py` `_rolling_zscore` 함수 (118~128줄)를 nan-safe하게 수정:
```python
def _rolling_zscore(arr: np.ndarray, window: int = 288) -> np.ndarray:
"""rolling window z-score 정규화. nan은 전파된다(nan-safe).
15분봉 기준 3일(288캔들) 윈도우. min_periods=1로 초반 데이터도 활용."""
s = pd.Series(arr.astype(np.float64))
r = s.rolling(window=window, min_periods=1)
mean = r.mean() # pandas rolling은 nan을 자동으로 건너뜀
std = r.std(ddof=0)
std = std.where(std >= 1e-8, other=1e-8)
z = (s - mean) / std
return z.values.astype(np.float32)
```
> 참고: pandas `rolling().mean()`은 기본적으로 nan을 건너뛰므로 별도 처리 불필요.
> nan 입력 → nan 출력이 자연스럽게 전파됨.
**Step 6: 테스트 재실행 (PASS 확인)**
```bash
python -m pytest tests/test_dataset_builder.py -v
```
Expected: 모든 테스트 PASS
**Step 7: 커밋**
```bash
git add src/dataset_builder.py tests/test_dataset_builder.py
git commit -m "feat: OI/펀딩비 결측 구간을 np.nan으로 마스킹 (0.0 → nan)"
```
---
### Task 2: `dataset_builder.py` — 분모 epsilon 통일
**Files:**
- Modify: `src/dataset_builder.py:157-168`
- Test: `tests/test_dataset_builder.py`
**Step 1: epsilon 통일 테스트 작성**
`tests/test_dataset_builder.py`에 추가:
```python
def test_epsilon_no_division_by_zero():
"""bb_range=0, close=0, vol_ma20=0 극단값에서 nan/inf가 발생하지 않아야 한다."""
import numpy as np
import pandas as pd
from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators
n = 100
# close를 모두 같은 값으로 → bb_range=0 유발
df = pd.DataFrame({
"open": np.ones(n),
"high": np.ones(n),
"low": np.ones(n),
"close": np.ones(n),
"volume": np.ones(n),
})
d = _calc_indicators(df)
sig = _calc_signals(d)
feat = _calc_features_vectorized(d, sig)
numeric_cols = feat.select_dtypes(include=[np.number]).columns
assert not feat[numeric_cols].isin([np.inf, -np.inf]).any().any(), \
"inf 값이 있으면 안 됨"
```
**Step 2: 테스트 실행 (기준선)**
```bash
python -m pytest tests/test_dataset_builder.py::test_epsilon_no_division_by_zero -v
```
**Step 3: `_calc_features_vectorized` 분모 epsilon 통일**
`src/dataset_builder.py` 157~168줄을 아래로 교체:
```python
bb_range = bb_upper - bb_lower
bb_pct = (close - bb_lower) / (bb_range + 1e-8)
ema_align = np.where(
(ema9 > ema21) & (ema21 > ema50), 1,
np.where(
(ema9 < ema21) & (ema21 < ema50), -1, 0
)
).astype(np.float32)
atr_pct = atr / (close + 1e-8)
vol_ratio = volume / (vol_ma20 + 1e-8)
```
그리고 상대강도 계산 (246~247줄):
```python
xrp_btc_rs_raw = (xrp_r1 / (btc_r1 + 1e-8)).astype(np.float32)
xrp_eth_rs_raw = (xrp_r1 / (eth_r1 + 1e-8)).astype(np.float32)
```
**Step 4: 테스트 재실행**
```bash
python -m pytest tests/test_dataset_builder.py -v
```
Expected: 모든 테스트 PASS
**Step 5: 커밋**
```bash
git add src/dataset_builder.py tests/test_dataset_builder.py
git commit -m "refactor: 분모 연산을 1e-8 epsilon 패턴으로 통일"
```
---
### Task 3: `mlx_filter.py` — nan-safe 정규화
**Files:**
- Modify: `src/mlx_filter.py:140-145`
- Test: `tests/test_mlx_filter.py`
**Step 1: nan-safe 정규화 테스트 작성**
`tests/test_mlx_filter.py`에 추가:
```python
def test_fit_with_nan_features():
"""oi_change 피처에 nan이 포함된 경우 학습이 정상 완료되어야 한다."""
import numpy as np
import pandas as pd
from src.mlx_filter import MLXFilter
from src.ml_features import FEATURE_COLS
n = 300
np.random.seed(42)
X = pd.DataFrame(
np.random.randn(n, len(FEATURE_COLS)).astype(np.float32),
columns=FEATURE_COLS,
)
# oi_change 앞 절반을 nan으로
X["oi_change"] = np.where(np.arange(n) < n // 2, np.nan, X["oi_change"])
y = pd.Series((np.random.rand(n) > 0.5).astype(np.float32))
model = MLXFilter(input_dim=len(FEATURE_COLS), hidden_dim=32, epochs=3)
model.fit(X, y) # nan 있어도 예외 없이 완료되어야 함
proba = model.predict_proba(X)
assert not np.any(np.isnan(proba)), "예측 확률에 nan이 없어야 함"
assert proba.min() >= 0.0 and proba.max() <= 1.0
```
**Step 2: 테스트 실행 (FAIL 확인)**
```bash
python -m pytest tests/test_mlx_filter.py::test_fit_with_nan_features -v
```
Expected: FAIL (현재 nan이 그대로 들어가 loss=nan 발생)
**Step 3: `mlx_filter.py` fit() 정규화 수정**
`src/mlx_filter.py` 140~145줄을 아래로 교체:
```python
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 = 평균값, 신경망에 줄 수 있는 가장 무난한 결측 대치값)
self._mean = np.nanmean(X_np, axis=0)
self._std = np.nanstd(X_np, axis=0) + 1e-8
X_np = (X_np - self._mean) / self._std
X_np = np.nan_to_num(X_np, nan=0.0)
```
**Step 4: `predict_proba`도 nan_to_num 적용**
`src/mlx_filter.py` 185~189줄:
```python
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_np = np.nan_to_num(X_np, nan=0.0)
```
**Step 5: 테스트 재실행**
```bash
python -m pytest tests/test_mlx_filter.py -v
```
Expected: 모든 테스트 PASS
**Step 6: 커밋**
```bash
git add src/mlx_filter.py tests/test_mlx_filter.py
git commit -m "fix: MLXFilter fit/predict에 nan-safe 정규화 적용 (nanmean + nan_to_num)"
```
---
### Task 4: `train_model.py` — 정밀도 우선 임계값 탐색
**Files:**
- Modify: `scripts/train_model.py:236-246`
- Test: 없음 (스크립트 레벨 변경, 수동 검증)
**Step 1: `train_model.py` 임계값 탐색 교체**
`scripts/train_model.py` 234~246줄을 아래로 교체:
```python
val_proba = model.predict_proba(X_val)[:, 1]
auc = roc_auc_score(y_val, val_proba)
# 최적 임계값 탐색: 최소 재현율(0.15) 조건부 정밀도 최대화
from sklearn.metrics import precision_recall_curve
precisions, recalls, thresholds = precision_recall_curve(y_val, val_proba)
# precision_recall_curve의 마지막 원소는 (1.0, 0.0)이므로 제외
precisions, recalls = precisions[:-1], recalls[:-1]
MIN_RECALL = 0.15
valid_idx = np.where(recalls >= MIN_RECALL)[0]
if len(valid_idx) > 0:
best_idx = valid_idx[np.argmax(precisions[valid_idx])]
best_thr = float(thresholds[best_idx])
best_prec = float(precisions[best_idx])
best_rec = float(recalls[best_idx])
else:
best_thr, best_prec, best_rec = 0.50, 0.0, 0.0
print(f" [경고] recall >= {MIN_RECALL} 조건 만족 임계값 없음 → 기본값 0.50 사용")
print(f"\n검증 AUC: {auc:.4f} | 최적 임계값: {best_thr:.4f} "
f"(Precision={best_prec:.3f}, Recall={best_rec:.3f})")
print(classification_report(y_val, (val_proba >= best_thr).astype(int), zero_division=0))
```
그리고 로그 저장 부분 (261~271줄)에 임계값 정보 추가:
```python
log.append({
"date": datetime.now().isoformat(),
"backend": "lgbm",
"auc": round(auc, 4),
"best_threshold": round(best_thr, 4),
"best_precision": round(best_prec, 3),
"best_recall": round(best_rec, 3),
"samples": len(dataset),
"features": len(actual_feature_cols),
"time_weight_decay": time_weight_decay,
"model_path": str(MODEL_PATH),
})
```
**Step 2: 수동 검증 (dry-run)**
```bash
python scripts/train_model.py --data data/combined_15m.parquet 2>&1 | tail -30
```
Expected: "최적 임계값: X.XXXX (Precision=X.XXX, Recall=X.XXX)" 형태 출력
**Step 3: 커밋**
```bash
git add scripts/train_model.py
git commit -m "feat: LightGBM 임계값 탐색을 정밀도 우선(recall>=0.15 조건부)으로 변경"
```
---
### Task 5: `train_mlx_model.py` — 동일한 임계값 탐색 적용
**Files:**
- Modify: `scripts/train_mlx_model.py:119-122`
**Step 1: `train_mlx_model.py` 임계값 탐색 교체**
`scripts/train_mlx_model.py` 119~122줄을 아래로 교체:
```python
val_proba = model.predict_proba(X_val)
auc = roc_auc_score(y_val, val_proba)
# 최적 임계값 탐색: 최소 재현율(0.15) 조건부 정밀도 최대화
from sklearn.metrics import precision_recall_curve, classification_report
precisions, recalls, thresholds = precision_recall_curve(y_val, val_proba)
precisions, recalls = precisions[:-1], recalls[:-1]
MIN_RECALL = 0.15
valid_idx = np.where(recalls >= MIN_RECALL)[0]
if len(valid_idx) > 0:
best_idx = valid_idx[np.argmax(precisions[valid_idx])]
best_thr = float(thresholds[best_idx])
best_prec = float(precisions[best_idx])
best_rec = float(recalls[best_idx])
else:
best_thr, best_prec, best_rec = 0.50, 0.0, 0.0
print(f" [경고] recall >= {MIN_RECALL} 조건 만족 임계값 없음 → 기본값 0.50 사용")
print(f"\n검증 AUC: {auc:.4f} | 최적 임계값: {best_thr:.4f} "
f"(Precision={best_prec:.3f}, Recall={best_rec:.3f})")
print(classification_report(y_val, (val_proba >= best_thr).astype(int), zero_division=0))
```
그리고 로그 저장 부분에 임계값 정보 추가:
```python
log.append({
"date": datetime.now().isoformat(),
"backend": "mlx",
"auc": round(auc, 4),
"best_threshold": round(best_thr, 4),
"best_precision": round(best_prec, 3),
"best_recall": round(best_rec, 3),
"samples": len(dataset),
"train_sec": round(t3 - t2, 1),
"time_weight_decay": time_weight_decay,
"model_path": str(MLX_MODEL_PATH),
})
```
**Step 2: 커밋**
```bash
git add scripts/train_mlx_model.py
git commit -m "feat: MLX 임계값 탐색을 정밀도 우선(recall>=0.15 조건부)으로 변경"
```
---
### Task 6: 전체 테스트 통과 확인
**Step 1: 전체 테스트 실행**
```bash
python -m pytest tests/ -v --tb=short 2>&1 | tail -40
```
Expected: 모든 테스트 PASS
**Step 2: 최종 커밋 (필요 시)**
```bash
git add -A
git commit -m "chore: OI nan 마스킹 / epsilon 통일 / 정밀도 우선 임계값 전체 통합"
```

Binary file not shown.

Binary file not shown.

BIN
models/mlx_filter.onnx Normal file

Binary file not shown.

View File

@@ -135,5 +135,86 @@
"features": 21, "features": 21,
"time_weight_decay": 3.0, "time_weight_decay": 3.0,
"model_path": "models/lgbm_filter.pkl" "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"
},
{
"date": "2026-03-01T22:18:59.852831",
"backend": "lgbm",
"auc": 0.5504,
"samples": 533,
"features": 21,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T22:19:29.532472",
"backend": "lgbm",
"auc": 0.5504,
"samples": 533,
"features": 21,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T22:19:30.938005",
"backend": "mlx",
"auc": 0.5714,
"samples": 533,
"train_sec": 0.1,
"time_weight_decay": 2.0,
"model_path": "models/mlx_filter.weights"
},
{
"date": "2026-03-01T22:26:46.459326",
"backend": "mlx",
"auc": 0.6167,
"samples": 533,
"train_sec": 0.2,
"time_weight_decay": 2.0,
"model_path": "models/mlx_filter.weights"
},
{
"date": "2026-03-01T22:45:55.473533",
"backend": "lgbm",
"auc": 0.556,
"samples": 533,
"features": 23,
"time_weight_decay": 2.0,
"model_path": "models/lgbm_filter.pkl"
},
{
"date": "2026-03-01T23:04:51.194544",
"backend": "mlx",
"auc": 0.5972,
"samples": 533,
"train_sec": 0.1,
"time_weight_decay": 2.0,
"model_path": "models/mlx_filter.weights"
} }
] ]

View File

@@ -2,6 +2,11 @@
바이낸스 선물 REST API로 과거 캔들 데이터를 수집해 parquet으로 저장한다. 바이낸스 선물 REST API로 과거 캔들 데이터를 수집해 parquet으로 저장한다.
사용법: python scripts/fetch_history.py --symbol XRPUSDT --interval 1m --days 90 사용법: python scripts/fetch_history.py --symbol XRPUSDT --interval 1m --days 90
python scripts/fetch_history.py --symbols XRPUSDT BTCUSDT ETHUSDT --days 90 python scripts/fetch_history.py --symbols XRPUSDT BTCUSDT ETHUSDT --days 90
OI/펀딩비 수집 제약:
- OI 히스토리: 바이낸스 API 제한으로 최근 30일치만 제공 (period=15m, limit=500/req)
- 펀딩비: 8시간 주기 → 15분봉에 forward-fill 병합
- 30일 이전 구간은 oi_change=0, funding_rate=0으로 채움
""" """
import sys import sys
from pathlib import Path from pathlib import Path
@@ -9,6 +14,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
import asyncio import asyncio
import argparse import argparse
import aiohttp
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import pandas as pd import pandas as pd
from binance import AsyncClient from binance import AsyncClient
@@ -21,6 +27,7 @@ load_dotenv()
# 1500개씩 가져오므로 90일 1m 데이터 = ~65회 요청/심볼 # 1500개씩 가져오므로 90일 1m 데이터 = ~65회 요청/심볼
# 심볼 간 딜레이 없이 연속 요청하면 레이트 리밋(-1003) 발생 # 심볼 간 딜레이 없이 연속 요청하면 레이트 리밋(-1003) 발생
_REQUEST_DELAY = 0.3 # 초당 ~3.3 req → 안전 마진 충분 _REQUEST_DELAY = 0.3 # 초당 ~3.3 req → 안전 마진 충분
_FAPI_BASE = "https://fapi.binance.com"
def _now_ms() -> int: def _now_ms() -> int:
@@ -107,15 +114,164 @@ async def fetch_klines_all(
return dfs return dfs
async def _fetch_oi_hist(
session: aiohttp.ClientSession,
symbol: str,
period: str = "15m",
) -> pd.DataFrame:
"""
바이낸스 /futures/data/openInterestHist 엔드포인트로 OI 히스토리를 수집한다.
API 제한: 최근 30일치만 제공, 1회 최대 500개.
"""
url = f"{_FAPI_BASE}/futures/data/openInterestHist"
all_rows = []
# 30일 전부터 현재까지 수집
start_ts = int((datetime.now(timezone.utc) - timedelta(days=30)).timestamp() * 1000)
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
print(f" [{symbol}] OI 히스토리 수집 중 (최근 30일)...")
while start_ts < now_ms:
params = {
"symbol": symbol,
"period": period,
"limit": 500,
"startTime": start_ts,
}
async with session.get(url, params=params) as resp:
data = await resp.json()
if not data or not isinstance(data, list):
break
all_rows.extend(data)
last_ts = int(data[-1]["timestamp"])
if last_ts >= now_ms or len(data) < 500:
break
start_ts = last_ts + 1
await asyncio.sleep(_REQUEST_DELAY)
if not all_rows:
print(f" [{symbol}] OI 데이터 없음 — 빈 DataFrame 반환")
return pd.DataFrame(columns=["oi", "oi_value"])
df = pd.DataFrame(all_rows)
df["timestamp"] = pd.to_datetime(df["timestamp"].astype(int), unit="ms", utc=True)
df = df.set_index("timestamp")
df = df[["sumOpenInterest", "sumOpenInterestValue"]].copy()
df.columns = ["oi", "oi_value"]
df["oi"] = df["oi"].astype(float)
df["oi_value"] = df["oi_value"].astype(float)
# OI 변화율 (1캔들 전 대비)
df["oi_change"] = df["oi"].pct_change(1).fillna(0)
print(f" [{symbol}] OI 수집 완료: {len(df):,}")
return df[["oi_change"]]
async def _fetch_funding_rate(
session: aiohttp.ClientSession,
symbol: str,
days: int,
) -> pd.DataFrame:
"""
바이낸스 /fapi/v1/fundingRate 엔드포인트로 펀딩비 히스토리를 수집한다.
8시간 주기 데이터 → 15분봉 인덱스에 forward-fill로 병합 예정.
"""
url = f"{_FAPI_BASE}/fapi/v1/fundingRate"
all_rows = []
start_ts = int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp() * 1000)
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
print(f" [{symbol}] 펀딩비 히스토리 수집 중 ({days}일)...")
while start_ts < now_ms:
params = {
"symbol": symbol,
"startTime": start_ts,
"limit": 1000,
}
async with session.get(url, params=params) as resp:
data = await resp.json()
if not data or not isinstance(data, list):
break
all_rows.extend(data)
last_ts = int(data[-1]["fundingTime"])
if last_ts >= now_ms or len(data) < 1000:
break
start_ts = last_ts + 1
await asyncio.sleep(_REQUEST_DELAY)
if not all_rows:
print(f" [{symbol}] 펀딩비 데이터 없음 — 빈 DataFrame 반환")
return pd.DataFrame(columns=["funding_rate"])
df = pd.DataFrame(all_rows)
df["timestamp"] = pd.to_datetime(df["fundingTime"].astype(int), unit="ms", utc=True)
df = df.set_index("timestamp")
df["funding_rate"] = df["fundingRate"].astype(float)
print(f" [{symbol}] 펀딩비 수집 완료: {len(df):,}")
return df[["funding_rate"]]
def _merge_oi_funding(
candles: pd.DataFrame,
oi_df: pd.DataFrame,
funding_df: pd.DataFrame,
) -> pd.DataFrame:
"""
캔들 DataFrame에 OI 변화율과 펀딩비를 병합한다.
- oi_change: 15분봉 인덱스에 nearest merge (없는 구간은 0)
- funding_rate: 8시간 주기 → forward-fill 후 병합 (없는 구간은 0)
"""
result = candles.copy()
# OI 병합: 타임스탬프 기준 reindex + nearest fill
if not oi_df.empty:
oi_reindexed = oi_df.reindex(result.index, method="nearest", tolerance=pd.Timedelta("8min"))
result["oi_change"] = oi_reindexed["oi_change"].fillna(0).astype(float)
else:
result["oi_change"] = 0.0
# 펀딩비 병합: forward-fill (8시간 주기이므로 다음 펀딩 시점까지 이전 값 유지)
if not funding_df.empty:
funding_reindexed = funding_df.reindex(
result.index.union(funding_df.index)
).sort_index()
funding_reindexed = funding_reindexed["funding_rate"].ffill()
result["funding_rate"] = funding_reindexed.reindex(result.index).fillna(0).astype(float)
else:
result["funding_rate"] = 0.0
return result
async def _fetch_oi_and_funding(
symbol: str,
days: int,
candles: pd.DataFrame,
) -> pd.DataFrame:
"""단일 심볼의 OI + 펀딩비를 수집해 캔들에 병합한다."""
async with aiohttp.ClientSession() as session:
oi_df = await _fetch_oi_hist(session, symbol)
await asyncio.sleep(1)
funding_df = await _fetch_funding_rate(session, symbol, days)
return _merge_oi_funding(candles, oi_df, funding_df)
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="바이낸스 선물 과거 캔들 수집. 단일 심볼 또는 멀티 심볼 병합 저장." description="바이낸스 선물 과거 캔들 수집. 단일 심볼 또는 멀티 심볼 병합 저장."
) )
parser.add_argument("--symbols", nargs="+", default=["XRPUSDT"]) parser.add_argument("--symbols", nargs="+", default=["XRPUSDT"])
parser.add_argument("--symbol", default=None, help="단일 심볼 (--symbols 미사용 시)") parser.add_argument("--symbol", default=None, help="단일 심볼 (--symbols 미사용 시)")
parser.add_argument("--interval", default="1m") parser.add_argument("--interval", default="15m")
parser.add_argument("--days", type=int, default=90) parser.add_argument("--days", type=int, default=365)
parser.add_argument("--output", default="data/xrpusdt_1m.parquet") parser.add_argument("--output", default="data/combined_15m.parquet")
parser.add_argument(
"--no-oi", action="store_true",
help="OI/펀딩비 수집을 건너뜀 (캔들 데이터만 저장)",
)
args = parser.parse_args() args = parser.parse_args()
# 하위 호환: --symbol 단독 사용 시 symbols로 통합 # 하위 호환: --symbol 단독 사용 시 symbols로 통합
@@ -124,8 +280,11 @@ def main():
if len(args.symbols) == 1: if len(args.symbols) == 1:
df = asyncio.run(fetch_klines(args.symbols[0], args.interval, args.days)) df = asyncio.run(fetch_klines(args.symbols[0], args.interval, args.days))
if not args.no_oi:
print(f"\n[OI/펀딩비] {args.symbols[0]} 수집 중...")
df = asyncio.run(_fetch_oi_and_funding(args.symbols[0], args.days, df))
df.to_parquet(args.output) df.to_parquet(args.output)
print(f"저장 완료: {args.output} ({len(df):,}행)") print(f"저장 완료: {args.output} ({len(df):,}, {len(df.columns)}컬럼)")
else: else:
# 멀티 심볼: 단일 클라이언트로 순차 수집 후 타임스탬프 기준 inner join 병합 # 멀티 심볼: 단일 클라이언트로 순차 수집 후 타임스탬프 기준 inner join 병합
dfs = asyncio.run(fetch_klines_all(args.symbols, args.interval, args.days)) dfs = asyncio.run(fetch_klines_all(args.symbols, args.interval, args.days))
@@ -139,6 +298,11 @@ def main():
how="inner", how="inner",
) )
# 주 심볼(XRP)에 대해서만 OI/펀딩비 수집 후 병합
if not args.no_oi:
print(f"\n[OI/펀딩비] {primary} 수집 중...")
merged = asyncio.run(_fetch_oi_and_funding(primary, args.days, merged))
output = args.output.replace("xrpusdt", "combined") output = args.output.replace("xrpusdt", "combined")
merged.to_parquet(output) merged.to_parquet(output)
print(f"\n병합 저장 완료: {output} ({len(merged):,}행, {len(merged.columns)}컬럼)") print(f"\n병합 저장 완료: {output} ({len(merged):,}행, {len(merged.columns)}컬럼)")

View File

@@ -1,10 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 맥미니에서 전체 학습 파이프라인을 실행하고 LXC로 배포한다. # 맥미니에서 전체 학습 파이프라인을 실행하고 LXC로 배포한다.
# 사용법: bash scripts/train_and_deploy.sh [mlx|lgbm] # 사용법: bash scripts/train_and_deploy.sh [mlx|lgbm] [wf-splits]
# #
# 예시: # 예시:
# bash scripts/train_and_deploy.sh # LightGBM (기본값) # bash scripts/train_and_deploy.sh # LightGBM + Walk-Forward 5폴드 (기본값)
# bash scripts/train_and_deploy.sh mlx # MLX GPU 학습 # bash scripts/train_and_deploy.sh mlx # MLX GPU 학습 + Walk-Forward 5폴드
# bash scripts/train_and_deploy.sh lgbm 3 # LightGBM + Walk-Forward 3폴드
# bash scripts/train_and_deploy.sh mlx 0 # MLX 학습만 (Walk-Forward 건너뜀)
# bash scripts/train_and_deploy.sh lgbm 0 # LightGBM 학습만 (Walk-Forward 건너뜀)
set -euo pipefail set -euo pipefail
@@ -20,26 +23,45 @@ else
fi fi
BACKEND="${1:-lgbm}" BACKEND="${1:-lgbm}"
WF_SPLITS="${2:-5}" # 두 번째 인자: Walk-Forward 폴드 수 (0이면 건너뜀)
cd "$PROJECT_ROOT" cd "$PROJECT_ROOT"
echo "=== [1/3] 데이터 수집 (XRP + BTC + ETH 3심볼, 1년치) ===" echo "=== [1/3] 데이터 수집 (XRP + BTC + ETH 3심볼, 1년치 + OI/펀딩비) ==="
python scripts/fetch_history.py \ python scripts/fetch_history.py \
--symbols XRPUSDT BTCUSDT ETHUSDT \ --symbols XRPUSDT BTCUSDT ETHUSDT \
--interval 1m \ --interval 15m \
--days 365 \ --days 365 \
--output data/xrpusdt_1m.parquet --output data/combined_15m.parquet
# 결과: data/combined_1m.parquet (타임스탬프 기준 병합)
echo "" echo ""
echo "=== [2/3] 모델 학습 (21개 피처: XRP 13 + BTC/ETH 상관관계 8) ===" echo "=== [2/3] 모델 학습 (23개 피처: XRP 13 + BTC/ETH 8 + OI/펀딩비 2) ==="
DECAY="${TIME_WEIGHT_DECAY:-2.0}" DECAY="${TIME_WEIGHT_DECAY:-2.0}"
if [ "$BACKEND" = "mlx" ]; then if [ "$BACKEND" = "mlx" ]; then
echo " 백엔드: MLX (Apple Silicon GPU), decay=${DECAY}" 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 else
echo " 백엔드: LightGBM (CPU), decay=${DECAY}" 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
# Walk-Forward 검증 (WF_SPLITS > 0 인 경우)
if [ "$WF_SPLITS" -gt 0 ] 2>/dev/null; then
echo ""
echo "=== [2.5/3] Walk-Forward 검증 (${WF_SPLITS}폴드) ==="
if [ "$BACKEND" = "mlx" ]; then
python scripts/train_mlx_model.py \
--data data/combined_15m.parquet \
--decay "$DECAY" \
--wf \
--wf-splits "$WF_SPLITS"
else
python scripts/train_model.py \
--data data/combined_15m.parquet \
--decay "$DECAY" \
--wf \
--wf-splits "$WF_SPLITS"
fi
fi fi
echo "" echo ""

View File

@@ -118,8 +118,26 @@ def train_mlx(data_path: str, time_weight_decay: float = 2.0) -> float:
val_proba = model.predict_proba(X_val) val_proba = model.predict_proba(X_val)
auc = roc_auc_score(y_val, val_proba) auc = roc_auc_score(y_val, val_proba)
print(f"\n검증 AUC: {auc:.4f}")
print(classification_report(y_val, (val_proba >= 0.60).astype(int))) # 최적 임계값 탐색: 최소 재현율(0.15) 조건부 정밀도 최대화
from sklearn.metrics import precision_recall_curve
precisions, recalls, thresholds = precision_recall_curve(y_val, val_proba)
precisions, recalls = precisions[:-1], recalls[:-1]
MIN_RECALL = 0.15
valid_idx = np.where(recalls >= MIN_RECALL)[0]
if len(valid_idx) > 0:
best_idx = valid_idx[np.argmax(precisions[valid_idx])]
best_thr = float(thresholds[best_idx])
best_prec = float(precisions[best_idx])
best_rec = float(recalls[best_idx])
else:
best_thr, best_prec, best_rec = 0.50, 0.0, 0.0
print(f" [경고] recall >= {MIN_RECALL} 조건 만족 임계값 없음 → 기본값 0.50 사용")
print(f"\n검증 AUC: {auc:.4f} | 최적 임계값: {best_thr:.4f} "
f"(Precision={best_prec:.3f}, Recall={best_rec:.3f})")
print(classification_report(y_val, (val_proba >= best_thr).astype(int), zero_division=0))
MLX_MODEL_PATH.parent.mkdir(exist_ok=True) MLX_MODEL_PATH.parent.mkdir(exist_ok=True)
model.save(MLX_MODEL_PATH) model.save(MLX_MODEL_PATH)
@@ -133,6 +151,9 @@ def train_mlx(data_path: str, time_weight_decay: float = 2.0) -> float:
"date": datetime.now().isoformat(), "date": datetime.now().isoformat(),
"backend": "mlx", "backend": "mlx",
"auc": round(auc, 4), "auc": round(auc, 4),
"best_threshold": round(best_thr, 4),
"best_precision": round(best_prec, 3),
"best_recall": round(best_rec, 3),
"samples": len(dataset), "samples": len(dataset),
"train_sec": round(t3 - t2, 1), "train_sec": round(t3 - t2, 1),
"time_weight_decay": time_weight_decay, "time_weight_decay": time_weight_decay,
@@ -144,15 +165,107 @@ def train_mlx(data_path: str, time_weight_decay: float = 2.0) -> float:
return auc return auc
def walk_forward_auc(
data_path: str,
time_weight_decay: float = 2.0,
n_splits: int = 5,
train_ratio: float = 0.6,
) -> None:
"""Walk-Forward 검증: 슬라이딩 윈도우로 n_splits번 학습/검증 반복."""
print(f"\n=== Walk-Forward 검증 ({n_splits}폴드, decay={time_weight_decay}) ===")
raw = pd.read_parquet(data_path)
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
)
missing = [c for c in FEATURE_COLS if c not in dataset.columns]
for col in missing:
dataset[col] = 0.0
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)
n = len(dataset)
step = max(1, int(n * (1 - train_ratio) / n_splits))
train_end_start = int(n * train_ratio)
aucs = []
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_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]
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]))
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)
model = MLXFilter(
input_dim=len(FEATURE_COLS),
hidden_dim=128,
lr=1e-3,
epochs=100,
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}"
)
print(f"\n Walk-Forward 평균 AUC: {np.mean(aucs):.4f} ± {np.std(aucs):.4f}")
print(f" 폴드별: {[round(a, 4) for a in aucs]}")
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--data", default="data/combined_1m.parquet") parser.add_argument("--data", default="data/combined_15m.parquet")
parser.add_argument( parser.add_argument(
"--decay", type=float, default=2.0, "--decay", type=float, default=2.0,
help="시간 가중치 감쇠 강도 (0=균등, 2.0=최신이 ~7.4배 높음)", help="시간 가중치 감쇠 강도 (0=균등, 2.0=최신이 ~7.4배 높음)",
) )
parser.add_argument("--wf", action="store_true", help="Walk-Forward 검증 실행")
parser.add_argument("--wf-splits", type=int, default=5, help="Walk-Forward 폴드 수")
args = parser.parse_args() args = parser.parse_args()
train_mlx(args.data, time_weight_decay=args.decay)
if args.wf:
walk_forward_auc(args.data, time_weight_decay=args.decay, n_splits=args.wf_splits)
else:
train_mlx(args.data, time_weight_decay=args.decay)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -53,7 +53,7 @@ def _cgroup_cpu_count() -> int:
return cpu_count() return cpu_count()
LOOKAHEAD = 60 LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 (dataset_builder.py와 동기화)
ATR_SL_MULT = 1.5 ATR_SL_MULT = 1.5
ATR_TP_MULT = 3.0 ATR_TP_MULT = 3.0
MODEL_PATH = Path("models/lgbm_filter.pkl") MODEL_PATH = Path("models/lgbm_filter.pkl")
@@ -233,16 +233,26 @@ def train(data_path: str, time_weight_decay: float = 2.0):
val_proba = model.predict_proba(X_val)[:, 1] val_proba = model.predict_proba(X_val)[:, 1]
auc = roc_auc_score(y_val, val_proba) auc = roc_auc_score(y_val, val_proba)
# 최적 임계값 탐색 (F1 기준)
thresholds = np.arange(0.40, 0.70, 0.05) # 최적 임계값 탐색: 최소 재현율(0.15) 조건부 정밀도 최대화
best_thr, best_f1 = 0.50, 0.0 from sklearn.metrics import precision_recall_curve
for thr in thresholds: precisions, recalls, thresholds = precision_recall_curve(y_val, val_proba)
pred = (val_proba >= thr).astype(int) # precision_recall_curve의 마지막 원소는 (1.0, 0.0)이므로 제외
from sklearn.metrics import f1_score precisions, recalls = precisions[:-1], recalls[:-1]
f1 = f1_score(y_val, pred, zero_division=0)
if f1 > best_f1: MIN_RECALL = 0.15
best_f1, best_thr = f1, thr valid_idx = np.where(recalls >= MIN_RECALL)[0]
print(f"\n검증 AUC: {auc:.4f} | 최적 임계값: {best_thr:.2f} (F1={best_f1:.3f})") if len(valid_idx) > 0:
best_idx = valid_idx[np.argmax(precisions[valid_idx])]
best_thr = float(thresholds[best_idx])
best_prec = float(precisions[best_idx])
best_rec = float(recalls[best_idx])
else:
best_thr, best_prec, best_rec = 0.50, 0.0, 0.0
print(f" [경고] recall >= {MIN_RECALL} 조건 만족 임계값 없음 → 기본값 0.50 사용")
print(f"\n검증 AUC: {auc:.4f} | 최적 임계값: {best_thr:.4f} "
f"(Precision={best_prec:.3f}, Recall={best_rec:.3f})")
print(classification_report(y_val, (val_proba >= best_thr).astype(int), zero_division=0)) print(classification_report(y_val, (val_proba >= best_thr).astype(int), zero_division=0))
if MODEL_PATH.exists(): if MODEL_PATH.exists():
@@ -262,6 +272,9 @@ def train(data_path: str, time_weight_decay: float = 2.0):
"date": datetime.now().isoformat(), "date": datetime.now().isoformat(),
"backend": "lgbm", "backend": "lgbm",
"auc": round(auc, 4), "auc": round(auc, 4),
"best_threshold": round(best_thr, 4),
"best_precision": round(best_prec, 3),
"best_recall": round(best_rec, 3),
"samples": len(dataset), "samples": len(dataset),
"features": len(actual_feature_cols), "features": len(actual_feature_cols),
"time_weight_decay": time_weight_decay, "time_weight_decay": time_weight_decay,
@@ -357,7 +370,7 @@ def walk_forward_auc(
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--data", default="data/combined_1m.parquet") parser.add_argument("--data", default="data/combined_15m.parquet")
parser.add_argument( parser.add_argument(
"--decay", type=float, default=2.0, "--decay", type=float, default=2.0,
help="시간 가중치 감쇠 강도 (0=균등, 2.0=최신이 ~7.4배 높음)", help="시간 가중치 감쇠 강도 (0=균등, 2.0=최신이 ~7.4배 높음)",

View File

@@ -20,7 +20,7 @@ class TradingBot:
self.current_trade_side: str | None = None # "LONG" | "SHORT" self.current_trade_side: str | None = None # "LONG" | "SHORT"
self.stream = MultiSymbolStream( self.stream = MultiSymbolStream(
symbols=[config.symbol, "BTCUSDT", "ETHUSDT"], symbols=[config.symbol, "BTCUSDT", "ETHUSDT"],
interval="1m", interval="15m",
on_candle=self._on_candle_closed, on_candle=self._on_candle_closed,
) )

View File

@@ -5,13 +5,21 @@ import pandas as pd
from binance import AsyncClient, BinanceSocketManager from binance import AsyncClient, BinanceSocketManager
from loguru import logger 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: class KlineStream:
def __init__( def __init__(
self, self,
symbol: str, symbol: str,
interval: str = "1m", interval: str = "15m",
buffer_size: int = 200, buffer_size: int = 200,
on_candle: Callable = None, on_candle: Callable = None,
): ):
@@ -40,13 +48,13 @@ class KlineStream:
self.on_candle(candle) self.on_candle(candle)
def get_dataframe(self) -> pd.DataFrame | None: def get_dataframe(self) -> pd.DataFrame | None:
if len(self.buffer) < 50: if len(self.buffer) < _MIN_CANDLES_FOR_SIGNAL:
return None return None
df = pd.DataFrame(list(self.buffer)) df = pd.DataFrame(list(self.buffer))
df.set_index("timestamp", inplace=True) df.set_index("timestamp", inplace=True)
return df 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로 과거 캔들 데이터를 버퍼에 미리 채운다.""" """REST API로 과거 캔들 데이터를 버퍼에 미리 채운다."""
logger.info(f"과거 캔들 {limit}개 로드 중...") logger.info(f"과거 캔들 {limit}개 로드 중...")
klines = await client.futures_klines( klines = await client.futures_klines(
@@ -96,7 +104,7 @@ class MultiSymbolStream:
def __init__( def __init__(
self, self,
symbols: list[str], symbols: list[str],
interval: str = "1m", interval: str = "15m",
buffer_size: int = 200, buffer_size: int = 200,
on_candle: Callable = None, on_candle: Callable = None,
): ):
@@ -142,13 +150,13 @@ class MultiSymbolStream:
def get_dataframe(self, symbol: str) -> pd.DataFrame | None: def get_dataframe(self, symbol: str) -> pd.DataFrame | None:
key = symbol.lower() key = symbol.lower()
buf = self.buffers.get(key) 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 return None
df = pd.DataFrame(list(buf)) df = pd.DataFrame(list(buf))
df.set_index("timestamp", inplace=True) df.set_index("timestamp", inplace=True)
return df 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로 모든 심볼의 과거 캔들을 버퍼에 미리 채운다.""" """REST API로 모든 심볼의 과거 캔들을 버퍼에 미리 채운다."""
for symbol in self.symbols: for symbol in self.symbols:
logger.info(f"{symbol.upper()} 과거 캔들 {limit}개 로드 중...") logger.info(f"{symbol.upper()} 과거 캔들 {limit}개 로드 중...")

View File

@@ -11,10 +11,10 @@ import pandas_ta as ta
from src.ml_features import FEATURE_COLS from src.ml_features import FEATURE_COLS
LOOKAHEAD = 90 LOOKAHEAD = 24 # 15분봉 × 24 = 6시간 뷰
ATR_SL_MULT = 1.5 ATR_SL_MULT = 1.5
ATR_TP_MULT = 2.0 ATR_TP_MULT = 2.0
WARMUP = 60 # 지표 안정화에 필요한 최소 행 수 WARMUP = 60 # 15분봉 기준 60캔들 = 15시간 (지표 안정화 충분)
def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame: def _calc_indicators(df: pd.DataFrame) -> pd.DataFrame:
@@ -115,14 +115,16 @@ def _calc_signals(d: pd.DataFrame) -> np.ndarray:
return signal_arr return signal_arr
def _rolling_zscore(arr: np.ndarray, window: int = 200) -> np.ndarray: def _rolling_zscore(arr: np.ndarray, window: int = 288) -> np.ndarray:
"""rolling window z-score 정규화. window 미만 구간은 0으로 채운다. """rolling window z-score 정규화. nan은 전파된다(nan-safe).
절대값 피처(수익률, ATR 등)를 레짐 변화에 무관하게 만든다.""" 15분봉 기준 3일(288캔들) 윈도우. min_periods=1로 초반 데이터도 활용."""
s = pd.Series(arr.astype(np.float64)) s = pd.Series(arr.astype(np.float64))
mean = s.rolling(window, min_periods=window).mean() r = s.rolling(window=window, min_periods=1)
std = s.rolling(window, min_periods=window).std() mean = r.mean() # pandas rolling은 nan을 자동으로 건너뜀
z = (s - mean) / std.where(std > 0, other=np.nan) std = r.std(ddof=0)
return z.fillna(0).values.astype(np.float32) std = std.where(std >= 1e-8, other=1e-8)
z = (s - mean) / std
return z.values.astype(np.float32)
def _calc_features_vectorized( def _calc_features_vectorized(
@@ -152,7 +154,7 @@ def _calc_features_vectorized(
macd_sig = d["macd_signal"] macd_sig = d["macd_signal"]
bb_range = bb_upper - bb_lower bb_range = bb_upper - bb_lower
bb_pct = np.where(bb_range > 0, (close - bb_lower) / bb_range, 0.5) bb_pct = (close - bb_lower) / (bb_range + 1e-8)
ema_align = np.where( ema_align = np.where(
(ema9 > ema21) & (ema21 > ema50), 1, (ema9 > ema21) & (ema21 > ema50), 1,
@@ -161,8 +163,8 @@ def _calc_features_vectorized(
) )
).astype(np.float32) ).astype(np.float32)
atr_pct = np.where(close > 0, atr / close, 0.0) atr_pct = atr / (close + 1e-8)
vol_ratio = np.where(vol_ma20 > 0, volume / vol_ma20, 1.0) vol_ratio = volume / (vol_ma20 + 1e-8)
ret_1 = close.pct_change(1).fillna(0).values ret_1 = close.pct_change(1).fillna(0).values
ret_3 = close.pct_change(3).fillna(0).values ret_3 = close.pct_change(3).fillna(0).values
@@ -240,8 +242,8 @@ def _calc_features_vectorized(
eth_r5 = _align(eth_ret_5, n).astype(np.float32) eth_r5 = _align(eth_ret_5, n).astype(np.float32)
xrp_r1 = ret_1.astype(np.float32) xrp_r1 = ret_1.astype(np.float32)
xrp_btc_rs_raw = np.where(btc_r1 != 0, xrp_r1 / btc_r1, 0.0).astype(np.float32) xrp_btc_rs_raw = (xrp_r1 / (btc_r1 + 1e-8)).astype(np.float32)
xrp_eth_rs_raw = np.where(eth_r1 != 0, xrp_r1 / eth_r1, 0.0).astype(np.float32) xrp_eth_rs_raw = (xrp_r1 / (eth_r1 + 1e-8)).astype(np.float32)
extra = pd.DataFrame({ extra = pd.DataFrame({
"btc_ret_1": _rolling_zscore(btc_r1), "btc_ret_1": _rolling_zscore(btc_r1),
@@ -255,6 +257,22 @@ def _calc_features_vectorized(
}, index=d.index) }, index=d.index)
result = pd.concat([result, extra], axis=1) result = pd.concat([result, extra], axis=1)
# OI 변화율 / 펀딩비 피처
# 컬럼 없으면 전체 nan, 있으면 0.0 구간(데이터 미제공 구간)을 nan으로 마스킹
# LightGBM은 nan을 자체 처리; MLX는 fit()에서 nanmean/nanstd + nan_to_num 처리
if "oi_change" in d.columns:
oi_raw = np.where(d["oi_change"].values == 0.0, np.nan, d["oi_change"].values)
else:
oi_raw = np.full(len(d), np.nan)
if "funding_rate" in d.columns:
fr_raw = np.where(d["funding_rate"].values == 0.0, np.nan, d["funding_rate"].values)
else:
fr_raw = np.full(len(d), np.nan)
result["oi_change"] = _rolling_zscore(oi_raw.astype(np.float64))
result["funding_rate"] = _rolling_zscore(fr_raw.astype(np.float64))
return result return result
@@ -345,7 +363,12 @@ def generate_dataset_vectorized(
feat_all = _calc_features_vectorized(d, signal_arr, btc_df=btc_df, eth_df=eth_df) feat_all = _calc_features_vectorized(d, signal_arr, btc_df=btc_df, eth_df=eth_df)
# 신호 발생 + NaN 없음 + 미래 데이터 충분한 인덱스만 # 신호 발생 + NaN 없음 + 미래 데이터 충분한 인덱스만
available_cols_for_nan_check = [c for c in FEATURE_COLS if c in feat_all.columns] # oi_change/funding_rate는 선택적 피처(컬럼 없으면 전체 nan)이므로 NaN 체크에서 제외
OPTIONAL_COLS = {"oi_change", "funding_rate"}
available_cols_for_nan_check = [
c for c in FEATURE_COLS
if c in feat_all.columns and c not in OPTIONAL_COLS
]
valid_rows = ( valid_rows = (
(signal_arr != "HOLD") & (signal_arr != "HOLD") &
(~feat_all[available_cols_for_nan_check].isna().any(axis=1).values) & (~feat_all[available_cols_for_nan_check].isna().any(axis=1).values) &

View File

@@ -8,6 +8,9 @@ FEATURE_COLS = [
"btc_ret_1", "btc_ret_3", "btc_ret_5", "btc_ret_1", "btc_ret_3", "btc_ret_5",
"eth_ret_1", "eth_ret_3", "eth_ret_5", "eth_ret_1", "eth_ret_3", "eth_ret_5",
"xrp_btc_rs", "xrp_eth_rs", "xrp_btc_rs", "xrp_eth_rs",
# 시장 미시구조: OI 변화율(z-score), 펀딩비(z-score)
# parquet에 oi_change/funding_rate 컬럼이 없으면 dataset_builder에서 0으로 채움
"oi_change", "funding_rate",
] ]

View File

@@ -44,6 +44,11 @@ class MLFilter:
self._try_load() self._try_load()
def _try_load(self): def _try_load(self):
# 로드 여부와 무관하게 두 파일의 현재 mtime을 항상 기록한다.
# 이렇게 해야 로드하지 않은 쪽 파일이 나중에 변경됐을 때만 리로드가 트리거된다.
self._loaded_onnx_mtime = _mtime(self._onnx_path)
self._loaded_lgbm_mtime = _mtime(self._lgbm_path)
# ONNX 우선 시도 # ONNX 우선 시도
if self._onnx_path.exists(): if self._onnx_path.exists():
try: try:
@@ -53,8 +58,6 @@ class MLFilter:
providers=["CPUExecutionProvider"], providers=["CPUExecutionProvider"],
) )
self._lgbm_model = None self._lgbm_model = None
self._loaded_onnx_mtime = _mtime(self._onnx_path)
self._loaded_lgbm_mtime = 0.0
logger.info( logger.info(
f"ML 필터 로드: ONNX ({self._onnx_path}) " f"ML 필터 로드: ONNX ({self._onnx_path}) "
f"| 임계값={self._threshold}" f"| 임계값={self._threshold}"
@@ -68,8 +71,6 @@ class MLFilter:
if self._lgbm_path.exists(): if self._lgbm_path.exists():
try: try:
self._lgbm_model = joblib.load(self._lgbm_path) self._lgbm_model = joblib.load(self._lgbm_path)
self._loaded_lgbm_mtime = _mtime(self._lgbm_path)
self._loaded_onnx_mtime = 0.0
logger.info( logger.info(
f"ML 필터 로드: LightGBM ({self._lgbm_path}) " f"ML 필터 로드: LightGBM ({self._lgbm_path}) "
f"| 임계값={self._threshold}" f"| 임계값={self._threshold}"

View File

@@ -140,9 +140,12 @@ class MLXFilter:
X_np = X[FEATURE_COLS].values.astype(np.float32) X_np = X[FEATURE_COLS].values.astype(np.float32)
y_np = y.values.astype(np.float32) y_np = y.values.astype(np.float32)
self._mean = X_np.mean(axis=0) # nan-safe 정규화: nanmean/nanstd로 통계 계산 후 nan → 0.0 대치
self._std = X_np.std(axis=0) + 1e-8 # (z-score 후 0.0 = 평균값, 신경망에 줄 수 있는 가장 무난한 결측 대치값)
self._mean = np.nanmean(X_np, axis=0)
self._std = np.nanstd(X_np, axis=0) + 1e-8
X_np = (X_np - self._mean) / self._std X_np = (X_np - self._mean) / self._std
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 w_np = sample_weight.astype(np.float32) if sample_weight is not None else None
@@ -186,6 +189,7 @@ class MLXFilter:
X_np = X[FEATURE_COLS].values.astype(np.float32) X_np = X[FEATURE_COLS].values.astype(np.float32)
if self._trained and self._mean is not None: if self._trained and self._mean is not None:
X_np = (X_np - self._mean) / self._std X_np = (X_np - self._mean) / self._std
X_np = np.nan_to_num(X_np, nan=0.0)
x = mx.array(X_np) x = mx.array(X_np)
self._model.eval() self._model.eval()
logits = self._model(x) logits = self._model(x)

View File

@@ -91,3 +91,72 @@ def test_matches_original_generate_dataset(sample_df):
assert 0.5 <= ratio <= 2.0, ( assert 0.5 <= ratio <= 2.0, (
f"샘플 수 차이가 너무 큼: 벡터화={len(vec)}, 기존={len(orig)}, 비율={ratio:.2f}" f"샘플 수 차이가 너무 큼: 벡터화={len(vec)}, 기존={len(orig)}, 비율={ratio:.2f}"
) )
def test_epsilon_no_division_by_zero():
"""bb_range=0, close=0, vol_ma20=0 극단값에서 nan/inf가 발생하지 않아야 한다."""
import numpy as np
import pandas as pd
from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators
n = 100
# close를 모두 같은 값으로 → bb_range=0 유발
df = pd.DataFrame({
"open": np.ones(n),
"high": np.ones(n),
"low": np.ones(n),
"close": np.ones(n),
"volume": np.ones(n),
})
d = _calc_indicators(df)
sig = _calc_signals(d)
feat = _calc_features_vectorized(d, sig)
numeric_cols = feat.select_dtypes(include=[np.number]).columns
assert not feat[numeric_cols].isin([np.inf, -np.inf]).any().any(), \
"inf 값이 있으면 안 됨"
def test_oi_nan_masking_no_column():
"""oi_change 컬럼이 없으면 전체가 nan이어야 한다."""
import numpy as np
import pandas as pd
from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators
n = 100
np.random.seed(0)
df = pd.DataFrame({
"open": np.random.uniform(1, 2, n),
"high": np.random.uniform(2, 3, n),
"low": np.random.uniform(0.5, 1, n),
"close": np.random.uniform(1, 2, n),
"volume": np.random.uniform(1000, 5000, n),
})
d = _calc_indicators(df)
sig = _calc_signals(d)
feat = _calc_features_vectorized(d, sig)
assert feat["oi_change"].isna().all(), "oi_change 컬럼 없을 때 전부 nan이어야 함"
def test_oi_nan_masking_with_zeros():
"""oi_change 컬럼이 있어도 0.0 구간은 nan으로 마스킹되어야 한다."""
import numpy as np
import pandas as pd
from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators
n = 100
np.random.seed(0)
df = pd.DataFrame({
"open": np.random.uniform(1, 2, n),
"high": np.random.uniform(2, 3, n),
"low": np.random.uniform(0.5, 1, n),
"close": np.random.uniform(1, 2, n),
"volume": np.random.uniform(1000, 5000, n),
"oi_change": np.concatenate([np.zeros(50), np.random.uniform(-0.1, 0.1, 50)]),
})
d = _calc_indicators(df)
sig = _calc_signals(d)
feat = _calc_features_vectorized(d, sig)
assert feat["oi_change"].iloc[50:].notna().any(), "실제 OI 값 구간에 유한값이 있어야 함"

View File

@@ -65,6 +65,31 @@ def test_mlx_filter_fit_and_predict():
assert np.all((proba >= 0.0) & (proba <= 1.0)) assert np.all((proba >= 0.0) & (proba <= 1.0))
def test_fit_with_nan_features():
"""oi_change 피처에 nan이 포함된 경우 학습이 정상 완료되어야 한다."""
import numpy as np
import pandas as pd
from src.mlx_filter import MLXFilter
from src.ml_features import FEATURE_COLS
n = 300
np.random.seed(42)
X = pd.DataFrame(
np.random.randn(n, len(FEATURE_COLS)).astype(np.float32),
columns=FEATURE_COLS,
)
# oi_change 앞 절반을 nan으로
X["oi_change"] = np.where(np.arange(n) < n // 2, np.nan, X["oi_change"])
y = pd.Series((np.random.rand(n) > 0.5).astype(np.float32))
model = MLXFilter(input_dim=len(FEATURE_COLS), hidden_dim=32, epochs=3)
model.fit(X, y) # nan 있어도 예외 없이 완료되어야 함
proba = model.predict_proba(X)
assert not np.any(np.isnan(proba)), "예측 확률에 nan이 없어야 함"
assert proba.min() >= 0.0 and proba.max() <= 1.0
def test_mlx_filter_save_load(tmp_path): def test_mlx_filter_save_load(tmp_path):
"""저장 후 로드한 모델이 동일한 예측값을 반환해야 한다.""" """저장 후 로드한 모델이 동일한 예측값을 반환해야 한다."""
from src.mlx_filter import MLXFilter from src.mlx_filter import MLXFilter