""" 시장 분석 서비스 - 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 ) logger = logging.getLogger(__name__) KST = pytz.timezone('Asia/Seoul') def _get_kst_now(): return datetime.now(KST).replace(tzinfo=None) 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 = "" 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 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: logger.debug("HMM 앙상블 조회 실패 (규칙 기반 유지): %s", e) return {"hmm": None, "ensemble": None} async def check_market_regime() -> dict: try: if not is_market_open(): return {"regime": "neutral", "regime_200d": "bear", "message": "장외 시간 - 시장 레짐 판별 스킵", "position_multiplier": 0.5} 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 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 # 205봉 미만이면 MA200/SIDEWAYS 판별 불가 → KIS 폴백(240봉 페이지네이션)으로 넘김 if naver_data and len(naver_data) >= 205: chart_data = list(reversed(naver_data)) # → newest-first except Exception: pass if chart_data is None: _api = _real_market_api or korea_investment_api kospi_chart = await _api.get_daily_chart("069500", "D", limit=240) if "output2" not in kospi_chart or not kospi_chart["output2"]: # 국면 판별 불가 시 신규 진입 차단 (기존 0.5배 매매 지속 → 무국면 매매가 실손실 원인) return {"regime": "neutral", "regime_200d": "bear", "message": "시장 지수 확인 불가 — 신규 진입 차단", "position_multiplier": 0.0, "min_score": 9999, "max_trades": 0} chart_data = kospi_chart["output2"] # newest-first # closes: newest-first (closes[0]=최신, closes[-1]=가장 오래된) closes = [float(item.get("stck_clpr", 0)) for item in chart_data] if len(closes) < 60: return {"regime": "neutral", "regime_200d": "bear", "message": "데이터 부족 — 신규 진입 차단", "position_multiplier": 0.0, "min_score": 9999, "max_trades": 0} 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.warning(f"국면 판별 불가: 일봉 {len(closes)}개 < 205 — 신규 진입 차단") 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 결과와 앙상블 _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 # ── 당일 급락 감지 (사이드카/전쟁 등) ─────────────────────── # 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": f"횡보장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, ADX<20 또는 기울기~0) — 진입 차단", "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: logger.error(f"시장 지수 확인 실패: {e}") return {"regime": "neutral", "message": "시장 지수 확인 실패", "position_multiplier": 0.5} 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: logger.debug(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 # ─── 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 volume_ratio = (volume / avg_vol_20) if avg_vol_20 > 0 else 0 # 유동성 필터: 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, 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_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 는 봉수 의존). 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_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 _analyze_chart_items(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: logger.debug(f"플레이북 적용 실패 (원본 유지): {e}") return regime_result