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>
This commit is contained in:
114
backend/tests/test_ml_feature_direction.py
Normal file
114
backend/tests/test_ml_feature_direction.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user