9/23~9/28 닷새간 stock_analysis.ml_score 가 200/200 NULL 이었고 로그는 0줄이었다.
원인 3건이 겹쳐 있었다.
1. 시계열 역방향 (추론 ≠ 학습)
market_analyzer 는 chart_data 를 oldest-first 로 정규화해 넘기는데
chart_data_to_dataframe() 은 "입력은 최신순" 가정으로 무조건 뒤집는다.
→ ML 피처가 역방향 시계열에서 계산됐다. 학습은 db_data_to_dataframe
(trade_date ASC, 정방향)이므로 추론이 학습과 정반대 방향이었다.
ensemble_score 관측 범위가 18~50 에 갇혀 ML BUY 가 0건이던 것의 직접 원인.
→ chart_data_to_dataframe_v2(): stck_bsop_date 오름차순 정렬로 방향 의존성 제거.
2. LSTM 시퀀스 워밍업 NaN → int(nan) ValueError
탭뷸러는 NaN 검사를 받지만 시퀀스는 검사되지 않았다. 시퀀스 피처
volatility_20d 의 첫 유효 인덱스가 20 이므로 요구 봉수는 60+20=80 인데
게이트가 <61 이어서 61~79 구간이 크래시 밴드였다.
NaN → LSTM 이 [nan,nan,nan] 반환(예외 아님) → 가중평균 전체 NaN
→ ml_ensemble.py int(prob_up*100) 에서 ValueError.
→ MIN_BARS_FOR_PREDICT=80 + 시퀀스 유한성 검사. NaN 을 0 으로 채우지 않고
사유를 담은 error 로 반환한다.
prepare_training_data 도 idx 60~79 샘플 시퀀스에 NaN 을 넣고 있었다
(valid_mask[:60]=False 만 존재) → X_seq.mean 이 NaN → seq_mean/std 오염.
3. except Exception: pass 가 1·2 를 조용히 삼켰다
→ _run_ml_ensemble() 분리 + 사유별 집계(get/reset_ml_skip_stats,
log_ml_skip_summary). 최초 1건 + 이후 50건마다 warning, 사이클 종료 시
사유별 1줄 요약. batch_daily_analysis 에 배선.
부수 수정
- 네이버 ML 차트 80일창(75봉) → 200일창(154봉). 룰 입력은 _trim_to_legacy_window
로 기존 창과 동일 배열로 절단 — adx_wilder 는 Wilder 재귀라 배열 길이에
민감하므로 절단 없이는 SIDEWAYS 히스테리시스 판정이 바뀐다(실측 14.21→14.55).
전 지표 동일성 확인: rsi/ma/bb/atr/adx/stochastic 모두 일치.
- predict() 가 어떤 입력에도 예외를 던지지 않도록 얇은 가드 + _predict_impl 분리.
비유한 확률을 내는 모델은 warning 과 함께 앙상블에서 제외(degraded_models).
- training_metrics 가 항상 {} 였던 원인: self.training_metrics = metrics 가
_save_models() 뒤에 있어 직전 값이 기록됨. 순서 정정 + per-class 지표 추가
("전부 HOLD" 모델을 정확도만으로 판별 못하는 문제 대응).
- endpoints/ml_ensemble.py: get_daily_chart 는 output2 를 채우는데 output 을
읽어 항상 400 이었다. 게이트 61→MIN_BARS_FOR_PREDICT, async 핸들러의
동기 predict() → predict_async().
미변경: 모델 파일, 아키텍처·하이퍼파라미터, 임계값(60/0.3/0.7, 가중치
0.4/0.35/0.25), torch.load(weights_only=True), 지표 계산식, KIS 호출량.
검증: 579 passed / 0 failed (소켓·KIS 차단 하, 기준선 559 + 신규 20).
신규 tests/test_ml_feature_direction.py 는 결함 역주입으로 검출력 증명 —
방향 역전 2건 + 크래시 밴드 61/70/79 3건 + 게이트 1건 = 6건 실패 확인.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
945 lines
38 KiB
Python
945 lines
38 KiB
Python
"""
|
|
시장 분석 서비스
|
|
|
|
- KIS API를 통해 관심 종목(watchlist)의 기술적 지표 일괄 분석
|
|
- 시장 국면(bull/bear/sideways) 판별 및 추천 종목 선별
|
|
- OVERHEAT(급등 과열) 필터, 추세 필터 적용
|
|
- 분석 결과를 StockAnalysis DB에 upsert하고 Redis 캐시에 저장
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
import time
|
|
import pytz
|
|
from datetime import datetime, timedelta
|
|
from typing import List, Optional
|
|
from pydantic import BaseModel
|
|
|
|
from app.core.config import settings
|
|
from app.core.market_schedule import is_market_open
|
|
from app.services.korea_investment_api import korea_investment_api, real_market_api as _real_market_api
|
|
from app.services.mean_reversion_strategy import (
|
|
MarketRegime, detect_market_regime, calculate_atr, calculate_adx
|
|
)
|
|
from app.services.technical_indicators import (
|
|
TREND_FILTER_ENABLED, TREND_MA_SHORT, TREND_MA_LONG, TREND_TOLERANCE_PCT,
|
|
OVERHEAT_FILTER_ENABLED, _passes_uptrend, analyze_overheat,
|
|
analyze_custom_signals, analyze_macd, analyze_rsi, analyze_bollinger,
|
|
analyze_moving_average, analyze_stochastic, analyze_volatility_breakout,
|
|
analyze_adx, analyze_obv
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
KST = pytz.timezone('Asia/Seoul')
|
|
|
|
def _get_kst_now():
|
|
return datetime.now(KST).replace(tzinfo=None)
|
|
|
|
class RecommendationCriteria(BaseModel):
|
|
use_macd: bool = False
|
|
use_rsi: bool = False
|
|
use_bollinger: bool = False
|
|
use_moving_average: bool = False
|
|
use_stochastic: bool = False
|
|
use_adx: bool = False
|
|
use_obv: bool = False
|
|
use_volatility_breakout: bool = False
|
|
use_overheat_filter: bool = True
|
|
min_volume_ratio: float = 1.0
|
|
max_disparity: float = 200.0
|
|
for_batch: bool = False
|
|
|
|
class StockRecommendation(BaseModel):
|
|
stock_code: str
|
|
stock_name: str
|
|
current_price: float
|
|
change_rate: float
|
|
signals: List[str]
|
|
score: float
|
|
price_date: str = ""
|
|
volume: Optional[int] = None
|
|
rsi: Optional[float] = None
|
|
ma5: Optional[float] = None
|
|
ma20: Optional[float] = None
|
|
ma60: Optional[float] = None
|
|
stochastic_k: Optional[float] = None
|
|
stochastic_d: Optional[float] = None
|
|
bollinger_upper: Optional[float] = None
|
|
bollinger_lower: Optional[float] = None
|
|
bollinger_middle: Optional[float] = None
|
|
ml_score: Optional[int] = None
|
|
ml_confidence: Optional[float] = None
|
|
ml_signal: Optional[str] = None
|
|
atr_14: Optional[float] = None
|
|
adx_14: Optional[float] = None
|
|
|
|
_market_regime_cache: dict = {"result": None, "ts": 0.0}
|
|
_MARKET_REGIME_CACHE_TTL = 120
|
|
|
|
async def _get_hmm_ensemble_info(
|
|
rule_regime_str: str,
|
|
closes_oldest_first: Optional[List[float]] = None,
|
|
volumes_oldest_first: Optional[List[float]] = None,
|
|
) -> dict:
|
|
"""HMM 앙상블 결과를 안전하게 가져온다.
|
|
|
|
HMM 미설치, 모델 없음, 예측 실패 시 모두 rule_only fallback.
|
|
기존 check_market_regime 로직에 영향 없이 optional 정보를 추가한다.
|
|
"""
|
|
try:
|
|
from app.services.regime_hmm import (
|
|
is_hmm_available, predict_current_regime, ensemble_regime,
|
|
)
|
|
if not is_hmm_available():
|
|
return {"hmm": None, "ensemble": None}
|
|
|
|
hmm_result = await predict_current_regime(
|
|
closes=closes_oldest_first,
|
|
volumes=volumes_oldest_first,
|
|
)
|
|
ensemble = ensemble_regime(hmm_result, rule_regime_str)
|
|
return {"hmm": hmm_result, "ensemble": ensemble}
|
|
except Exception as e:
|
|
logger.debug("HMM 앙상블 조회 실패 (규칙 기반 유지): %s", e)
|
|
return {"hmm": None, "ensemble": None}
|
|
|
|
async def check_market_regime() -> dict:
|
|
try:
|
|
if not is_market_open():
|
|
return {"regime": "neutral", "regime_200d": "bear", "message": "장외 시간 - 시장 레짐 판별 스킵", "position_multiplier": 0.5}
|
|
|
|
now_ts = time.monotonic()
|
|
if _market_regime_cache["result"] is not None and (now_ts - _market_regime_cache["ts"]) < _MARKET_REGIME_CACHE_TTL:
|
|
return _market_regime_cache["result"]
|
|
|
|
# ── 데이터 수집: chart_data는 항상 newest-first 순서로 통일 ──
|
|
# naver_data: oldest-first → reversed → newest-first
|
|
# KIS output2: newest-first (API 기본)
|
|
chart_data = None
|
|
try:
|
|
from app.services.naver_finance import get_naver_daily_chart
|
|
# MA200+기울기(5봉) 판별에 205봉 이상 필요 — 캘린더일 기준이므로 400일 요청(≈270거래일)
|
|
naver_data = await get_naver_daily_chart("069500", days=400) # oldest-first
|
|
# 205봉 미만이면 MA200/SIDEWAYS 판별 불가 → KIS 폴백(240봉 페이지네이션)으로 넘김
|
|
if naver_data and len(naver_data) >= 205:
|
|
chart_data = list(reversed(naver_data)) # → newest-first
|
|
except Exception:
|
|
pass
|
|
|
|
if chart_data is None:
|
|
_api = _real_market_api or korea_investment_api
|
|
kospi_chart = await _api.get_daily_chart("069500", "D", limit=240)
|
|
if "output2" not in kospi_chart or not kospi_chart["output2"]:
|
|
# 국면 판별 불가 시 신규 진입 차단 (기존 0.5배 매매 지속 → 무국면 매매가 실손실 원인)
|
|
return {"regime": "neutral", "regime_200d": "bear", "message": "시장 지수 확인 불가 — 신규 진입 차단",
|
|
"position_multiplier": 0.0, "min_score": 9999, "max_trades": 0}
|
|
chart_data = kospi_chart["output2"] # newest-first
|
|
|
|
# closes: newest-first (closes[0]=최신, closes[-1]=가장 오래된)
|
|
closes = [float(item.get("stck_clpr", 0)) for item in chart_data]
|
|
|
|
if len(closes) < 60:
|
|
return {"regime": "neutral", "regime_200d": "bear", "message": "데이터 부족 — 신규 진입 차단",
|
|
"position_multiplier": 0.0, "min_score": 9999, "max_trades": 0}
|
|
|
|
def _cache_and_return(result: dict) -> dict:
|
|
_market_regime_cache["result"] = result
|
|
_market_regime_cache["ts"] = time.monotonic()
|
|
return result
|
|
|
|
# ── 수동 비상 정지 ────────────────────────────────────────────
|
|
if settings.TRADING_HALTED:
|
|
return _cache_and_return({
|
|
"regime": "halted",
|
|
"regime_200d": "bear",
|
|
"message": "⛔ TRADING_HALTED=true — 수동 비상 정지 중 (매수 전면 차단)",
|
|
"position_multiplier": 0.0,
|
|
"min_score": 9999,
|
|
"max_trades": 0,
|
|
})
|
|
|
|
# closes는 newest-first: closes[0]=오늘, closes[:20]=최근 20일
|
|
current_price = closes[0]
|
|
ma20 = sum(closes[:20]) / 20
|
|
ma60 = sum(closes[:60]) / 60
|
|
|
|
if len(closes) >= 205:
|
|
# CLAUDE.md 6.1: ADX 기반 SIDEWAYS 판별을 위해 highs/lows도 전달
|
|
# chart_data: newest-first → _highs/_lows도 newest-first
|
|
_highs = [float(item.get("stck_hgpr", 0)) for item in chart_data]
|
|
_lows = [float(item.get("stck_lwpr", 0)) for item in chart_data]
|
|
# detect_market_regime()는 oldest-first 입력 기대 → reversed
|
|
regime_enum = detect_market_regime(
|
|
list(reversed(closes)),
|
|
highs=list(reversed(_highs)),
|
|
lows=list(reversed(_lows)),
|
|
)
|
|
is_bull_200 = regime_enum == MarketRegime.BULL
|
|
is_sideways_200 = regime_enum == MarketRegime.SIDEWAYS
|
|
ma200 = sum(closes[:200]) / 200
|
|
else:
|
|
# 205봉 미만 → MA200/ADX 국면 판별 불가. 기존에는 MA20 프록시로 상승장 판정 후
|
|
# 매매를 지속했으나(실계좌 손실 원인), 판별 불가 시 신규 진입을 차단한다.
|
|
logger.warning(f"국면 판별 불가: 일봉 {len(closes)}개 < 205 — 신규 진입 차단")
|
|
return _cache_and_return({
|
|
"regime": "neutral",
|
|
"regime_200d": "bear",
|
|
"message": f"국면 판별 불가 (일봉 {len(closes)}개 < 205) — 신규 진입 차단",
|
|
"position_multiplier": 0.0,
|
|
"min_score": 9999,
|
|
"max_trades": 0,
|
|
})
|
|
|
|
# ── HMM 앙상블 (optional) ────────────────────────────────────
|
|
# 규칙 기반 결과를 먼저 결정한 뒤, HMM 결과와 앙상블
|
|
_rule_regime_str = "sideways" if is_sideways_200 else ("bull" if is_bull_200 else "bear")
|
|
# HMM/앙상블에는 oldest-first 데이터 전달 (closes: newest-first → reversed)
|
|
_closes_oldest = list(reversed(closes))
|
|
_volumes_oldest = [float(item.get("acml_vol", 0)) for item in reversed(chart_data)]
|
|
hmm_info = await _get_hmm_ensemble_info(_rule_regime_str, _closes_oldest, _volumes_oldest)
|
|
|
|
# HMM 앙상블이 sideways 로 판정하면 규칙 기반도 sideways 로 오버라이드
|
|
_ensemble = hmm_info.get("ensemble")
|
|
if _ensemble and _ensemble.get("method") == "ensemble":
|
|
if _ensemble["regime"] == "sideways" and not is_sideways_200:
|
|
logger.info(
|
|
"HMM 앙상블: 규칙=%s, HMM=%s → sideways 오버라이드 (conf=%.2f)",
|
|
_rule_regime_str, _ensemble.get("hmm_regime"), _ensemble.get("confidence", 0),
|
|
)
|
|
is_sideways_200 = True
|
|
|
|
# ── 당일 급락 감지 (사이드카/전쟁 등) ───────────────────────
|
|
# closes[0]이 오늘 데이터일 때만 적용 (일봉 미확정 시 전일 종가가 closes[0]이 되어 오탐 발생)
|
|
latest_date = chart_data[0].get("stck_bsop_date", "") if chart_data else ""
|
|
today_str = _get_kst_now().strftime("%Y%m%d")
|
|
if latest_date == today_str and len(closes) >= 2 and closes[1] > 0:
|
|
today_change_pct = (closes[0] - closes[1]) / closes[1] * 100
|
|
if today_change_pct <= settings.MARKET_CRASH_HALT_PCT:
|
|
return _cache_and_return({
|
|
"regime": "crash",
|
|
"regime_200d": "bear",
|
|
"message": f"🚨 당일 급락 감지 ({today_change_pct:.1f}% ≤ {settings.MARKET_CRASH_HALT_PCT}%) — 매수 전면 차단",
|
|
"position_multiplier": 0.0,
|
|
"min_score": 9999,
|
|
"max_trades": 0,
|
|
"today_change_pct": round(today_change_pct, 2),
|
|
"hmm_ensemble": _ensemble,
|
|
})
|
|
|
|
consecutive_down = 0
|
|
for i in range(len(closes) - 1):
|
|
if closes[i] < closes[i + 1]:
|
|
consecutive_down += 1
|
|
else:
|
|
break
|
|
|
|
drop_from_ma20 = ((current_price - ma20) / ma20) * 100
|
|
|
|
# CLAUDE.md 6.1: SIDEWAYS 국면 — 모든 진입 신호 무시
|
|
if is_sideways_200:
|
|
return _cache_and_return({
|
|
"regime": "sideways",
|
|
"regime_200d": "sideways",
|
|
"message": f"횡보장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, ADX<20 또는 기울기~0) — 진입 차단",
|
|
"position_multiplier": 0.0,
|
|
"min_score": 9999,
|
|
"max_trades": 0,
|
|
"consecutive_down": consecutive_down,
|
|
"drop_from_ma20": round(drop_from_ma20, 1),
|
|
"hmm_ensemble": _ensemble,
|
|
})
|
|
|
|
if current_price < ma60 or consecutive_down >= 5:
|
|
return _cache_and_return({
|
|
"regime": "strong_bearish",
|
|
"regime_200d": "bear",
|
|
"message": f"강한 하락장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, {consecutive_down}일 연속하락)",
|
|
"position_multiplier": 0.1,
|
|
"min_score": settings.BEAR_MARKET_MIN_SCORE + 3,
|
|
"max_trades": 1,
|
|
"consecutive_down": consecutive_down,
|
|
"drop_from_ma20": round(drop_from_ma20, 1),
|
|
"hmm_ensemble": _ensemble,
|
|
})
|
|
|
|
if current_price < ma20:
|
|
return _cache_and_return({
|
|
"regime": "bearish",
|
|
"regime_200d": "bear",
|
|
"message": f"하락장 (코스피: {current_price:.0f} < MA20: {ma20:.0f}, MA200: {ma200:.0f})",
|
|
"position_multiplier": settings.BEAR_MARKET_POSITION_RATIO,
|
|
"min_score": settings.BEAR_MARKET_MIN_SCORE,
|
|
"max_trades": settings.BEAR_MARKET_MAX_TRADES,
|
|
"consecutive_down": consecutive_down,
|
|
"drop_from_ma20": round(drop_from_ma20, 1),
|
|
"hmm_ensemble": _ensemble,
|
|
})
|
|
|
|
return _cache_and_return({
|
|
"regime": "bullish",
|
|
"regime_200d": "bull" if is_bull_200 else "bear",
|
|
"message": f"상승장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, Bull={is_bull_200})",
|
|
"position_multiplier": 1.0,
|
|
"min_score": 1,
|
|
"max_trades": settings.MAX_DAILY_TRADES,
|
|
"consecutive_down": consecutive_down,
|
|
"drop_from_ma20": round(drop_from_ma20, 1),
|
|
"hmm_ensemble": _ensemble,
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"시장 지수 확인 실패: {e}")
|
|
return {"regime": "neutral", "message": "시장 지수 확인 실패", "position_multiplier": 0.5}
|
|
|
|
|
|
def _extract_chart_data(
|
|
chart_data: List[dict],
|
|
stock_code: str,
|
|
stock_name: str,
|
|
) -> Optional[dict]:
|
|
"""Extract and validate basic chart data from KIS format.
|
|
|
|
Returns dict with: stock_code, stock_name, current_price, price_date, volume,
|
|
change_rate, closes, volumes, or None if validation fails.
|
|
"""
|
|
latest = chart_data[-1] if chart_data else None
|
|
if not latest:
|
|
return None
|
|
|
|
if not stock_name:
|
|
stock_name = latest.get("hts_kor_isnm", stock_code)
|
|
|
|
current_price = float(latest.get("stck_clpr", 0))
|
|
price_date = latest.get("stck_bsop_date", "")
|
|
volume = int(latest.get("acml_vol", 0) or 0)
|
|
if current_price == 0:
|
|
logger.debug("필터[가격0] %s", stock_code)
|
|
return None
|
|
|
|
# 가격 필터: 5,000원 이하 종목 제외 (CLAUDE.md 섹션 5.4: "5,000원 초과"만 허용)
|
|
if current_price <= settings.MIN_PRICE_KRW:
|
|
logger.info("필터[저가] %s: %.0f원 < %d원", stock_code, current_price, settings.MIN_PRICE_KRW)
|
|
return None
|
|
|
|
if len(chart_data) >= 2:
|
|
prev_price = float(chart_data[-2].get("stck_clpr", current_price))
|
|
change_rate = ((current_price - prev_price) / prev_price) * 100 if prev_price > 0 else 0
|
|
else:
|
|
change_rate = 0
|
|
|
|
closes = [float(item.get("stck_clpr", 0)) for item in chart_data]
|
|
volumes = [int(item.get("acml_vol", 0)) for item in chart_data]
|
|
|
|
return {
|
|
"stock_code": stock_code,
|
|
"stock_name": stock_name,
|
|
"current_price": current_price,
|
|
"price_date": price_date,
|
|
"volume": volume,
|
|
"change_rate": change_rate,
|
|
"closes": closes,
|
|
"volumes": volumes,
|
|
"chart_data": chart_data,
|
|
}
|
|
|
|
|
|
def _calculate_technical_indicators(chart_data: List[dict], closes: List[float]) -> dict:
|
|
"""Calculate technical indicators (MA, RSI, Stochastic, Bollinger, ATR, ADX).
|
|
|
|
Returns dict with calculated indicators.
|
|
"""
|
|
indicators = {}
|
|
|
|
# Moving Averages
|
|
indicators["ma5"] = sum(closes[-5:]) / 5 if len(closes) >= 5 else None
|
|
indicators["ma20"] = sum(closes[-20:]) / 20 if len(closes) >= 20 else None
|
|
indicators["ma60"] = sum(closes[-60:]) / 60 if len(closes) >= 60 else None
|
|
|
|
# RSI
|
|
rsi = None
|
|
if len(closes) >= 15:
|
|
prices_for_rsi = closes[-15:]
|
|
gains = []
|
|
losses = []
|
|
for i in range(1, len(prices_for_rsi)):
|
|
diff = prices_for_rsi[i] - prices_for_rsi[i-1]
|
|
gains.append(max(diff, 0))
|
|
losses.append(max(-diff, 0))
|
|
avg_gain = sum(gains[-14:]) / 14
|
|
avg_loss = sum(losses[-14:]) / 14
|
|
if avg_loss == 0:
|
|
rsi = 100.0
|
|
else:
|
|
rs = avg_gain / avg_loss
|
|
rsi = 100 - (100 / (1 + rs))
|
|
indicators["rsi"] = rsi
|
|
|
|
# Stochastic
|
|
stochastic_k = None
|
|
stochastic_d = None
|
|
if len(chart_data) >= 14:
|
|
recent = chart_data[-14:]
|
|
highs = [float(item.get("stck_hgpr", 0)) for item in recent]
|
|
lows = [float(item.get("stck_lwpr", 0)) for item in recent]
|
|
closes_for_stoch = [float(item.get("stck_clpr", 0)) for item in recent]
|
|
highest_high = max(highs)
|
|
lowest_low = min(lows)
|
|
if highest_high != lowest_low:
|
|
stochastic_k = ((closes_for_stoch[-1] - lowest_low) / (highest_high - lowest_low)) * 100
|
|
stochastic_d_vals = []
|
|
for j in range(len(recent)-3, len(recent)):
|
|
slice_high = max([float(item.get("stck_hgpr", 0)) for item in recent[j-3 if j-3>=0 else 0:j+1]])
|
|
slice_low = min([float(item.get("stck_lwpr", 0)) for item in recent[j-3 if j-3>=0 else 0:j+1]])
|
|
if slice_high != slice_low:
|
|
stochastic_d_vals.append(((float(recent[j].get("stck_clpr", 0)) - slice_low) / (slice_high - slice_low)) * 100)
|
|
if stochastic_d_vals:
|
|
stochastic_d = sum(stochastic_d_vals) / len(stochastic_d_vals)
|
|
indicators["stochastic_k"] = stochastic_k
|
|
indicators["stochastic_d"] = stochastic_d
|
|
|
|
# Bollinger Bands
|
|
bollinger_upper = bollinger_lower = bollinger_middle = None
|
|
if len(closes) >= 20:
|
|
window = closes[-20:]
|
|
middle = sum(window) / len(window)
|
|
variance = sum((p - middle) ** 2 for p in window) / len(window)
|
|
stddev = variance ** 0.5
|
|
bollinger_middle = middle
|
|
bollinger_upper = middle + 2 * stddev
|
|
bollinger_lower = middle - 2 * stddev
|
|
indicators["bollinger_upper"] = bollinger_upper
|
|
indicators["bollinger_lower"] = bollinger_lower
|
|
indicators["bollinger_middle"] = bollinger_middle
|
|
|
|
# ATR/ADX
|
|
atr_14_val = None
|
|
adx_14_val = None
|
|
try:
|
|
if len(chart_data) >= 15:
|
|
_h = [float(d.get("stck_hgpr", 0)) for d in chart_data]
|
|
_l = [float(d.get("stck_lwpr", 0)) for d in chart_data]
|
|
_c = [float(d.get("stck_clpr", 0)) for d in chart_data]
|
|
atr_14_val = calculate_atr(_h, _l, _c)
|
|
if len(chart_data) >= 29:
|
|
adx_14_val = calculate_adx(_h, _l, _c)
|
|
except Exception as _e:
|
|
logger.debug(f"ATR/ADX 계산 실패: {_e}")
|
|
indicators["atr_14"] = atr_14_val
|
|
indicators["adx_14"] = adx_14_val
|
|
|
|
return indicators
|
|
|
|
|
|
# ─── 네이버 일봉 봉수 (2026-09-28) ─────────────────────────────────────────
|
|
# get_naver_daily_chart(days=N) 은 [오늘-(N+30)일, 오늘] 캘린더 창을 요청한다.
|
|
# days=80 → 110일 창 → 실측 75~77 거래일 (추석 연휴 포함 시 75)
|
|
# days=200 → 230일 창 → 약 155 거래일
|
|
# ML 최소 요구치는 80봉(시퀀스 60 + 시퀀스 피처 워밍업 20)이므로 기존 기본값으로는
|
|
# 항상 미달이었다. ML 입력만 확장하고 룰 입력은 기존 창으로 자른다.
|
|
_NAVER_ML_CHART_DAYS = 200
|
|
_NAVER_LEGACY_DAYS = 80 # 기존 동작(=get_naver_daily_chart 기본값) 재현용
|
|
|
|
|
|
def _trim_to_legacy_window(
|
|
chart_data: Optional[List[dict]], legacy_days: int,
|
|
) -> Optional[List[dict]]:
|
|
"""확장 조회 결과를 기존 창(days=legacy_days)과 **동일한 배열**로 자른다.
|
|
|
|
get_naver_daily_chart 의 startTime 은 `오늘 - (days+30)` 이므로 같은 경계로 자르면
|
|
기존 호출과 같은 봉 집합이 된다 → RSI/BB/ATR/ADX 등 룰 지표 값이 변하지 않는다.
|
|
(ADX 는 Wilder 재귀라 배열 길이에 민감하므로 이 절단이 필수다.)
|
|
"""
|
|
if not chart_data:
|
|
return chart_data
|
|
cutoff = (datetime.now() - timedelta(days=legacy_days + 30)).strftime("%Y%m%d")
|
|
trimmed = [
|
|
item for item in chart_data
|
|
if str(item.get("stck_bsop_date", "") or "") >= cutoff
|
|
]
|
|
return trimmed if trimmed else chart_data
|
|
|
|
|
|
# ─── ML 앙상블 스킵 집계 (2026-09-28) ───────────────────────────────────────
|
|
# 9/23~9/28 닷새간 ml_score 가 200/200 NULL 이었는데 아무 로그도 남지 않았다.
|
|
# 원인은 `except Exception: pass` 였다. 사유별로 집계해 반드시 눈에 띄게 만든다.
|
|
_ML_SKIP_STATS: dict = {"total": 0, "reasons": {}, "logged": 0}
|
|
_ML_SUCCESS_COUNT: dict = {"n": 0}
|
|
_ML_SKIP_LOG_EVERY = 50 # 최초 1회 + 이후 N회마다 1줄
|
|
_DIGITS_RE = re.compile(r"\d+")
|
|
|
|
|
|
def get_ml_skip_stats() -> dict:
|
|
"""ML 스킵 집계 스냅샷 (배치 사이클 종료 시 요약 로그용)."""
|
|
return {
|
|
"ml_ok": _ML_SUCCESS_COUNT["n"],
|
|
"ml_skipped": _ML_SKIP_STATS["total"],
|
|
"reasons": dict(_ML_SKIP_STATS["reasons"]),
|
|
}
|
|
|
|
|
|
def reset_ml_skip_stats() -> None:
|
|
"""배치 사이클 시작 시 호출 — 집계 초기화."""
|
|
_ML_SKIP_STATS["total"] = 0
|
|
_ML_SKIP_STATS["reasons"] = {}
|
|
_ML_SKIP_STATS["logged"] = 0
|
|
_ML_SUCCESS_COUNT["n"] = 0
|
|
|
|
|
|
def log_ml_skip_summary(context: str = "") -> None:
|
|
"""사이클 종료 시 사유별 집계를 1줄로 남긴다 (warning 수준)."""
|
|
snap = get_ml_skip_stats()
|
|
if snap["ml_skipped"] == 0:
|
|
logger.info("[ML] %s 예측 성공 %d종목, 스킵 0건", context, snap["ml_ok"])
|
|
return
|
|
detail = ", ".join(f"{k}={v}" for k, v in sorted(
|
|
snap["reasons"].items(), key=lambda kv: -kv[1]))
|
|
logger.warning(
|
|
"[ML] %s 스킵 %d종목 (성공 %d종목) — 사유별: %s",
|
|
context, snap["ml_skipped"], snap["ml_ok"], detail,
|
|
)
|
|
|
|
|
|
def _record_ml_skip(stock_code: str, reason: str) -> None:
|
|
"""스킵 사유 집계 + 최초/주기적 warning 로그."""
|
|
# 숫자(봉수 등)를 지워 사유 단위로 묶는다 — "봉수 부족 75/80" 과 "77/80" 이 한 버킷
|
|
key = _DIGITS_RE.sub("N", reason)[:80] if reason else "unknown"
|
|
_ML_SKIP_STATS["total"] += 1
|
|
_ML_SKIP_STATS["reasons"][key] = _ML_SKIP_STATS["reasons"].get(key, 0) + 1
|
|
n = _ML_SKIP_STATS["total"]
|
|
if n == 1 or n % _ML_SKIP_LOG_EVERY == 0:
|
|
_ML_SKIP_STATS["logged"] += 1
|
|
logger.warning("[ML] 예측 스킵 %s: %s (누적 %d건)", stock_code, reason, n)
|
|
|
|
|
|
def _run_ml_ensemble(chart_data: List[dict], stock_code: str) -> Optional[dict]:
|
|
"""ML 앙상블 예측 — 실패는 집계·로그로 드러내고 None 을 반환한다.
|
|
|
|
ML 실패가 룰 기반 분석 저장을 막지 않도록 여기서 흡수하되,
|
|
**조용히 넘기지 않는다**(기존 `except Exception: pass` 가 9/23 무증상의 원인).
|
|
"""
|
|
try:
|
|
from app.services.ml_ensemble import get_ensemble
|
|
ensemble = get_ensemble()
|
|
if not ensemble.is_trained:
|
|
_record_ml_skip(stock_code, "모델 미학습")
|
|
return None
|
|
ml_result = ensemble.predict(chart_data) # 예외를 던지지 않는 계약
|
|
if "error" in ml_result:
|
|
_record_ml_skip(stock_code, f"{ml_result['error']} (봉수 {len(chart_data)})")
|
|
return None
|
|
if ml_result.get("degraded_models"):
|
|
logger.warning(
|
|
"[ML] %s 일부 모델 제외 상태로 예측: %s",
|
|
stock_code, ml_result["degraded_models"],
|
|
)
|
|
_ML_SUCCESS_COUNT["n"] += 1
|
|
return ml_result
|
|
except Exception as e:
|
|
# predict() 는 예외를 내지 않아야 하므로 여기에 오면 계약 위반 = 버그다.
|
|
logger.warning(
|
|
"[ML] %s 예측 중 예상치 못한 예외 (%s): %s",
|
|
stock_code, type(e).__name__, e, exc_info=True,
|
|
)
|
|
_record_ml_skip(stock_code, f"예외 {type(e).__name__}")
|
|
return None
|
|
|
|
|
|
def _build_analysis_result(
|
|
chart_data: List[dict],
|
|
chart_info: dict,
|
|
indicators: dict,
|
|
criteria: "RecommendationCriteria",
|
|
) -> Optional[StockRecommendation]:
|
|
"""Build analysis result with signals and score. (기존 시그니처 유지)"""
|
|
return _build_analysis_result_v2(chart_data, chart_info, indicators, criteria)
|
|
|
|
|
|
def _build_analysis_result_v2(
|
|
chart_data: List[dict],
|
|
chart_info: dict,
|
|
indicators: dict,
|
|
criteria: "RecommendationCriteria",
|
|
ml_chart_data: Optional[List[dict]] = None,
|
|
) -> Optional[StockRecommendation]:
|
|
"""_build_analysis_result + ML 전용 장기 차트 주입 (2026-09-28).
|
|
|
|
Args:
|
|
ml_chart_data: ML 앙상블에만 사용할 더 긴 차트(≥80봉). None 이면 chart_data 사용.
|
|
**룰 지표(RSI/BB/ATR/ADX)는 chart_data 만으로 계산되므로 값이 변하지 않는다.**
|
|
ADX(Wilder)는 배열 전체를 훑는 재귀 지표라 봉수가 바뀌면 값이 달라진다 —
|
|
그래서 룰 경로의 입력은 절대 늘리지 않고 ML 입력만 분리했다.
|
|
|
|
Returns StockRecommendation or None based on score threshold and criteria.
|
|
"""
|
|
stock_code = chart_info["stock_code"]
|
|
stock_name = chart_info["stock_name"]
|
|
current_price = chart_info["current_price"]
|
|
price_date = chart_info["price_date"]
|
|
volume = chart_info["volume"]
|
|
change_rate = chart_info["change_rate"]
|
|
closes = chart_info["closes"]
|
|
volumes = chart_info["volumes"]
|
|
|
|
ma5 = indicators["ma5"]
|
|
ma20 = indicators["ma20"]
|
|
ma60 = indicators["ma60"]
|
|
rsi = indicators["rsi"]
|
|
stochastic_k = indicators["stochastic_k"]
|
|
stochastic_d = indicators["stochastic_d"]
|
|
bollinger_upper = indicators["bollinger_upper"]
|
|
bollinger_lower = indicators["bollinger_lower"]
|
|
bollinger_middle = indicators["bollinger_middle"]
|
|
atr_14_val = indicators["atr_14"]
|
|
adx_14_val = indicators["adx_14"]
|
|
|
|
avg_vol_20 = sum(volumes[-20:]) / 20 if len(volumes) >= 20 else 0
|
|
volume_ratio = (volume / avg_vol_20) if avg_vol_20 > 0 else 0
|
|
|
|
# 유동성 필터: 20일 평균 거래량 기준으로 판단
|
|
liquidity_vol = avg_vol_20 if avg_vol_20 > 0 else volume
|
|
if liquidity_vol < 100_000:
|
|
logger.info("필터[거래량] %s: 20일평균 %.0f주 < 10만주", stock_code, liquidity_vol)
|
|
return None
|
|
|
|
if not criteria.for_batch and avg_vol_20 < 50000 and current_price < 50000:
|
|
return None
|
|
|
|
disparity = (current_price / ma20) * 100 if ma20 else 100
|
|
|
|
trend_ok = None
|
|
if TREND_FILTER_ENABLED:
|
|
trend_ok = _passes_uptrend(closes, TREND_MA_SHORT, TREND_MA_LONG)
|
|
|
|
signals = []
|
|
score = 0
|
|
|
|
if disparity > criteria.max_disparity:
|
|
score -= 5
|
|
signals.append(f"이격도 과열({disparity:.1f}% > {criteria.max_disparity}%) - 매수금지")
|
|
|
|
if criteria.use_overheat_filter and OVERHEAT_FILTER_ENABLED:
|
|
overheat = analyze_overheat(closes, ma20)
|
|
if overheat["blocked"]:
|
|
score -= 10
|
|
signals.append(overheat["reason"])
|
|
|
|
if criteria.use_volatility_breakout:
|
|
vb_result = analyze_volatility_breakout(chart_data)
|
|
if vb_result and vb_result["signal"] == "buy":
|
|
signals.append(vb_result["description"])
|
|
score += 4
|
|
|
|
custom_result = analyze_custom_signals(chart_data)
|
|
if custom_result:
|
|
if custom_result["signal"] == "buy":
|
|
signals.append(custom_result["description"])
|
|
score += 3
|
|
elif custom_result["signal"] == "sell":
|
|
signals.append(custom_result["description"])
|
|
score -= 2
|
|
elif custom_result["signal"] == "exit":
|
|
signals.append(custom_result["description"])
|
|
score -= 5
|
|
elif custom_result["signal"] == "take_profit":
|
|
signals.append(custom_result["description"])
|
|
score += 2
|
|
|
|
if criteria.use_macd:
|
|
result = analyze_macd(chart_data)
|
|
if result["signal"] == "buy":
|
|
signals.append(result["description"])
|
|
score += 2
|
|
|
|
rsi_buy_triggered = False
|
|
if criteria.use_rsi:
|
|
result = analyze_rsi(chart_data)
|
|
if result["signal"] == "buy":
|
|
rsi_buy_triggered = True
|
|
if volume_ratio >= 1.2:
|
|
signals.append(result["description"] + " + 거래량증가")
|
|
score += 3
|
|
elif volume_ratio >= 1.0:
|
|
signals.append(result["description"] + " (거래량보통)")
|
|
score += 1
|
|
else:
|
|
signals.append(result["description"] + " (거래량부족-차단)")
|
|
|
|
if criteria.use_bollinger:
|
|
result = analyze_bollinger(chart_data)
|
|
if result["signal"] == "buy":
|
|
signals.append(result["description"])
|
|
score += 1
|
|
|
|
if criteria.use_moving_average:
|
|
result = analyze_moving_average(chart_data)
|
|
if result["signal"] == "buy":
|
|
signals.append(result["description"])
|
|
score += 2
|
|
|
|
stochastic_buy_triggered = False
|
|
if criteria.use_stochastic:
|
|
result = analyze_stochastic(chart_data)
|
|
if result["signal"] == "buy":
|
|
stochastic_buy_triggered = True
|
|
signals.append(result["description"])
|
|
score += 0.5
|
|
|
|
if criteria.use_adx:
|
|
result = analyze_adx(chart_data)
|
|
if result["signal"] == "buy":
|
|
signals.append(result["description"])
|
|
score += 1
|
|
|
|
if criteria.use_obv:
|
|
result = analyze_obv(chart_data)
|
|
if result["signal"] == "buy":
|
|
signals.append(result["description"])
|
|
score += 1
|
|
|
|
# RSI + 스토캐스틱 복합 신호 보너스
|
|
if rsi_buy_triggered and stochastic_buy_triggered:
|
|
signals.append("RSI+스토캐스틱 복합 과매도")
|
|
score += 2
|
|
|
|
if volume_ratio >= 1.5 and change_rate > 0:
|
|
signals.append(f"거래량 실린 상승 ({volume_ratio:.1f}배)")
|
|
score += 3
|
|
elif volume_ratio < 1.0:
|
|
score -= 1
|
|
|
|
if TREND_FILTER_ENABLED and trend_ok is not None:
|
|
if trend_ok:
|
|
signals.append(f"추세상승(>{TREND_MA_SHORT}MA & {TREND_MA_SHORT}MA>{TREND_MA_LONG}MA, tol={int(TREND_TOLERANCE_PCT*10000)/100:.2f}%)")
|
|
score += 1
|
|
else:
|
|
signals.append(f"추세약함({TREND_MA_SHORT}MA≤{TREND_MA_LONG}MA 또는 종가≤{TREND_MA_SHORT}MA)")
|
|
|
|
if criteria.min_volume_ratio > 0 and volume_ratio < criteria.min_volume_ratio:
|
|
return None
|
|
|
|
ml_score_val = None
|
|
ml_confidence_val = None
|
|
ml_signal_val = None
|
|
|
|
if settings.ML_ENSEMBLE_ENABLED:
|
|
# ML 결과는 chart_data 보다 긴 시계열이 있으면 그쪽을 쓴다 (봉수 부족 방지).
|
|
ml_result = _run_ml_ensemble(ml_chart_data or chart_data, stock_code)
|
|
if ml_result is not None:
|
|
ml_score_val = ml_result["ensemble_score"]
|
|
ml_confidence_val = ml_result["confidence"]
|
|
ml_signal_val = ml_result["signal"]
|
|
|
|
rule_score_normalized = (score / 15.0) * 100.0
|
|
combined_score = int(
|
|
rule_score_normalized * settings.ML_ENSEMBLE_RULE_WEIGHT +
|
|
ml_score_val * settings.ML_ENSEMBLE_ML_WEIGHT
|
|
)
|
|
score = max(0, int(combined_score / 100.0 * 15.0))
|
|
|
|
signals.append(f"ML앙상블: {ml_signal_val}({ml_score_val}점, 신뢰도{ml_confidence_val:.0%})")
|
|
|
|
if criteria.for_batch or score >= 2:
|
|
final_score = max(1, score) if len(signals) > 0 else score
|
|
return StockRecommendation(
|
|
stock_code=stock_code,
|
|
stock_name=stock_name,
|
|
current_price=current_price,
|
|
change_rate=change_rate,
|
|
signals=signals if signals else ["조건 분석 중"],
|
|
score=final_score,
|
|
price_date=price_date,
|
|
volume=volume,
|
|
rsi=rsi,
|
|
ma5=ma5,
|
|
ma20=ma20,
|
|
ma60=ma60,
|
|
stochastic_k=stochastic_k,
|
|
stochastic_d=stochastic_d,
|
|
bollinger_upper=bollinger_upper,
|
|
bollinger_lower=bollinger_lower,
|
|
bollinger_middle=bollinger_middle,
|
|
ml_score=ml_score_val,
|
|
ml_confidence=ml_confidence_val,
|
|
ml_signal=ml_signal_val,
|
|
atr_14=atr_14_val,
|
|
adx_14=adx_14_val,
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def _analyze_chart_items(
|
|
chart_data: List[dict],
|
|
stock_code: str,
|
|
stock_name: str,
|
|
criteria: "RecommendationCriteria",
|
|
) -> Optional[StockRecommendation]:
|
|
"""KIS 형식 chart_data (oldest first)로 기술적 분석 수행 — API 호출 없음.
|
|
|
|
analyze_stock / analyze_stock_for_batch_db 양쪽에서 공유하는 순수 분석 로직.
|
|
|
|
주요 단계:
|
|
1. 차트 데이터 추출 및 검증
|
|
2. 기술적 지표 계산
|
|
3. 분석 결과 생성 (신호 및 점수)
|
|
"""
|
|
return _analyze_chart_items_v2(chart_data, stock_code, stock_name, criteria)
|
|
|
|
|
|
def _analyze_chart_items_v2(
|
|
chart_data: List[dict],
|
|
stock_code: str,
|
|
stock_name: str,
|
|
criteria: "RecommendationCriteria",
|
|
ml_chart_data: Optional[List[dict]] = None,
|
|
) -> Optional[StockRecommendation]:
|
|
"""_analyze_chart_items + ML 전용 장기 차트 주입 (2026-09-28).
|
|
|
|
ml_chart_data 는 ML 앙상블에만 전달되며 룰 지표 계산에는 쓰이지 않는다.
|
|
"""
|
|
# 1. Extract and validate chart data
|
|
chart_info = _extract_chart_data(chart_data, stock_code, stock_name)
|
|
if not chart_info:
|
|
return None
|
|
|
|
# 2. Calculate technical indicators
|
|
indicators = _calculate_technical_indicators(chart_data, chart_info["closes"])
|
|
|
|
# 3. Build analysis result with signals and scoring
|
|
return _build_analysis_result_v2(
|
|
chart_data, chart_info, indicators, criteria, ml_chart_data=ml_chart_data,
|
|
)
|
|
|
|
|
|
async def analyze_stock(stock_code: str, stock_name: str, criteria: RecommendationCriteria) -> Optional[StockRecommendation]:
|
|
"""차트를 조회한 뒤 _analyze_chart_items에 위임.
|
|
|
|
배치 분석(for_batch=True)일 때는 네이버 일봉 데이터를 사용하여
|
|
KIS API 호출량을 절감한다. 실패 시 KIS API로 폴백.
|
|
"""
|
|
try:
|
|
chart_data = None
|
|
ml_chart_data = None
|
|
|
|
# 배치 분석: 네이버 일봉 우선 사용
|
|
if criteria.for_batch:
|
|
try:
|
|
from app.services.naver_finance import get_naver_daily_chart
|
|
# ML 은 최소 80봉이 필요하다(시퀀스60+워밍업20). 기존 기본값 days=80 은
|
|
# 캘린더 110일 창 → 실제 75~77봉밖에 오지 않아 ML 이 항상 NaN 으로 터졌다.
|
|
ml_chart_data = await get_naver_daily_chart(
|
|
stock_code, days=_NAVER_ML_CHART_DAYS,
|
|
)
|
|
# 룰 지표는 기존과 동일한 창으로 계산한다(ADX Wilder 는 봉수 의존).
|
|
chart_data = _trim_to_legacy_window(ml_chart_data, _NAVER_LEGACY_DAYS)
|
|
except Exception as e:
|
|
logger.warning("네이버 일봉 조회 실패 %s: %s", stock_code, e)
|
|
|
|
# 네이버 실패 또는 실시간 분석 → KIS API 폴백
|
|
if chart_data is None:
|
|
_api = _real_market_api or korea_investment_api
|
|
# limit 기본값 240 — ML 최소 요구치(80봉)를 충분히 넘는다
|
|
chart_response = await _api.get_daily_chart(stock_code, "D")
|
|
if "output2" not in chart_response or not chart_response["output2"]:
|
|
return None
|
|
chart_data = list(reversed(chart_response["output2"]))
|
|
ml_chart_data = None # KIS 경로는 동일 배열을 그대로 ML 에 사용
|
|
|
|
return _analyze_chart_items_v2(
|
|
chart_data, stock_code, stock_name, criteria, ml_chart_data=ml_chart_data,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error analyzing {stock_code}: {str(e)}")
|
|
return None
|
|
|
|
|
|
async def analyze_stock_for_batch_db(
|
|
chart_data_newest_first: List[dict],
|
|
stock_code: str,
|
|
stock_name: str,
|
|
) -> Optional[StockRecommendation]:
|
|
"""DB에서 가져온 chart_data (newest first)로 배치 분석 — KIS API 호출 없음."""
|
|
criteria = RecommendationCriteria(
|
|
use_macd=True,
|
|
use_rsi=True,
|
|
use_bollinger=True,
|
|
use_moving_average=True,
|
|
use_stochastic=True,
|
|
use_adx=True,
|
|
use_obv=True,
|
|
use_volatility_breakout=True,
|
|
min_volume_ratio=0,
|
|
max_disparity=200.0,
|
|
for_batch=True,
|
|
)
|
|
try:
|
|
chart_data = list(reversed(chart_data_newest_first)) # oldest first
|
|
return _analyze_chart_items(chart_data, stock_code, stock_name, criteria)
|
|
except Exception as e:
|
|
logger.error(f"Error analyzing {stock_code}: {str(e)}")
|
|
return None
|
|
|
|
|
|
async def analyze_stock_for_batch(stock_code: str, stock_name: str) -> Optional[StockRecommendation]:
|
|
criteria = RecommendationCriteria(
|
|
use_macd=True,
|
|
use_rsi=True,
|
|
use_bollinger=True,
|
|
use_moving_average=True,
|
|
use_stochastic=True,
|
|
use_adx=True,
|
|
use_obv=True,
|
|
use_volatility_breakout=True,
|
|
min_volume_ratio=0,
|
|
max_disparity=200.0,
|
|
for_batch=True,
|
|
)
|
|
return await analyze_stock(stock_code, stock_name, criteria)
|
|
|
|
async def analyze_stock_with_indicators(stock_code: str, stock_name: str) -> Optional[StockRecommendation]:
|
|
criteria = RecommendationCriteria(
|
|
use_macd=True,
|
|
use_rsi=True,
|
|
use_bollinger=True,
|
|
use_moving_average=True,
|
|
use_stochastic=True,
|
|
use_adx=True,
|
|
use_obv=True,
|
|
use_volatility_breakout=True,
|
|
min_volume_ratio=1.2,
|
|
max_disparity=110.0
|
|
)
|
|
return await analyze_stock(stock_code, stock_name, criteria)
|
|
|
|
async def get_all_tradable_stocks() -> List[tuple]:
|
|
"""네이버 파이낸스에서 KOSPI+KOSDAQ 시가총액 순위 종목 조회.
|
|
|
|
KIS volume-rank API 대신 네이버 파이낸스 크롤링을 사용한다.
|
|
실서버 API 키 불필요.
|
|
"""
|
|
try:
|
|
from app.services.naver_finance import get_naver_stock_list
|
|
stocks = await get_naver_stock_list(max_pages_per_market=10)
|
|
return stocks
|
|
except Exception as e:
|
|
logger.error(f"네이버 파이낸스 종목 조회 실패: {e}", exc_info=True)
|
|
return []
|
|
|
|
|
|
async def check_market_regime_with_playbook() -> dict:
|
|
"""check_market_regime() 결과에 market_playbook 오버레이를 적용.
|
|
|
|
기존 check_market_regime() 시그니처를 변경하지 않고,
|
|
플레이북(FOMC, 옵션 만기, 서킷브레이커 등) 반영이 필요한
|
|
호출자를 위한 새 함수.
|
|
|
|
반환 dict에 'playbook' 키가 추가되며,
|
|
playbook.entry_allowed=False 이면 regime='event_blocked' 로 오버라이드.
|
|
"""
|
|
regime_result = await check_market_regime()
|
|
try:
|
|
from app.services.market_playbook import apply_playbook_to_regime
|
|
return apply_playbook_to_regime(regime_result)
|
|
except Exception as e:
|
|
logger.debug(f"플레이북 적용 실패 (원본 유지): {e}")
|
|
return regime_result |