""" 시장 분석 서비스 - KIS API를 통해 관심 종목(watchlist)의 기술적 지표 일괄 분석 - 시장 국면(bull/bear/sideways) 판별 및 추천 종목 선별 - OVERHEAT(급등 과열) 필터, 추세 필터 적용 - 분석 결과를 StockAnalysis DB에 upsert하고 Redis 캐시에 저장 """ import asyncio import logging import re import time import pytz from datetime import datetime, timedelta from typing import List, Optional from pydantic import BaseModel from app.core.config import settings from app.core.market_schedule import is_market_open from app.services.korea_investment_api import korea_investment_api, real_market_api as _real_market_api from app.services.mean_reversion_strategy import ( MarketRegime, detect_market_regime, calculate_atr, calculate_adx ) from app.services.technical_indicators import ( TREND_FILTER_ENABLED, TREND_MA_SHORT, TREND_MA_LONG, TREND_TOLERANCE_PCT, OVERHEAT_FILTER_ENABLED, _passes_uptrend, analyze_overheat, analyze_custom_signals, analyze_macd, analyze_rsi, analyze_bollinger, analyze_moving_average, analyze_stochastic, analyze_volatility_breakout, analyze_adx, analyze_obv ) from app.core.log_throttle import warn_throttled # noqa: E402 logger = logging.getLogger(__name__) KST = pytz.timezone('Asia/Seoul') def _get_kst_now(): return datetime.now(KST).replace(tzinfo=None) def _sideways_message_v2(current_price: float, ma200: float, by_hmm: bool) -> str: """SIDEWAYS 응답 message — 실효 설정값과 판정 출처(rule/HMM)를 반영한다.""" _enter = settings.MEAN_REVERSION_ADX_SIDEWAYS - 2.0 _exit = settings.MEAN_REVERSION_ADX_SIDEWAYS + 2.0 _basis = ( "HMM 앙상블 오버라이드" if by_hmm else (f"규칙: ADX<{_enter:g}(해제>{_exit:g}) AND " f"MA200 기울기<{settings.MEAN_REVERSION_SLOPE_THRESHOLD:g}") ) return (f"횡보장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, {_basis})" " — 진입 차단") class RecommendationCriteria(BaseModel): use_macd: bool = False use_rsi: bool = False use_bollinger: bool = False use_moving_average: bool = False use_stochastic: bool = False use_adx: bool = False use_obv: bool = False use_volatility_breakout: bool = False use_overheat_filter: bool = True min_volume_ratio: float = 1.0 max_disparity: float = 200.0 for_batch: bool = False class StockRecommendation(BaseModel): stock_code: str stock_name: str current_price: float change_rate: float signals: List[str] score: float price_date: str = "" # 장중인데 마지막 봉이 오늘 봉이 아님(전일 봉 기반 신호). 매수 후보에서 제외할 것. price_stale: bool = False volume: Optional[int] = None rsi: Optional[float] = None ma5: Optional[float] = None ma20: Optional[float] = None ma60: Optional[float] = None stochastic_k: Optional[float] = None stochastic_d: Optional[float] = None bollinger_upper: Optional[float] = None bollinger_lower: Optional[float] = None bollinger_middle: Optional[float] = None ml_score: Optional[int] = None ml_confidence: Optional[float] = None ml_signal: Optional[str] = None atr_14: Optional[float] = None adx_14: Optional[float] = None _market_regime_cache: dict = {"result": None, "ts": 0.0} _MARKET_REGIME_CACHE_TTL = 120 _OFFHOURS_REGIME_MAX_AGE_SEC = 72 * 3600 # 주말 포함 직전 장중 판정 재사용 한도 async def _get_hmm_ensemble_info( rule_regime_str: str, closes_oldest_first: Optional[List[float]] = None, volumes_oldest_first: Optional[List[float]] = None, ) -> dict: """HMM 앙상블 결과를 안전하게 가져온다. HMM 미설치, 모델 없음, 예측 실패 시 모두 rule_only fallback. 기존 check_market_regime 로직에 영향 없이 optional 정보를 추가한다. """ try: from app.services.regime_hmm import ( is_hmm_available, predict_current_regime, ensemble_regime, ) if not is_hmm_available(): return {"hmm": None, "ensemble": None} hmm_result = await predict_current_regime( closes=closes_oldest_first, volumes=volumes_oldest_first, ) ensemble = ensemble_regime(hmm_result, rule_regime_str) return {"hmm": hmm_result, "ensemble": ensemble} except Exception as e: warn_throttled(logger, "market_analyzer:104", "HMM 앙상블 조회 실패 (규칙 기반 유지): %s", e) return {"hmm": None, "ensemble": None} _REGIME_KIS_LOOKBACK_DAYS = 400 _regime_source_state: dict = {"source": None, "bars": 0, "naver_bars": 0, "ts": 0.0, "blocked": False, "stale": False} def get_regime_source_health() -> dict: """국면 판정 데이터 소스 상태(/health 노출용 사본).""" return dict(_regime_source_state) def _is_stale_index_chart_v2(chart_newest_first) -> bool: """장중인데 지수 일봉의 최신 봉 날짜가 오늘(KST)이 아니면 True. 날짜 필드가 없으면 검증 불가이므로 False(기존 동작 유지). """ if not chart_newest_first: return False latest = str(chart_newest_first[0].get("stck_bsop_date", "") or "") if not latest: return False return latest != _get_kst_now().strftime("%Y%m%d") def _regime_block_v2(message: str, source: str, bars: int, naver_bars: int) -> dict: """국면 판별 불가 시 fail-closed 반환 + 원인 로그/상태 기록(차단은 캐시하지 않음).""" logger.error("[Regime] 신규 진입 차단: %s (source=%s, bars=%d, naver_bars=%d)", message, source, bars, naver_bars) _regime_source_state.update(source=source, bars=bars, naver_bars=naver_bars, ts=time.time(), blocked=True) return {"regime": "neutral", "regime_200d": "bear", "message": message, "position_multiplier": 0.0, "min_score": 9999, "max_trades": 0} async def check_market_regime() -> dict: try: if not is_market_open(): _off = {"regime": "neutral", "regime_200d": "bear", "message": "장외 시간 - 시장 레짐 판별 스킵", "position_multiplier": 0.5} # 장외 사이클(장전/15:35 확정)이 가짜 'bear' 로 후보를 분류하지 않도록 # 직전 장중 판정의 regime_200d 만 빌려온다(배수/게이트는 장외 값 유지). _prev = _market_regime_cache["result"] if (_prev and _prev.get("regime_200d") in ("bull", "bear", "sideways") and (time.monotonic() - _market_regime_cache["ts"]) < _OFFHOURS_REGIME_MAX_AGE_SEC): _off["regime_200d"] = _prev["regime_200d"] _off["regime_200d_source"] = "last_intraday_cache" return _off now_ts = time.monotonic() if _market_regime_cache["result"] is not None and (now_ts - _market_regime_cache["ts"]) < _MARKET_REGIME_CACHE_TTL: return _market_regime_cache["result"] # ── 데이터 수집: chart_data는 항상 newest-first 순서로 통일 ── # naver_data: oldest-first → reversed → newest-first # KIS output2: newest-first (API 기본) chart_data = None _src = "none" _naver_n = 0 _naver_stale_fallback = None try: from app.services.naver_finance import get_naver_daily_chart # MA200+기울기(5봉) 판별에 205봉 이상 필요 — 캘린더일 기준이므로 400일 요청(≈270거래일) naver_data = await get_naver_daily_chart("069500", days=400) # oldest-first _naver_n = len(naver_data) if naver_data else 0 # 205봉 미만이면 MA200/SIDEWAYS 판별 불가 → KIS 폴백으로 넘김 if naver_data and len(naver_data) >= 205: _cand = list(reversed(naver_data)) # → newest-first if _is_stale_index_chart_v2(_cand): # 장중인데 오늘 봉이 없음 → 다음 소스(KIS 범위) 시도, 실패 시 최후 폴백 _naver_stale_fallback = _cand else: chart_data = _cand _src = "naver" except Exception as _swallowed: warn_throttled(logger, "market_analyzer:127", "[Regime] 네이버 KODEX200 차트 조회 실패(KIS 폴백 진행): %s", _swallowed) if chart_data is None: _api = _real_market_api or korea_investment_api # get_daily_chart 는 실측 30행 상한이라 205봉을 못 채운다 → 날짜 범위 페이지네이션 우선 _rows = [] try: _end = datetime.now().strftime("%Y%m%d") _start = (datetime.now() - timedelta(days=_REGIME_KIS_LOOKBACK_DAYS)).strftime("%Y%m%d") _rows = await _api.get_daily_ohlcv_range("069500", _start, _end) or [] except Exception as _e: logger.warning("[Regime] KIS 범위 일봉 폴백 실패: %s", _e) if len(_rows) >= 205 and (_naver_stale_fallback is None or not _is_stale_index_chart_v2(_rows)): chart_data = _rows # newest-first _src = "kis_range" elif _naver_stale_fallback is not None: chart_data = _naver_stale_fallback _src = "naver" else: kospi_chart = await _api.get_daily_chart("069500", "D", limit=240) _out2 = (kospi_chart or {}).get("output2") if isinstance(kospi_chart, dict) else None if not _out2: return _regime_block_v2( "시장 지수 확인 불가 — 신규 진입 차단", "none", 0, _naver_n) chart_data = _out2 if len(_out2) >= len(_rows) else _rows _src = "kis_chart" if chart_data is _out2 else "kis_range" # closes: newest-first (closes[0]=최신, closes[-1]=가장 오래된) closes = [float(item.get("stck_clpr", 0)) for item in chart_data] _idx_stale = _is_stale_index_chart_v2(chart_data) _regime_source_state.update(source=_src, bars=len(closes), naver_bars=_naver_n, ts=time.time(), blocked=False, stale=_idx_stale) if _idx_stale: # 당일 봉이 없으면 아래 당일 급락(CRASH) 감지가 건너뛰어진다 — 조용히 넘기지 않는다. warn_throttled( logger, "market_analyzer:regime_stale", "[Regime] 장중 지수 일봉이 낡음(최신 봉 %s != 오늘 %s, source=%s) — 당일 급락 감지 비활성", chart_data[0].get("stck_bsop_date", ""), _get_kst_now().strftime("%Y%m%d"), _src) if len(closes) < 60: return _regime_block_v2("데이터 부족 — 신규 진입 차단", _src, len(closes), _naver_n) def _cache_and_return(result: dict) -> dict: _market_regime_cache["result"] = result _market_regime_cache["ts"] = time.monotonic() return result # ── 수동 비상 정지 ──────────────────────────────────────────── if settings.TRADING_HALTED: return _cache_and_return({ "regime": "halted", "regime_200d": "bear", "message": "⛔ TRADING_HALTED=true — 수동 비상 정지 중 (매수 전면 차단)", "position_multiplier": 0.0, "min_score": 9999, "max_trades": 0, }) # closes는 newest-first: closes[0]=오늘, closes[:20]=최근 20일 current_price = closes[0] ma20 = sum(closes[:20]) / 20 ma60 = sum(closes[:60]) / 60 if len(closes) >= 205: # CLAUDE.md 6.1: ADX 기반 SIDEWAYS 판별을 위해 highs/lows도 전달 # chart_data: newest-first → _highs/_lows도 newest-first _highs = [float(item.get("stck_hgpr", 0)) for item in chart_data] _lows = [float(item.get("stck_lwpr", 0)) for item in chart_data] # detect_market_regime()는 oldest-first 입력 기대 → reversed regime_enum = detect_market_regime( list(reversed(closes)), highs=list(reversed(_highs)), lows=list(reversed(_lows)), ) is_bull_200 = regime_enum == MarketRegime.BULL is_sideways_200 = regime_enum == MarketRegime.SIDEWAYS ma200 = sum(closes[:200]) / 200 else: # 205봉 미만 → MA200/ADX 국면 판별 불가. 기존에는 MA20 프록시로 상승장 판정 후 # 매매를 지속했으나(실계좌 손실 원인), 판별 불가 시 신규 진입을 차단한다. logger.error( "[Regime] 국면 판별 불가: 일봉 %d개 < 205 (source=%s, naver_bars=%d) — 신규 진입 차단", len(closes), _src, _naver_n) _regime_source_state.update(blocked=True) return _cache_and_return({ "regime": "neutral", "regime_200d": "bear", "message": f"국면 판별 불가 (일봉 {len(closes)}개 < 205) — 신규 진입 차단", "position_multiplier": 0.0, "min_score": 9999, "max_trades": 0, }) # ── HMM 앙상블 (optional) ──────────────────────────────────── # 규칙 기반 결과를 먼저 결정한 뒤, HMM 결과와 앙상블 _sideways_by_hmm = False _rule_regime_str = "sideways" if is_sideways_200 else ("bull" if is_bull_200 else "bear") # HMM/앙상블에는 oldest-first 데이터 전달 (closes: newest-first → reversed) _closes_oldest = list(reversed(closes)) _volumes_oldest = [float(item.get("acml_vol", 0)) for item in reversed(chart_data)] hmm_info = await _get_hmm_ensemble_info(_rule_regime_str, _closes_oldest, _volumes_oldest) # HMM 앙상블이 sideways 로 판정하면 규칙 기반도 sideways 로 오버라이드 _ensemble = hmm_info.get("ensemble") if _ensemble and _ensemble.get("method") == "ensemble": if _ensemble["regime"] == "sideways" and not is_sideways_200: logger.info( "HMM 앙상블: 규칙=%s, HMM=%s → sideways 오버라이드 (conf=%.2f)", _rule_regime_str, _ensemble.get("hmm_regime"), _ensemble.get("confidence", 0), ) is_sideways_200 = True _sideways_by_hmm = True # ── 당일 급락 감지 (사이드카/전쟁 등) ─────────────────────── # closes[0]이 오늘 데이터일 때만 적용 (일봉 미확정 시 전일 종가가 closes[0]이 되어 오탐 발생) latest_date = chart_data[0].get("stck_bsop_date", "") if chart_data else "" today_str = _get_kst_now().strftime("%Y%m%d") if latest_date == today_str and len(closes) >= 2 and closes[1] > 0: today_change_pct = (closes[0] - closes[1]) / closes[1] * 100 if today_change_pct <= settings.MARKET_CRASH_HALT_PCT: return _cache_and_return({ "regime": "crash", "regime_200d": "bear", "message": f"🚨 당일 급락 감지 ({today_change_pct:.1f}% ≤ {settings.MARKET_CRASH_HALT_PCT}%) — 매수 전면 차단", "position_multiplier": 0.0, "min_score": 9999, "max_trades": 0, "today_change_pct": round(today_change_pct, 2), "hmm_ensemble": _ensemble, }) consecutive_down = 0 for i in range(len(closes) - 1): if closes[i] < closes[i + 1]: consecutive_down += 1 else: break drop_from_ma20 = ((current_price - ma20) / ma20) * 100 # CLAUDE.md 6.1: SIDEWAYS 국면 — 모든 진입 신호 무시 if is_sideways_200: return _cache_and_return({ "regime": "sideways", "regime_200d": "sideways", "message": _sideways_message_v2(current_price, ma200, _sideways_by_hmm), "position_multiplier": 0.0, "min_score": 9999, "max_trades": 0, "consecutive_down": consecutive_down, "drop_from_ma20": round(drop_from_ma20, 1), "hmm_ensemble": _ensemble, }) if current_price < ma60 or consecutive_down >= 5: return _cache_and_return({ "regime": "strong_bearish", "regime_200d": "bear", "message": f"강한 하락장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, {consecutive_down}일 연속하락)", "position_multiplier": 0.1, "min_score": settings.BEAR_MARKET_MIN_SCORE + 3, "max_trades": 1, "consecutive_down": consecutive_down, "drop_from_ma20": round(drop_from_ma20, 1), "hmm_ensemble": _ensemble, }) if current_price < ma20: return _cache_and_return({ "regime": "bearish", "regime_200d": "bear", "message": f"하락장 (코스피: {current_price:.0f} < MA20: {ma20:.0f}, MA200: {ma200:.0f})", "position_multiplier": settings.BEAR_MARKET_POSITION_RATIO, "min_score": settings.BEAR_MARKET_MIN_SCORE, "max_trades": settings.BEAR_MARKET_MAX_TRADES, "consecutive_down": consecutive_down, "drop_from_ma20": round(drop_from_ma20, 1), "hmm_ensemble": _ensemble, }) return _cache_and_return({ "regime": "bullish", "regime_200d": "bull" if is_bull_200 else "bear", "message": f"상승장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, Bull={is_bull_200})", "position_multiplier": 1.0, "min_score": 1, "max_trades": settings.MAX_DAILY_TRADES, "consecutive_down": consecutive_down, "drop_from_ma20": round(drop_from_ma20, 1), "hmm_ensemble": _ensemble, }) except Exception as e: # [H3 2026-10-02] fail-closed — 기존 반환은 regime_200d/max_trades 가 없고 # position_multiplier=0.5 여서 소비부 기본값(.get("regime_200d","bull"))으로 # BULL 진입이 계속됐다. 다른 '판별 불가' 분기와 동일하게 신규 진입을 차단한다. # (청산 경로는 국면을 진입 게이트로 쓰지 않는다.) logger.error(f"시장 지수 확인 실패 — 신규 진입 차단(fail-closed): {e}", exc_info=True) return {"regime": "neutral", "regime_200d": "bear", "message": "시장 지수 확인 실패 — 신규 진입 차단", "position_multiplier": 0.0, "min_score": 9999, "max_trades": 0} _VOL_SESSION_MINUTES = 390 # 09:00~15:30 _VOL_MIN_ELAPSED_FRACTION = 0.10 # 개장 직후(≈39분) 배율 폭주 방지 하한 def _volume_ratio_v2(volumes: List[int], volume: int, price_date: str, now: Optional[datetime] = None) -> float: """거래량비. 마지막 봉이 '오늘 장중 부분봉'이면 시간 정규화한다. 부분봉: volume / (직전 20봉 평균 × 경과 세션 비율). 오늘 봉은 분모에서 제외. 그 외(마감 봉·과거 봉): 기존과 동일하게 volume / avg(volumes[-20:]). """ now = now or datetime.now(KST) today = now.strftime("%Y%m%d") minutes = (now.hour * 60 + now.minute) - 9 * 60 intraday = ( str(price_date) == today and now.weekday() < 5 and 0 <= minutes < _VOL_SESSION_MINUTES ) if intraday: prior = volumes[-21:-1] if len(prior) < 20: return 0 avg_prior = sum(prior) / 20 frac = max(_VOL_MIN_ELAPSED_FRACTION, minutes / _VOL_SESSION_MINUTES) denom = avg_prior * frac return (volume / denom) if denom > 0 else 0 avg = sum(volumes[-20:]) / 20 if len(volumes) >= 20 else 0 return (volume / avg) if avg > 0 else 0 def _is_stale_intraday_bar_v2(price_date: str, now: Optional[datetime] = None) -> bool: """장중(is_market_open)인데 마지막 봉 날짜가 오늘이 아니면 True. 장전/장후는 전일 봉이 정상.""" now = now or datetime.now(KST) if not price_date or not is_market_open(): return False return str(price_date) != now.strftime("%Y%m%d") def _extract_chart_data( chart_data: List[dict], stock_code: str, stock_name: str, ) -> Optional[dict]: """Extract and validate basic chart data from KIS format. Returns dict with: stock_code, stock_name, current_price, price_date, volume, change_rate, closes, volumes, or None if validation fails. """ latest = chart_data[-1] if chart_data else None if not latest: return None if not stock_name: stock_name = latest.get("hts_kor_isnm", stock_code) current_price = float(latest.get("stck_clpr", 0)) price_date = latest.get("stck_bsop_date", "") volume = int(latest.get("acml_vol", 0) or 0) if current_price == 0: logger.debug("필터[가격0] %s", stock_code) return None # 가격 필터: 5,000원 이하 종목 제외 (CLAUDE.md 섹션 5.4: "5,000원 초과"만 허용) if current_price <= settings.MIN_PRICE_KRW: logger.info("필터[저가] %s: %.0f원 < %d원", stock_code, current_price, settings.MIN_PRICE_KRW) return None if len(chart_data) >= 2: prev_price = float(chart_data[-2].get("stck_clpr", current_price)) change_rate = ((current_price - prev_price) / prev_price) * 100 if prev_price > 0 else 0 else: change_rate = 0 closes = [float(item.get("stck_clpr", 0)) for item in chart_data] volumes = [int(item.get("acml_vol", 0)) for item in chart_data] return { "stock_code": stock_code, "stock_name": stock_name, "current_price": current_price, "price_date": price_date, "volume": volume, "change_rate": change_rate, "closes": closes, "volumes": volumes, "chart_data": chart_data, } def _calculate_technical_indicators(chart_data: List[dict], closes: List[float]) -> dict: """Calculate technical indicators (MA, RSI, Stochastic, Bollinger, ATR, ADX). Returns dict with calculated indicators. """ indicators = {} # Moving Averages indicators["ma5"] = sum(closes[-5:]) / 5 if len(closes) >= 5 else None indicators["ma20"] = sum(closes[-20:]) / 20 if len(closes) >= 20 else None indicators["ma60"] = sum(closes[-60:]) / 60 if len(closes) >= 60 else None # RSI rsi = None if len(closes) >= 15: prices_for_rsi = closes[-15:] gains = [] losses = [] for i in range(1, len(prices_for_rsi)): diff = prices_for_rsi[i] - prices_for_rsi[i-1] gains.append(max(diff, 0)) losses.append(max(-diff, 0)) avg_gain = sum(gains[-14:]) / 14 avg_loss = sum(losses[-14:]) / 14 if avg_loss == 0: rsi = 100.0 else: rs = avg_gain / avg_loss rsi = 100 - (100 / (1 + rs)) indicators["rsi"] = rsi # Stochastic stochastic_k = None stochastic_d = None if len(chart_data) >= 14: recent = chart_data[-14:] highs = [float(item.get("stck_hgpr", 0)) for item in recent] lows = [float(item.get("stck_lwpr", 0)) for item in recent] closes_for_stoch = [float(item.get("stck_clpr", 0)) for item in recent] highest_high = max(highs) lowest_low = min(lows) if highest_high != lowest_low: stochastic_k = ((closes_for_stoch[-1] - lowest_low) / (highest_high - lowest_low)) * 100 stochastic_d_vals = [] for j in range(len(recent)-3, len(recent)): slice_high = max([float(item.get("stck_hgpr", 0)) for item in recent[j-3 if j-3>=0 else 0:j+1]]) slice_low = min([float(item.get("stck_lwpr", 0)) for item in recent[j-3 if j-3>=0 else 0:j+1]]) if slice_high != slice_low: stochastic_d_vals.append(((float(recent[j].get("stck_clpr", 0)) - slice_low) / (slice_high - slice_low)) * 100) if stochastic_d_vals: stochastic_d = sum(stochastic_d_vals) / len(stochastic_d_vals) indicators["stochastic_k"] = stochastic_k indicators["stochastic_d"] = stochastic_d # Bollinger Bands bollinger_upper = bollinger_lower = bollinger_middle = None if len(closes) >= 20: window = closes[-20:] middle = sum(window) / len(window) variance = sum((p - middle) ** 2 for p in window) / len(window) stddev = variance ** 0.5 bollinger_middle = middle bollinger_upper = middle + 2 * stddev bollinger_lower = middle - 2 * stddev indicators["bollinger_upper"] = bollinger_upper indicators["bollinger_lower"] = bollinger_lower indicators["bollinger_middle"] = bollinger_middle # ATR/ADX atr_14_val = None adx_14_val = None try: if len(chart_data) >= 15: _h = [float(d.get("stck_hgpr", 0)) for d in chart_data] _l = [float(d.get("stck_lwpr", 0)) for d in chart_data] _c = [float(d.get("stck_clpr", 0)) for d in chart_data] atr_14_val = calculate_atr(_h, _l, _c) if len(chart_data) >= 29: adx_14_val = calculate_adx(_h, _l, _c) except Exception as _e: warn_throttled(logger, "market_analyzer:426", f"ATR/ADX 계산 실패: {_e}") indicators["atr_14"] = atr_14_val indicators["adx_14"] = adx_14_val 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 # market_data._CHART_SPLIT_MISMATCH_RATIO 와 같은 값(테스트로 동치 고정). 순환 import 회피용 사본. _KRX_SPLIT_MISMATCH_RATIO = 0.20 _KRX_OVERRIDE_FIELDS = ("stck_clpr", "stck_oprc", "stck_hgpr", "stck_lwpr", "acml_vol") def _merge_krx_over_naver_v2( naver_rows: Optional[List[dict]], krx_rows: Optional[List[dict]], ) -> Optional[List[dict]]: """네이버 일봉(oldest first)의 같은 날짜 봉을 KRX(stock_daily_prices) 값으로 덮어쓴다. - 네이버 종가가 KRX 와 어긋나(실측 82% 봉 >0.05%) 백테스트·상관과 다른 가격열로 RSI/BB 게이트가 판정되던 문제 대응. KRX 에 없는 날짜(당일 장중 봉, DB 결측일)는 네이버 봉을 그대로 두어 봉 연속성을 유지한다. - KRX 봉의 종가/시가/고가/저가가 0·결측이면 덮어쓰지 않는다. - 입력 배열은 변경하지 않고 새 리스트를 반환한다. """ if not naver_rows: return naver_rows krx_by_date = {} for r in krx_rows or []: d = str(r.get("stck_bsop_date", "") or "") try: if d and all(float(r.get(k, 0) or 0) > 0 for k in ("stck_clpr", "stck_oprc", "stck_hgpr", "stck_lwpr")): krx_by_date[d] = r except (TypeError, ValueError): continue # 분할·병합 가드: 겹치는 날짜 종가가 20% 넘게 어긋나면(원주가 vs 수정주가) 덮어쓰면 # 한 배열 안에 가짜 급락/급등 봉이 생기므로 병합하지 않고 네이버 값을 유지한다. for n in naver_rows: k = krx_by_date.get(str(n.get("stck_bsop_date", "") or "")) if k is None: continue try: nc = float(n.get("stck_clpr", 0) or 0) kc = float(k.get("stck_clpr", 0) or 0) except (TypeError, ValueError): continue if nc > 0 and kc > 0 and abs(kc / nc - 1) > _KRX_SPLIT_MISMATCH_RATIO: logger.warning("KRX 병합 생략(분할/수정주가 불연속 추정): %s 네이버 %.0f vs KRX %.0f", n.get("stck_bsop_date"), nc, kc) return list(naver_rows) merged = [] for n in naver_rows: k = krx_by_date.get(str(n.get("stck_bsop_date", "") or "")) if k is None: merged.append(n) continue row = dict(n) for key in _KRX_OVERRIDE_FIELDS: if key in k: row[key] = k[key] row["krx_overridden"] = True merged.append(row) return merged async def _fetch_krx_rows_v2(stock_code: str, limit: int = 240) -> List[dict]: """stock_daily_prices 최근 limit 행을 KIS 형식(oldest first)으로. 실패 시 [] (네이버 유지).""" try: from sqlalchemy import select as _sa_select from app.core.database import AsyncSessionLocal from app.models.models import StockDailyPrice # DB-READ-EXCEPTION: 배치/분석용 일봉 SELECT 전용(commit·add 없음). 주문·콜백 경로 아님. async with AsyncSessionLocal() as db: res = await db.execute( _sa_select(StockDailyPrice) .where(StockDailyPrice.stock_code == stock_code) .order_by(StockDailyPrice.trade_date.desc()) .limit(limit) ) rows = res.scalars().all() except Exception as e: logger.warning("KRX 일봉 조회 실패 %s (네이버 값 유지): %s", stock_code, e) return [] return [ { "stck_bsop_date": r.trade_date.strftime("%Y%m%d"), "stck_clpr": str(r.close_price or 0), "stck_oprc": str(r.open_price or 0), "stck_hgpr": str(r.high_price or 0), "stck_lwpr": str(r.low_price or 0), "acml_vol": str(r.volume or 0), } for r in reversed(rows) ] # ─── 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. (기존 시그니처 유지)""" 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. """ stock_code = chart_info["stock_code"] stock_name = chart_info["stock_name"] current_price = chart_info["current_price"] price_date = chart_info["price_date"] volume = chart_info["volume"] change_rate = chart_info["change_rate"] closes = chart_info["closes"] volumes = chart_info["volumes"] ma5 = indicators["ma5"] ma20 = indicators["ma20"] ma60 = indicators["ma60"] rsi = indicators["rsi"] stochastic_k = indicators["stochastic_k"] stochastic_d = indicators["stochastic_d"] bollinger_upper = indicators["bollinger_upper"] bollinger_lower = indicators["bollinger_lower"] bollinger_middle = indicators["bollinger_middle"] atr_14_val = indicators["atr_14"] adx_14_val = indicators["adx_14"] avg_vol_20 = sum(volumes[-20:]) / 20 if len(volumes) >= 20 else 0 # 거래량비: 장중 부분봉이면 직전 20봉 평균 × 경과 세션 비율로 정규화 (마감 봉은 기존과 동일) volume_ratio = _volume_ratio_v2(volumes, volume, price_date) # 유동성 필터: 20일 평균 거래량 기준으로 판단 liquidity_vol = avg_vol_20 if avg_vol_20 > 0 else volume if liquidity_vol < 100_000: logger.info("필터[거래량] %s: 20일평균 %.0f주 < 10만주", stock_code, liquidity_vol) return None if not criteria.for_batch and avg_vol_20 < 50000 and current_price < 50000: return None disparity = (current_price / ma20) * 100 if ma20 else 100 trend_ok = None if TREND_FILTER_ENABLED: trend_ok = _passes_uptrend(closes, TREND_MA_SHORT, TREND_MA_LONG) signals = [] score = 0 if disparity > criteria.max_disparity: score -= 5 signals.append(f"이격도 과열({disparity:.1f}% > {criteria.max_disparity}%) - 매수금지") if criteria.use_overheat_filter and OVERHEAT_FILTER_ENABLED: overheat = analyze_overheat(closes, ma20) if overheat["blocked"]: score -= 10 signals.append(overheat["reason"]) if criteria.use_volatility_breakout: vb_result = analyze_volatility_breakout(chart_data) if vb_result and vb_result["signal"] == "buy": signals.append(vb_result["description"]) score += 4 custom_result = analyze_custom_signals(chart_data) if custom_result: if custom_result["signal"] == "buy": signals.append(custom_result["description"]) score += 3 elif custom_result["signal"] == "sell": signals.append(custom_result["description"]) score -= 2 elif custom_result["signal"] == "exit": signals.append(custom_result["description"]) score -= 5 elif custom_result["signal"] == "take_profit": signals.append(custom_result["description"]) score += 2 if criteria.use_macd: result = analyze_macd(chart_data) if result["signal"] == "buy": signals.append(result["description"]) score += 2 rsi_buy_triggered = False if criteria.use_rsi: result = analyze_rsi(chart_data) if result["signal"] == "buy": rsi_buy_triggered = True if volume_ratio >= 1.2: signals.append(result["description"] + " + 거래량증가") score += 3 elif volume_ratio >= 1.0: signals.append(result["description"] + " (거래량보통)") score += 1 else: signals.append(result["description"] + " (거래량부족-차단)") if criteria.use_bollinger: result = analyze_bollinger(chart_data) if result["signal"] == "buy": signals.append(result["description"]) score += 1 if criteria.use_moving_average: result = analyze_moving_average(chart_data) if result["signal"] == "buy": signals.append(result["description"]) score += 2 stochastic_buy_triggered = False if criteria.use_stochastic: result = analyze_stochastic(chart_data) if result["signal"] == "buy": stochastic_buy_triggered = True signals.append(result["description"]) score += 0.5 if criteria.use_adx: result = analyze_adx(chart_data) if result["signal"] == "buy": signals.append(result["description"]) score += 1 if criteria.use_obv: result = analyze_obv(chart_data) if result["signal"] == "buy": signals.append(result["description"]) score += 1 # RSI + 스토캐스틱 복합 신호 보너스 if rsi_buy_triggered and stochastic_buy_triggered: signals.append("RSI+스토캐스틱 복합 과매도") score += 2 if volume_ratio >= 1.5 and change_rate > 0: signals.append(f"거래량 실린 상승 ({volume_ratio:.1f}배)") score += 3 elif volume_ratio < 1.0: score -= 1 if TREND_FILTER_ENABLED and trend_ok is not None: if trend_ok: signals.append(f"추세상승(>{TREND_MA_SHORT}MA & {TREND_MA_SHORT}MA>{TREND_MA_LONG}MA, tol={int(TREND_TOLERANCE_PCT*10000)/100:.2f}%)") score += 1 else: signals.append(f"추세약함({TREND_MA_SHORT}MA≤{TREND_MA_LONG}MA 또는 종가≤{TREND_MA_SHORT}MA)") if criteria.min_volume_ratio > 0 and volume_ratio < criteria.min_volume_ratio: return None ml_score_val = None ml_confidence_val = None ml_signal_val = None if settings.ML_ENSEMBLE_ENABLED: # 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)) 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 return StockRecommendation( stock_code=stock_code, stock_name=stock_name, current_price=current_price, change_rate=change_rate, signals=signals if signals else ["조건 분석 중"], score=final_score, price_date=price_date, price_stale=_is_stale_intraday_bar_v2(price_date), volume=volume, rsi=rsi, ma5=ma5, ma20=ma20, ma60=ma60, stochastic_k=stochastic_k, stochastic_d=stochastic_d, bollinger_upper=bollinger_upper, bollinger_lower=bollinger_lower, bollinger_middle=bollinger_middle, ml_score=ml_score_val, ml_confidence=ml_confidence_val, ml_signal=ml_signal_val, atr_14=atr_14_val, adx_14=adx_14_val, ) return None def _analyze_chart_items( chart_data: List[dict], stock_code: str, stock_name: str, criteria: "RecommendationCriteria", ) -> Optional[StockRecommendation]: """KIS 형식 chart_data (oldest first)로 기술적 분석 수행 — API 호출 없음. analyze_stock / analyze_stock_for_batch_db 양쪽에서 공유하는 순수 분석 로직. 주요 단계: 1. 차트 데이터 추출 및 검증 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: return None # 2. Calculate technical indicators indicators = _calculate_technical_indicators(chart_data, chart_info["closes"]) # 3. Build analysis result with signals and scoring return _build_analysis_result_v2( chart_data, chart_info, indicators, criteria, ml_chart_data=ml_chart_data, ) async def _analyze_chart_items_offload_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_v2 를 워커 스레드로 오프로드 (ML 추론이 이벤트 루프를 막지 않게). 종목당 소요시간을 기록하고 느리면 warning. """ t0 = time.monotonic() try: return await asyncio.to_thread( _analyze_chart_items_v2, chart_data, stock_code, stock_name, criteria, ml_chart_data, ) finally: elapsed = time.monotonic() - t0 if elapsed >= 1.0: logger.warning("[분석] %s 처리 %.2fs (ML 포함, 스레드 오프로드)", stock_code, elapsed) else: logger.info("[분석] %s 처리 %.3fs", stock_code, elapsed) async def analyze_stock(stock_code: str, stock_name: str, criteria: RecommendationCriteria) -> Optional[StockRecommendation]: """차트를 조회한 뒤 _analyze_chart_items에 위임. 배치 분석(for_batch=True)일 때는 네이버 일봉 데이터를 사용하여 KIS API 호출량을 절감한다. 실패 시 KIS API로 폴백. """ try: chart_data = None ml_chart_data = None # 배치 분석: 네이버 일봉 우선 사용 if criteria.for_batch: try: from app.services.naver_finance import get_naver_daily_chart # 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 는 봉수 의존). if ml_chart_data: # 룰/ML 지표를 KRX 가격열로 정합(백테스트·상관과 동일 계열). 모니터링 통계도 누적. krx_rows = await _fetch_krx_rows_v2(stock_code) if krx_rows: try: from app.services.naver_finance import compare_naver_with_krx_v2 compare_naver_with_krx_v2(ml_chart_data, krx_rows) except Exception as e: logger.warning("네이버-KRX 대조 실패 %s: %s", stock_code, e) ml_chart_data = _merge_krx_over_naver_v2(ml_chart_data, krx_rows) 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 # 주의: get_daily_chart 는 실측 30행 상한(swing/entry_service 주석) — 80봉 미달이면 ML 스킵(§8.5-24) 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 await _analyze_chart_items_offload_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 async def analyze_stock_for_batch_db( chart_data_newest_first: List[dict], stock_code: str, stock_name: str, ) -> Optional[StockRecommendation]: """DB에서 가져온 chart_data (newest first)로 배치 분석 — KIS API 호출 없음.""" criteria = RecommendationCriteria( use_macd=True, use_rsi=True, use_bollinger=True, use_moving_average=True, use_stochastic=True, use_adx=True, use_obv=True, use_volatility_breakout=True, min_volume_ratio=0, max_disparity=200.0, for_batch=True, ) try: chart_data = list(reversed(chart_data_newest_first)) # oldest first return await _analyze_chart_items_offload_v2(chart_data, stock_code, stock_name, criteria) except Exception as e: logger.error(f"Error analyzing {stock_code}: {str(e)}") return None async def analyze_stock_for_batch(stock_code: str, stock_name: str) -> Optional[StockRecommendation]: criteria = RecommendationCriteria( use_macd=True, use_rsi=True, use_bollinger=True, use_moving_average=True, use_stochastic=True, use_adx=True, use_obv=True, use_volatility_breakout=True, min_volume_ratio=0, max_disparity=200.0, for_batch=True, ) return await analyze_stock(stock_code, stock_name, criteria) async def analyze_stock_with_indicators(stock_code: str, stock_name: str) -> Optional[StockRecommendation]: criteria = RecommendationCriteria( use_macd=True, use_rsi=True, use_bollinger=True, use_moving_average=True, use_stochastic=True, use_adx=True, use_obv=True, use_volatility_breakout=True, min_volume_ratio=1.2, max_disparity=110.0 ) return await analyze_stock(stock_code, stock_name, criteria) async def get_all_tradable_stocks() -> List[tuple]: """네이버 파이낸스에서 KOSPI+KOSDAQ 시가총액 순위 종목 조회. KIS volume-rank API 대신 네이버 파이낸스 크롤링을 사용한다. 실서버 API 키 불필요. """ try: from app.services.naver_finance import get_naver_stock_list stocks = await get_naver_stock_list(max_pages_per_market=10) return stocks except Exception as e: logger.error(f"네이버 파이낸스 종목 조회 실패: {e}", exc_info=True) return [] async def check_market_regime_with_playbook() -> dict: """check_market_regime() 결과에 market_playbook 오버레이를 적용. 기존 check_market_regime() 시그니처를 변경하지 않고, 플레이북(FOMC, 옵션 만기, 서킷브레이커 등) 반영이 필요한 호출자를 위한 새 함수. 반환 dict에 'playbook' 키가 추가되며, playbook.entry_allowed=False 이면 regime='event_blocked' 로 오버라이드. """ regime_result = await check_market_regime() try: from app.services.market_playbook import apply_playbook_to_regime return apply_playbook_to_regime(regime_result) except Exception as e: warn_throttled(logger, "market_analyzer:944", f"플레이북 적용 실패 (원본 유지): {e}") return regime_result