diff --git a/src/dataset_builder.py b/src/dataset_builder.py index 20af5dd..7ca8085 100644 --- a/src/dataset_builder.py +++ b/src/dataset_builder.py @@ -116,12 +116,11 @@ def _calc_signals(d: pd.DataFrame) -> np.ndarray: def _rolling_zscore(arr: np.ndarray, window: int = 288) -> np.ndarray: - """rolling window z-score 정규화. 15분봉 기준 3일(288캔들) 윈도우. - 절대값 피처(ATR, 수익률 등)를 레짐 변화에 무관하게 만든다. - min_periods=1로 초반 데이터도 활용하며, ddof=0(모표준편차)으로 계산한다.""" + """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() + mean = r.mean() # pandas rolling은 nan을 자동으로 건너뜀 std = r.std(ddof=0) std = std.where(std >= 1e-8, other=1e-8) z = (s - mean) / std @@ -258,10 +257,18 @@ def _calc_features_vectorized( }, index=d.index) result = pd.concat([result, extra], axis=1) - # OI 변화율 / 펀딩비 피처 (parquet에 컬럼이 있으면 z-score, 없으면 0) - # OI는 최근 30일치만 제공되므로 이전 구간은 0으로 채워진 채로 들어옴 - oi_raw = d["oi_change"].values if "oi_change" in d.columns else np.zeros(len(d)) - fr_raw = d["funding_rate"].values if "funding_rate" in d.columns else np.zeros(len(d)) + # 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)) @@ -356,7 +363,12 @@ def generate_dataset_vectorized( feat_all = _calc_features_vectorized(d, signal_arr, btc_df=btc_df, eth_df=eth_df) # 신호 발생 + 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 = ( (signal_arr != "HOLD") & (~feat_all[available_cols_for_nan_check].isna().any(axis=1).values) & diff --git a/tests/test_dataset_builder.py b/tests/test_dataset_builder.py index 653971f..03272fb 100644 --- a/tests/test_dataset_builder.py +++ b/tests/test_dataset_builder.py @@ -91,3 +91,48 @@ def test_matches_original_generate_dataset(sample_df): assert 0.5 <= ratio <= 2.0, ( f"샘플 수 차이가 너무 큼: 벡터화={len(vec)}, 기존={len(orig)}, 비율={ratio:.2f}" ) + + +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 값 구간에 유한값이 있어야 함"