feat: OI nan 마스킹 / epsilon 통일 / 정밀도 우선 임계값 #1

Merged
gihyeon merged 5 commits from feature/oi-nan-epsilon-precision-threshold into main 2026-03-01 23:57:32 +09:00
2 changed files with 66 additions and 9 deletions
Showing only changes of commit 417b8e3c6a - Show all commits

View File

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

View File

@@ -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 값 구간에 유한값이 있어야 함"