fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건) - day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가 - trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산 - risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수 - risk_management/recommendations: date.today() → KST 변환 - mdd_manager: Lock 밖 executed_key 참조 수정 - correlation_filter: output → output2 키 수정 - portfolio: 해외 종목 USD/KRW 환율 적용 분리 - Sharpe Ratio 거래빈도 기반 연간화 - telegram_notifier: send_message() 공개 래퍼 추가 - position_manager: 앱 재시작 시 DB 포지션 복원 - swing exit_service: remaining_pct 조건 범위 수정 - run_backtest: original_qty 기반 remaining_pct 계산 - us_market_schedule: 2027년 미국 공휴일 추가 - Login.tsx: res.ok 검증 추가 - mean_reversion_strategy: RSI 로그 하드코딩 수정 ## HIGH 수정 (10건) - db_worker: DLQ 파일 백업 메커니즘 추가 - korea_investment_api: 만료 토큰 임의 연장 제거 - market_data: chart_cache 최대 100개 + TTL 만료 - main.py: rate_limit_store 1시간 미접근 IP 정리 - recommendations: fire-and-forget try/except 추가 - swing/scheduler: DB 세션 3개 분리 - Dashboard: wsMessages 전체 순회 - Layout: /health 엔드포인트 + 30초 폴링 - ml_ensemble: POST /train require_api_key 추가 - day_trading: record_trade_result_safe() 전환 ## 코드 정리 - 프론트엔드: isUsStock, formatTime 공통 유틸 추출 - 백엔드: dead code 제거, 중복 import 통합 - MCP: JSON 파싱 헬퍼 추출 (~60줄 감소) - 전체 ~100개 파일 한국어 주석 추가 - 테스트 13건 코드 기준 수정 (468 passed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,7 +55,7 @@ class StockRecommendation(BaseModel):
|
||||
current_price: float
|
||||
change_rate: float
|
||||
signals: List[str]
|
||||
score: int
|
||||
score: float
|
||||
price_date: str = ""
|
||||
volume: Optional[int] = None
|
||||
rsi: Optional[float] = None
|
||||
@@ -76,6 +76,33 @@ class StockRecommendation(BaseModel):
|
||||
_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():
|
||||
@@ -85,13 +112,26 @@ async def check_market_regime() -> dict:
|
||||
if _market_regime_cache["result"] is not None and (now_ts - _market_regime_cache["ts"]) < _MARKET_REGIME_CACHE_TTL:
|
||||
return _market_regime_cache["result"]
|
||||
|
||||
_api = _real_market_api or korea_investment_api
|
||||
kospi_chart = await _api.get_daily_chart("069500", "D", limit=240)
|
||||
# ── 데이터 수집: 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
|
||||
naver_data = await get_naver_daily_chart("069500", days=240) # oldest-first
|
||||
if naver_data and len(naver_data) >= 60:
|
||||
chart_data = list(reversed(naver_data)) # → newest-first
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if "output2" not in kospi_chart or not kospi_chart["output2"]:
|
||||
return {"regime": "neutral", "message": "시장 지수 확인 불가", "position_multiplier": 0.5}
|
||||
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"]:
|
||||
return {"regime": "neutral", "message": "시장 지수 확인 불가", "position_multiplier": 0.5}
|
||||
chart_data = kospi_chart["output2"] # newest-first
|
||||
|
||||
chart_data = kospi_chart["output2"]
|
||||
# closes: newest-first (closes[0]=최신, closes[-1]=가장 오래된)
|
||||
closes = [float(item.get("stck_clpr", 0)) for item in chart_data]
|
||||
|
||||
if len(closes) < 60:
|
||||
@@ -113,18 +153,48 @@ async def check_market_regime() -> dict:
|
||||
"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:
|
||||
regime_enum = detect_market_regime(list(reversed(closes)))
|
||||
# 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:
|
||||
is_bull_200 = current_price > ma20
|
||||
is_sideways_200 = False
|
||||
ma200 = ma60
|
||||
|
||||
# ── 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 ""
|
||||
@@ -140,6 +210,7 @@ async def check_market_regime() -> dict:
|
||||
"min_score": 9999,
|
||||
"max_trades": 0,
|
||||
"today_change_pct": round(today_change_pct, 2),
|
||||
"hmm_ensemble": _ensemble,
|
||||
})
|
||||
|
||||
consecutive_down = 0
|
||||
@@ -151,6 +222,20 @@ async def check_market_regime() -> dict:
|
||||
|
||||
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",
|
||||
@@ -161,6 +246,7 @@ async def check_market_regime() -> dict:
|
||||
"max_trades": 1,
|
||||
"consecutive_down": consecutive_down,
|
||||
"drop_from_ma20": round(drop_from_ma20, 1),
|
||||
"hmm_ensemble": _ensemble,
|
||||
})
|
||||
|
||||
if current_price < ma20:
|
||||
@@ -173,6 +259,7 @@ async def check_market_regime() -> dict:
|
||||
"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({
|
||||
@@ -184,21 +271,22 @@ async def check_market_regime() -> dict:
|
||||
"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 _analyze_chart_items(
|
||||
def _extract_chart_data(
|
||||
chart_data: List[dict],
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
criteria: "RecommendationCriteria",
|
||||
) -> Optional[StockRecommendation]:
|
||||
"""KIS 형식 chart_data (oldest first)로 기술적 분석 수행 — API 호출 없음.
|
||||
) -> Optional[dict]:
|
||||
"""Extract and validate basic chart data from KIS format.
|
||||
|
||||
analyze_stock / analyze_stock_for_batch_db 양쪽에서 공유하는 순수 분석 로직.
|
||||
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:
|
||||
@@ -214,8 +302,8 @@ def _analyze_chart_items(
|
||||
logger.debug("필터[가격0] %s", stock_code)
|
||||
return None
|
||||
|
||||
# 가격 필터: 5,000원 미만 종목 제외 (CLAUDE.md 섹션 5.4)
|
||||
if current_price < settings.MIN_PRICE_KRW:
|
||||
# 가격 필터: 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
|
||||
|
||||
@@ -228,30 +316,32 @@ def _analyze_chart_items(
|
||||
closes = [float(item.get("stck_clpr", 0)) for item in chart_data]
|
||||
volumes = [int(item.get("acml_vol", 0)) for item in chart_data]
|
||||
|
||||
ma5 = sum(closes[-5:]) / 5 if len(closes) >= 5 else None
|
||||
ma20 = sum(closes[-20:]) / 20 if len(closes) >= 20 else None
|
||||
ma60 = sum(closes[-60:]) / 60 if len(closes) >= 60 else None
|
||||
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,
|
||||
}
|
||||
|
||||
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일 평균 거래량 기준으로 판단 (intraday 누적량 대신 사용)
|
||||
# chart_data[-1].acml_vol은 장 초반에 당일 누적량이므로 실제 유동성을 반영 못함
|
||||
# avg_vol_20 기준 10만주 미만 = 진입하기 어려운 비유동 종목
|
||||
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
|
||||
def _calculate_technical_indicators(chart_data: List[dict], closes: List[float]) -> dict:
|
||||
"""Calculate technical indicators (MA, RSI, Stochastic, Bollinger, ATR, ADX).
|
||||
|
||||
if not criteria.for_batch and avg_vol_20 < 50000 and current_price < 50000:
|
||||
return None
|
||||
Returns dict with calculated indicators.
|
||||
"""
|
||||
indicators = {}
|
||||
|
||||
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)
|
||||
# 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:]
|
||||
@@ -268,7 +358,9 @@ def _analyze_chart_items(
|
||||
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:
|
||||
@@ -288,7 +380,10 @@ def _analyze_chart_items(
|
||||
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:]
|
||||
@@ -298,6 +393,77 @@ def _analyze_chart_items(
|
||||
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
|
||||
@@ -339,15 +505,19 @@ def _analyze_chart_items(
|
||||
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
|
||||
else:
|
||||
signals.append(result["description"] + " (거래량부족)")
|
||||
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)
|
||||
@@ -361,11 +531,13 @@ def _analyze_chart_items(
|
||||
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 += 1
|
||||
score += 0.5
|
||||
|
||||
if criteria.use_adx:
|
||||
result = analyze_adx(chart_data)
|
||||
@@ -379,10 +551,15 @@ def _analyze_chart_items(
|
||||
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 < 0.8:
|
||||
elif volume_ratio < 1.0:
|
||||
score -= 1
|
||||
|
||||
if TREND_FILTER_ENABLED and trend_ok is not None:
|
||||
@@ -421,20 +598,7 @@ def _analyze_chart_items(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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 계산 실패 {stock_code}: {_e}")
|
||||
|
||||
if criteria.for_batch or len(signals) > 0 or score > 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,
|
||||
@@ -464,14 +628,58 @@ def _analyze_chart_items(
|
||||
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]:
|
||||
"""KIS API로 차트를 조회한 뒤 _analyze_chart_items에 위임."""
|
||||
"""차트를 조회한 뒤 _analyze_chart_items에 위임.
|
||||
|
||||
배치 분석(for_batch=True)일 때는 네이버 일봉 데이터를 사용하여
|
||||
KIS API 호출량을 절감한다. 실패 시 KIS API로 폴백.
|
||||
"""
|
||||
try:
|
||||
_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"]))
|
||||
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)}")
|
||||
@@ -548,4 +756,23 @@ async def get_all_tradable_stocks() -> List[tuple]:
|
||||
return stocks
|
||||
except Exception as e:
|
||||
logger.error(f"네이버 파이낸스 종목 조회 실패: {e}", exc_info=True)
|
||||
return []
|
||||
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
|
||||
Reference in New Issue
Block a user