Files
auto-stock/backend/tests/test_ml_feature_direction.py
shuim 43b4b1a7aa fix(ml): ML 앙상블 전면 미작동 — 시계열 역방향 + 시퀀스 워밍업 NaN 크래시
9/23~9/28 닷새간 stock_analysis.ml_score 가 200/200 NULL 이었고 로그는 0줄이었다.
원인 3건이 겹쳐 있었다.

1. 시계열 역방향 (추론 ≠ 학습)
   market_analyzer 는 chart_data 를 oldest-first 로 정규화해 넘기는데
   chart_data_to_dataframe() 은 "입력은 최신순" 가정으로 무조건 뒤집는다.
   → ML 피처가 역방향 시계열에서 계산됐다. 학습은 db_data_to_dataframe
   (trade_date ASC, 정방향)이므로 추론이 학습과 정반대 방향이었다.
   ensemble_score 관측 범위가 18~50 에 갇혀 ML BUY 가 0건이던 것의 직접 원인.
   → chart_data_to_dataframe_v2(): stck_bsop_date 오름차순 정렬로 방향 의존성 제거.

2. LSTM 시퀀스 워밍업 NaN → int(nan) ValueError
   탭뷸러는 NaN 검사를 받지만 시퀀스는 검사되지 않았다. 시퀀스 피처
   volatility_20d 의 첫 유효 인덱스가 20 이므로 요구 봉수는 60+20=80 인데
   게이트가 <61 이어서 61~79 구간이 크래시 밴드였다.
   NaN → LSTM 이 [nan,nan,nan] 반환(예외 아님) → 가중평균 전체 NaN
   → ml_ensemble.py int(prob_up*100) 에서 ValueError.
   → MIN_BARS_FOR_PREDICT=80 + 시퀀스 유한성 검사. NaN 을 0 으로 채우지 않고
     사유를 담은 error 로 반환한다.
   prepare_training_data 도 idx 60~79 샘플 시퀀스에 NaN 을 넣고 있었다
   (valid_mask[:60]=False 만 존재) → X_seq.mean 이 NaN → seq_mean/std 오염.

3. except Exception: pass 가 1·2 를 조용히 삼켰다
   → _run_ml_ensemble() 분리 + 사유별 집계(get/reset_ml_skip_stats,
     log_ml_skip_summary). 최초 1건 + 이후 50건마다 warning, 사이클 종료 시
     사유별 1줄 요약. batch_daily_analysis 에 배선.

부수 수정
- 네이버 ML 차트 80일창(75봉) → 200일창(154봉). 룰 입력은 _trim_to_legacy_window
  로 기존 창과 동일 배열로 절단 — adx_wilder 는 Wilder 재귀라 배열 길이에
  민감하므로 절단 없이는 SIDEWAYS 히스테리시스 판정이 바뀐다(실측 14.21→14.55).
  전 지표 동일성 확인: rsi/ma/bb/atr/adx/stochastic 모두 일치.
- predict() 가 어떤 입력에도 예외를 던지지 않도록 얇은 가드 + _predict_impl 분리.
  비유한 확률을 내는 모델은 warning 과 함께 앙상블에서 제외(degraded_models).
- training_metrics 가 항상 {} 였던 원인: self.training_metrics = metrics 가
  _save_models() 뒤에 있어 직전 값이 기록됨. 순서 정정 + per-class 지표 추가
  ("전부 HOLD" 모델을 정확도만으로 판별 못하는 문제 대응).
- endpoints/ml_ensemble.py: get_daily_chart 는 output2 를 채우는데 output 을
  읽어 항상 400 이었다. 게이트 61→MIN_BARS_FOR_PREDICT, async 핸들러의
  동기 predict() → predict_async().

미변경: 모델 파일, 아키텍처·하이퍼파라미터, 임계값(60/0.3/0.7, 가중치
0.4/0.35/0.25), torch.load(weights_only=True), 지표 계산식, KIS 호출량.

검증: 579 passed / 0 failed (소켓·KIS 차단 하, 기준선 559 + 신규 20).
신규 tests/test_ml_feature_direction.py 는 결함 역주입으로 검출력 증명 —
방향 역전 2건 + 크래시 밴드 61/70/79 3건 + 게이트 1건 = 6건 실패 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 11:51:36 +09:00

115 lines
5.0 KiB
Python

"""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)