"""ML 피처 파이프라인 회귀 테스트 (2026-09-28). 두 결함을 behavioral 하게 고정한다. 1. **시계열 방향**: market_analyzer 는 chart_data 를 oldest-first 로 정규화해 넘기는데 chart_data_to_dataframe() 은 "입력은 최신순" 가정으로 무조건 뒤집었다. → ML 추론이 학습(trade_date ASC)과 정반대 방향의 시계열을 봤다. 2. **시퀀스 워밍업 NaN**: 탭뷸러만 NaN 검사를 받고 LSTM 시퀀스는 검사되지 않아 61~79봉 구간이 `int(nan)` → ValueError 크래시 밴드였다. """ import numpy as np import pytest from app.services.feature_engineering import ( LSTM_SEQUENCE_LENGTH, MIN_BARS_FOR_PREDICT, SEQUENCE_FEATURE_WARMUP_BARS, chart_data_to_dataframe_v2, extract_single_features_v2, ) def _make_chart(n: int, oldest_first: bool = True) -> list: """상승 추세 합성 일봉. stck_bsop_date 는 영업일 근사(연속 날짜).""" rows = [] for i in range(n): base = 50_000 + i * 100 rows.append({ "stck_bsop_date": f"2026{(1 + i // 28):02d}{(1 + i % 28):02d}", "stck_oprc": str(base), "stck_hgpr": str(base + 300), "stck_lwpr": str(base - 300), "stck_clpr": str(base + 100), "acml_vol": str(1_000_000 + i * 1_000), }) return rows if oldest_first else list(reversed(rows)) # ─── 1. 방향 무관성 ──────────────────────────────────────────────────────── def test_dataframe_v2_is_input_order_agnostic(): """oldest-first / newest-first 어느 쪽을 넣어도 동일한 시간순 DataFrame.""" df_old = chart_data_to_dataframe_v2(_make_chart(120, oldest_first=True)) df_new = chart_data_to_dataframe_v2(_make_chart(120, oldest_first=False)) assert list(df_old["date"]) == list(df_new["date"]) # 시간 오름차순이어야 한다 dates = [str(d) for d in df_old["date"]] assert dates == sorted(dates) def test_features_identical_regardless_of_input_order(): """방향이 뒤집힌 입력에서도 피처가 같아야 한다 (구버전은 달랐다).""" tab_a, seq_a, err_a = extract_single_features_v2(_make_chart(150, True)) tab_b, seq_b, err_b = extract_single_features_v2(_make_chart(150, False)) assert err_a is None and err_b is None, (err_a, err_b) np.testing.assert_allclose(tab_a, tab_b, rtol=0, atol=0) np.testing.assert_allclose(seq_a, seq_b, rtol=0, atol=0) def test_uptrend_is_not_read_as_downtrend(): """상승 추세 입력이 실제로 상승으로 읽혀야 한다. 구버전은 oldest-first 입력을 뒤집어 상승장을 하락장으로 계산했다. change_rate_1d 부호로 방향을 고정한다. """ from app.services.feature_engineering import FEATURE_COLUMNS tab, _, err = extract_single_features_v2(_make_chart(150, oldest_first=True)) assert err is None, err idx = FEATURE_COLUMNS.index("change_rate_1d") assert tab[idx] > 0, f"상승 추세인데 change_rate_1d={tab[idx]} (방향 역전 의심)" # ─── 2. 시퀀스 NaN 밴드 ─────────────────────────────────────────────────── def test_min_bars_covers_sequence_warmup(): """최소 봉수는 시퀀스 길이 + 시퀀스 피처 워밍업을 모두 덮어야 한다.""" assert MIN_BARS_FOR_PREDICT >= LSTM_SEQUENCE_LENGTH + SEQUENCE_FEATURE_WARMUP_BARS @pytest.mark.parametrize("n", [0, 1, 30, 60, 61, 70, 79]) def test_insufficient_bars_return_reason_not_nan(n): """봉수 부족은 NaN 을 흘리지 않고 reason 을 반환한다 (구 61~79 크래시 밴드).""" tab, seq, err = extract_single_features_v2(_make_chart(n)) assert tab is None and seq is None assert err, f"n={n} 에서 사유 없이 실패" @pytest.mark.parametrize("n", [80, 81, 120, 200]) def test_sufficient_bars_produce_finite_features(n): """최소 봉수 이상에서는 탭뷸러·시퀀스 모두 유한해야 한다.""" tab, seq, err = extract_single_features_v2(_make_chart(n)) assert err is None, f"n={n}: {err}" assert np.all(np.isfinite(tab)), "탭뷸러에 NaN/Inf" assert np.all(np.isfinite(seq)), "LSTM 시퀀스에 NaN/Inf" assert seq.shape[0] == LSTM_SEQUENCE_LENGTH # ─── 3. predict() 무예외 계약 ───────────────────────────────────────────── @pytest.mark.parametrize("bad", [None, [], [{}], [1, 2, 3], "not-a-list"]) def test_predict_never_raises_on_bad_input(bad): """predict() 는 어떤 입력에도 예외를 던지지 않고 dict 를 반환한다. 구버전은 NaN 이 섞이면 `int(nan)` → ValueError 로 호출부를 터뜨렸고, market_analyzer 의 `except Exception: pass` 가 그것을 조용히 삼켰다. """ from app.services.ml_ensemble import MLEnsemble result = MLEnsemble().predict(bad) assert isinstance(result, dict)