Files
auto-stock/backend/app/services/market_analyzer.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

1212 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
시장 분석 서비스
- 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