Files
auto-stock/backend/app/services/market_analyzer.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

945 lines
38 KiB
Python

"""
시장 분석 서비스
- 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