- 스윙 체결가 확인 전 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
1212 lines
51 KiB
Python
1212 lines
51 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
|
||
)
|
||
|
||
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 |