Files
auto-stock/backend/app/services/market_analyzer.py
shuim 028a1c352b feat: 자동매매 신뢰성 개선 — 시세 실서버 라우팅·일일 한도 전역화·수동 매수 API
- korea_investment_api: KIS_QUOTES_VIA_REAL — 모의투자 모드에서 시세/차트/호가 조회를
  실계좌 시세 서버(20/s)로 위임, 주문·잔고는 모의서버 유지 (EGW00201 대응)
- recommendations: execute_auto_trade_background asyncio.Lock 중복 실행 방지 +
  MAX_DAILY_TRADES를 당일 BUY 건수 차감 전역 기준으로 적용 (11종목 동시 매수 재발 방지)
- manual_buy.py 신규: POST /api/v1/portfolio/buy (리스크 가드 + safe_place_order + db_worker)
- mcp-server: claude_code_client(claude -p 헤드리스 모드) 추가, trade_monitor 부분매도 시
  active 유지·remaining_pct 갱신, API 키 헤더·/api/v1 경로 수정, ODNO 주문ID 수정
- config: MCP_REQUEST_TIMEOUT, MCP_RISK_CACHE_TTL, BATCH_ANALYSIS_INTERVAL,
  SWING_INDEX_BREAKER_ENABLED(OFF), API_TRIGGERED_AUTO_TRADE(OFF) 추가
- docker-compose: 포트 충돌 해소 (postgres 15433, frontend 3001)
- frontend: 추천 패널 매수 버튼, 차트/호가/저널 패널 개선
- CLAUDE.md 압축 최적화 (규칙 변경 없음), _sweep_single_axis.py 백테스트 단일축 스윕

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:37:08 +09:00

791 lines
31 KiB
Python

"""
시장 분석 서비스
- KIS API를 통해 관심 종목(watchlist)의 기술적 지표 일괄 분석
- 시장 국면(bull/bear/sideways) 판별 및 추천 종목 선별
- OVERHEAT(급등 과열) 필터, 추세 필터 적용
- 분석 결과를 StockAnalysis DB에 upsert하고 Redis 캐시에 저장
"""
import asyncio
import logging
import time
import pytz
from datetime import datetime
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
def _build_analysis_result(
chart_data: List[dict],
chart_info: dict,
indicators: dict,
criteria: "RecommendationCriteria",
) -> Optional[StockRecommendation]:
"""Build analysis result with signals and score.
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:
try:
from app.services.ml_ensemble import get_ensemble
ensemble = get_ensemble()
if ensemble.is_trained:
ml_result = ensemble.predict(chart_data)
if "error" not in ml_result:
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%})")
except Exception:
pass
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. 분석 결과 생성 (신호 및 점수)
"""
# 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(chart_data, chart_info, indicators, criteria)
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
# 배치 분석: 네이버 일봉 우선 사용
if criteria.for_batch:
try:
from app.services.naver_finance import get_naver_daily_chart
chart_data = await get_naver_daily_chart(stock_code)
except Exception:
pass
# 네이버 실패 또는 실시간 분석 → KIS API 폴백
if chart_data is None:
_api = _real_market_api or korea_investment_api
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"]))
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_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