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:
@@ -8,9 +8,10 @@
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import pytz
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -429,13 +430,144 @@ def _calculate_technical_indicators(chart_data: List[dict], closes: List[float])
|
||||
return indicators
|
||||
|
||||
|
||||
# ─── 네이버 일봉 봉수 (2026-09-28) ─────────────────────────────────────────
|
||||
# get_naver_daily_chart(days=N) 은 [오늘-(N+30)일, 오늘] 캘린더 창을 요청한다.
|
||||
# days=80 → 110일 창 → 실측 75~77 거래일 (추석 연휴 포함 시 75)
|
||||
# days=200 → 230일 창 → 약 155 거래일
|
||||
# ML 최소 요구치는 80봉(시퀀스 60 + 시퀀스 피처 워밍업 20)이므로 기존 기본값으로는
|
||||
# 항상 미달이었다. ML 입력만 확장하고 룰 입력은 기존 창으로 자른다.
|
||||
_NAVER_ML_CHART_DAYS = 200
|
||||
_NAVER_LEGACY_DAYS = 80 # 기존 동작(=get_naver_daily_chart 기본값) 재현용
|
||||
|
||||
|
||||
def _trim_to_legacy_window(
|
||||
chart_data: Optional[List[dict]], legacy_days: int,
|
||||
) -> Optional[List[dict]]:
|
||||
"""확장 조회 결과를 기존 창(days=legacy_days)과 **동일한 배열**로 자른다.
|
||||
|
||||
get_naver_daily_chart 의 startTime 은 `오늘 - (days+30)` 이므로 같은 경계로 자르면
|
||||
기존 호출과 같은 봉 집합이 된다 → RSI/BB/ATR/ADX 등 룰 지표 값이 변하지 않는다.
|
||||
(ADX 는 Wilder 재귀라 배열 길이에 민감하므로 이 절단이 필수다.)
|
||||
"""
|
||||
if not chart_data:
|
||||
return chart_data
|
||||
cutoff = (datetime.now() - timedelta(days=legacy_days + 30)).strftime("%Y%m%d")
|
||||
trimmed = [
|
||||
item for item in chart_data
|
||||
if str(item.get("stck_bsop_date", "") or "") >= cutoff
|
||||
]
|
||||
return trimmed if trimmed else chart_data
|
||||
|
||||
|
||||
# ─── ML 앙상블 스킵 집계 (2026-09-28) ───────────────────────────────────────
|
||||
# 9/23~9/28 닷새간 ml_score 가 200/200 NULL 이었는데 아무 로그도 남지 않았다.
|
||||
# 원인은 `except Exception: pass` 였다. 사유별로 집계해 반드시 눈에 띄게 만든다.
|
||||
_ML_SKIP_STATS: dict = {"total": 0, "reasons": {}, "logged": 0}
|
||||
_ML_SUCCESS_COUNT: dict = {"n": 0}
|
||||
_ML_SKIP_LOG_EVERY = 50 # 최초 1회 + 이후 N회마다 1줄
|
||||
_DIGITS_RE = re.compile(r"\d+")
|
||||
|
||||
|
||||
def get_ml_skip_stats() -> dict:
|
||||
"""ML 스킵 집계 스냅샷 (배치 사이클 종료 시 요약 로그용)."""
|
||||
return {
|
||||
"ml_ok": _ML_SUCCESS_COUNT["n"],
|
||||
"ml_skipped": _ML_SKIP_STATS["total"],
|
||||
"reasons": dict(_ML_SKIP_STATS["reasons"]),
|
||||
}
|
||||
|
||||
|
||||
def reset_ml_skip_stats() -> None:
|
||||
"""배치 사이클 시작 시 호출 — 집계 초기화."""
|
||||
_ML_SKIP_STATS["total"] = 0
|
||||
_ML_SKIP_STATS["reasons"] = {}
|
||||
_ML_SKIP_STATS["logged"] = 0
|
||||
_ML_SUCCESS_COUNT["n"] = 0
|
||||
|
||||
|
||||
def log_ml_skip_summary(context: str = "") -> None:
|
||||
"""사이클 종료 시 사유별 집계를 1줄로 남긴다 (warning 수준)."""
|
||||
snap = get_ml_skip_stats()
|
||||
if snap["ml_skipped"] == 0:
|
||||
logger.info("[ML] %s 예측 성공 %d종목, 스킵 0건", context, snap["ml_ok"])
|
||||
return
|
||||
detail = ", ".join(f"{k}={v}" for k, v in sorted(
|
||||
snap["reasons"].items(), key=lambda kv: -kv[1]))
|
||||
logger.warning(
|
||||
"[ML] %s 스킵 %d종목 (성공 %d종목) — 사유별: %s",
|
||||
context, snap["ml_skipped"], snap["ml_ok"], detail,
|
||||
)
|
||||
|
||||
|
||||
def _record_ml_skip(stock_code: str, reason: str) -> None:
|
||||
"""스킵 사유 집계 + 최초/주기적 warning 로그."""
|
||||
# 숫자(봉수 등)를 지워 사유 단위로 묶는다 — "봉수 부족 75/80" 과 "77/80" 이 한 버킷
|
||||
key = _DIGITS_RE.sub("N", reason)[:80] if reason else "unknown"
|
||||
_ML_SKIP_STATS["total"] += 1
|
||||
_ML_SKIP_STATS["reasons"][key] = _ML_SKIP_STATS["reasons"].get(key, 0) + 1
|
||||
n = _ML_SKIP_STATS["total"]
|
||||
if n == 1 or n % _ML_SKIP_LOG_EVERY == 0:
|
||||
_ML_SKIP_STATS["logged"] += 1
|
||||
logger.warning("[ML] 예측 스킵 %s: %s (누적 %d건)", stock_code, reason, n)
|
||||
|
||||
|
||||
def _run_ml_ensemble(chart_data: List[dict], stock_code: str) -> Optional[dict]:
|
||||
"""ML 앙상블 예측 — 실패는 집계·로그로 드러내고 None 을 반환한다.
|
||||
|
||||
ML 실패가 룰 기반 분석 저장을 막지 않도록 여기서 흡수하되,
|
||||
**조용히 넘기지 않는다**(기존 `except Exception: pass` 가 9/23 무증상의 원인).
|
||||
"""
|
||||
try:
|
||||
from app.services.ml_ensemble import get_ensemble
|
||||
ensemble = get_ensemble()
|
||||
if not ensemble.is_trained:
|
||||
_record_ml_skip(stock_code, "모델 미학습")
|
||||
return None
|
||||
ml_result = ensemble.predict(chart_data) # 예외를 던지지 않는 계약
|
||||
if "error" in ml_result:
|
||||
_record_ml_skip(stock_code, f"{ml_result['error']} (봉수 {len(chart_data)})")
|
||||
return None
|
||||
if ml_result.get("degraded_models"):
|
||||
logger.warning(
|
||||
"[ML] %s 일부 모델 제외 상태로 예측: %s",
|
||||
stock_code, ml_result["degraded_models"],
|
||||
)
|
||||
_ML_SUCCESS_COUNT["n"] += 1
|
||||
return ml_result
|
||||
except Exception as e:
|
||||
# predict() 는 예외를 내지 않아야 하므로 여기에 오면 계약 위반 = 버그다.
|
||||
logger.warning(
|
||||
"[ML] %s 예측 중 예상치 못한 예외 (%s): %s",
|
||||
stock_code, type(e).__name__, e, exc_info=True,
|
||||
)
|
||||
_record_ml_skip(stock_code, f"예외 {type(e).__name__}")
|
||||
return None
|
||||
|
||||
|
||||
def _build_analysis_result(
|
||||
chart_data: List[dict],
|
||||
chart_info: dict,
|
||||
indicators: dict,
|
||||
criteria: "RecommendationCriteria",
|
||||
) -> Optional[StockRecommendation]:
|
||||
"""Build analysis result with signals and score.
|
||||
"""Build analysis result with signals and score. (기존 시그니처 유지)"""
|
||||
return _build_analysis_result_v2(chart_data, chart_info, indicators, criteria)
|
||||
|
||||
|
||||
def _build_analysis_result_v2(
|
||||
chart_data: List[dict],
|
||||
chart_info: dict,
|
||||
indicators: dict,
|
||||
criteria: "RecommendationCriteria",
|
||||
ml_chart_data: Optional[List[dict]] = None,
|
||||
) -> Optional[StockRecommendation]:
|
||||
"""_build_analysis_result + ML 전용 장기 차트 주입 (2026-09-28).
|
||||
|
||||
Args:
|
||||
ml_chart_data: ML 앙상블에만 사용할 더 긴 차트(≥80봉). None 이면 chart_data 사용.
|
||||
**룰 지표(RSI/BB/ATR/ADX)는 chart_data 만으로 계산되므로 값이 변하지 않는다.**
|
||||
ADX(Wilder)는 배열 전체를 훑는 재귀 지표라 봉수가 바뀌면 값이 달라진다 —
|
||||
그래서 룰 경로의 입력은 절대 늘리지 않고 ML 입력만 분리했다.
|
||||
|
||||
Returns StockRecommendation or None based on score threshold and criteria.
|
||||
"""
|
||||
@@ -590,26 +722,21 @@ def _build_analysis_result(
|
||||
ml_signal_val = None
|
||||
|
||||
if settings.ML_ENSEMBLE_ENABLED:
|
||||
try:
|
||||
from app.services.ml_ensemble import get_ensemble
|
||||
ensemble = get_ensemble()
|
||||
if ensemble.is_trained:
|
||||
ml_result = ensemble.predict(chart_data)
|
||||
if "error" not in ml_result:
|
||||
ml_score_val = ml_result["ensemble_score"]
|
||||
ml_confidence_val = ml_result["confidence"]
|
||||
ml_signal_val = ml_result["signal"]
|
||||
# ML 결과는 chart_data 보다 긴 시계열이 있으면 그쪽을 쓴다 (봉수 부족 방지).
|
||||
ml_result = _run_ml_ensemble(ml_chart_data or chart_data, stock_code)
|
||||
if ml_result is not None:
|
||||
ml_score_val = ml_result["ensemble_score"]
|
||||
ml_confidence_val = ml_result["confidence"]
|
||||
ml_signal_val = ml_result["signal"]
|
||||
|
||||
rule_score_normalized = (score / 15.0) * 100.0
|
||||
combined_score = int(
|
||||
rule_score_normalized * settings.ML_ENSEMBLE_RULE_WEIGHT +
|
||||
ml_score_val * settings.ML_ENSEMBLE_ML_WEIGHT
|
||||
)
|
||||
score = max(0, int(combined_score / 100.0 * 15.0))
|
||||
rule_score_normalized = (score / 15.0) * 100.0
|
||||
combined_score = int(
|
||||
rule_score_normalized * settings.ML_ENSEMBLE_RULE_WEIGHT +
|
||||
ml_score_val * settings.ML_ENSEMBLE_ML_WEIGHT
|
||||
)
|
||||
score = max(0, int(combined_score / 100.0 * 15.0))
|
||||
|
||||
signals.append(f"ML앙상블: {ml_signal_val}({ml_score_val}점, 신뢰도{ml_confidence_val:.0%})")
|
||||
except Exception:
|
||||
pass
|
||||
signals.append(f"ML앙상블: {ml_signal_val}({ml_score_val}점, 신뢰도{ml_confidence_val:.0%})")
|
||||
|
||||
if criteria.for_batch or score >= 2:
|
||||
final_score = max(1, score) if len(signals) > 0 else score
|
||||
@@ -656,6 +783,20 @@ def _analyze_chart_items(
|
||||
2. 기술적 지표 계산
|
||||
3. 분석 결과 생성 (신호 및 점수)
|
||||
"""
|
||||
return _analyze_chart_items_v2(chart_data, stock_code, stock_name, criteria)
|
||||
|
||||
|
||||
def _analyze_chart_items_v2(
|
||||
chart_data: List[dict],
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
criteria: "RecommendationCriteria",
|
||||
ml_chart_data: Optional[List[dict]] = None,
|
||||
) -> Optional[StockRecommendation]:
|
||||
"""_analyze_chart_items + ML 전용 장기 차트 주입 (2026-09-28).
|
||||
|
||||
ml_chart_data 는 ML 앙상블에만 전달되며 룰 지표 계산에는 쓰이지 않는다.
|
||||
"""
|
||||
# 1. Extract and validate chart data
|
||||
chart_info = _extract_chart_data(chart_data, stock_code, stock_name)
|
||||
if not chart_info:
|
||||
@@ -665,7 +806,9 @@ def _analyze_chart_items(
|
||||
indicators = _calculate_technical_indicators(chart_data, chart_info["closes"])
|
||||
|
||||
# 3. Build analysis result with signals and scoring
|
||||
return _build_analysis_result(chart_data, chart_info, indicators, criteria)
|
||||
return _build_analysis_result_v2(
|
||||
chart_data, chart_info, indicators, criteria, ml_chart_data=ml_chart_data,
|
||||
)
|
||||
|
||||
|
||||
async def analyze_stock(stock_code: str, stock_name: str, criteria: RecommendationCriteria) -> Optional[StockRecommendation]:
|
||||
@@ -676,24 +819,35 @@ async def analyze_stock(stock_code: str, stock_name: str, criteria: Recommendati
|
||||
"""
|
||||
try:
|
||||
chart_data = None
|
||||
ml_chart_data = None
|
||||
|
||||
# 배치 분석: 네이버 일봉 우선 사용
|
||||
if criteria.for_batch:
|
||||
try:
|
||||
from app.services.naver_finance import get_naver_daily_chart
|
||||
chart_data = await get_naver_daily_chart(stock_code)
|
||||
except Exception:
|
||||
pass
|
||||
# ML 은 최소 80봉이 필요하다(시퀀스60+워밍업20). 기존 기본값 days=80 은
|
||||
# 캘린더 110일 창 → 실제 75~77봉밖에 오지 않아 ML 이 항상 NaN 으로 터졌다.
|
||||
ml_chart_data = await get_naver_daily_chart(
|
||||
stock_code, days=_NAVER_ML_CHART_DAYS,
|
||||
)
|
||||
# 룰 지표는 기존과 동일한 창으로 계산한다(ADX Wilder 는 봉수 의존).
|
||||
chart_data = _trim_to_legacy_window(ml_chart_data, _NAVER_LEGACY_DAYS)
|
||||
except Exception as e:
|
||||
logger.warning("네이버 일봉 조회 실패 %s: %s", stock_code, e)
|
||||
|
||||
# 네이버 실패 또는 실시간 분석 → KIS API 폴백
|
||||
if chart_data is None:
|
||||
_api = _real_market_api or korea_investment_api
|
||||
# limit 기본값 240 — ML 최소 요구치(80봉)를 충분히 넘는다
|
||||
chart_response = await _api.get_daily_chart(stock_code, "D")
|
||||
if "output2" not in chart_response or not chart_response["output2"]:
|
||||
return None
|
||||
chart_data = list(reversed(chart_response["output2"]))
|
||||
ml_chart_data = None # KIS 경로는 동일 배열을 그대로 ML 에 사용
|
||||
|
||||
return _analyze_chart_items(chart_data, stock_code, stock_name, criteria)
|
||||
return _analyze_chart_items_v2(
|
||||
chart_data, stock_code, stock_name, criteria, ml_chart_data=ml_chart_data,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error analyzing {stock_code}: {str(e)}")
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user