diff --git a/backend/app/services/naver_finance.py b/backend/app/services/naver_finance.py index 89fb75e..49cc1dd 100644 --- a/backend/app/services/naver_finance.py +++ b/backend/app/services/naver_finance.py @@ -80,22 +80,27 @@ async def _fetch_page(client: httpx.AsyncClient, sosok: int, page: int) -> List[ async def get_naver_stock_list( max_pages_per_market: int = 10, min_price: int = _MIN_PRICE, + limit: int = 0, + kospi_ratio: float = 0.6, ) -> List[Tuple[str, str]]: """KOSPI + KOSDAQ 시가총액 순위 종목 조회. Args: max_pages_per_market: 시장별 최대 페이지 수 (페이지당 ~50종목) min_price: 최소 현재가 필터 (기본 5,000원) + limit: 최종 반환 종목 수 제한 (0=제한 없음) + kospi_ratio: limit 적용 시 KOSPI 비율 (기본 0.6 = 60%) Returns: [(종목코드, 종목명), ...] — ETF/레버리지/스팩 제외, 가격 필터 적용 """ - stocks: List[Tuple[str, str]] = [] seen_codes: set = set() + kospi_stocks: List[Tuple[str, str]] = [] + kosdaq_stocks: List[Tuple[str, str]] = [] async with httpx.AsyncClient() as client: for sosok, market_name in ((0, "KOSPI"), (1, "KOSDAQ")): - market_stocks = [] + market_stocks: List[Tuple[str, str]] = [] for page in range(1, max_pages_per_market + 1): rows = await _fetch_page(client, sosok, page) if not rows: @@ -115,7 +120,29 @@ async def get_naver_stock_list( await asyncio.sleep(0.3) # 서버 부하 방지 logger.info("네이버 %s: %d개 종목 수집", market_name, len(market_stocks)) - stocks.extend(market_stocks) + if sosok == 0: + kospi_stocks = market_stocks + else: + kosdaq_stocks = market_stocks + + # limit가 있으면 KOSPI/KOSDAQ 균형 배분 + if limit > 0: + kospi_limit = int(limit * kospi_ratio) + kosdaq_limit = limit - kospi_limit + # 한쪽이 부족하면 다른 쪽에서 채움 + if len(kospi_stocks) < kospi_limit: + kosdaq_limit += kospi_limit - len(kospi_stocks) + kospi_limit = len(kospi_stocks) + elif len(kosdaq_stocks) < kosdaq_limit: + kospi_limit += kosdaq_limit - len(kosdaq_stocks) + kosdaq_limit = len(kosdaq_stocks) + stocks = kospi_stocks[:kospi_limit] + kosdaq_stocks[:kosdaq_limit] + logger.info( + "네이버 파이낸스 총 %d개 종목 (KOSPI %d + KOSDAQ %d)", + len(stocks), min(len(kospi_stocks), kospi_limit), min(len(kosdaq_stocks), kosdaq_limit), + ) + else: + stocks = kospi_stocks + kosdaq_stocks + logger.info("네이버 파이낸스 총 %d개 종목 수집", len(stocks)) - logger.info("네이버 파이낸스 총 %d개 종목 수집", len(stocks)) return stocks diff --git a/backend/batch_daily_analysis.py b/backend/batch_daily_analysis.py index 7d26e42..0ecaca3 100644 --- a/backend/batch_daily_analysis.py +++ b/backend/batch_daily_analysis.py @@ -20,7 +20,7 @@ Cron: 0 9 * * 1-5 cd /path/to/auto-stock/backend && python batch_daily_analysis. import asyncio import sys -from datetime import datetime, date +from datetime import datetime, date, timedelta from pathlib import Path from typing import Optional @@ -84,21 +84,42 @@ async def _fetch_db_chart(stock_code: str, limit: int = 240) -> list: async def detect_and_log_market_regime() -> str: - """배치 시작 시 KOSPI200 레짐 판별 — DB에서 KODEX200 데이터 조회. + """배치 시작 시 KOSPI200 레짐 판별 — KIS API로 KODEX200(069500) 일봉 조회. BULL / BEAR 중 하나를 리턴하며 배치 전체에 로깅된다. 실패 시 UNKNOWN을 리턴하여 배치를 차단하지 않는다. + + 1차: KIS API get_daily_ohlcv_range (069500, 320일) + 2차 폴백: DB stock_daily_prices """ try: from app.services.mean_reversion_strategy import detect_market_regime + from app.services.korea_investment_api import korea_investment_api - chart_data = await _fetch_db_chart("069500", limit=240) - if not chart_data: - logger.warning("⚠️ KOSPI200(KODEX200) DB 데이터 없음 — 레짐 판별 스킵") - return "UNKNOWN" + closes: list[float] = [] - # DB 데이터는 newest first → oldest first 변환 후 detect_market_regime 호출 - closes = list(reversed([float(item["stck_clpr"]) for item in chart_data])) + # 1차: KIS API — KODEX200 일봉 320캘린더일 + try: + end_dt = datetime.now(KST) + start_dt = end_dt - timedelta(days=320) + chart_data = await korea_investment_api.get_daily_ohlcv_range( + "069500", + start_dt.strftime("%Y%m%d"), + end_dt.strftime("%Y%m%d"), + ) + if chart_data and len(chart_data) >= 205: + # KIS API 결과는 newest first → oldest first 변환 + closes = list(reversed([float(item["stck_clpr"]) for item in chart_data])) + logger.info("📡 KODEX200 KIS API 조회 성공: %d행", len(closes)) + except Exception as api_exc: + logger.warning("⚠️ KIS API KODEX200 조회 실패, DB 폴백 시도: %s", api_exc) + + # 2차 폴백: DB + if len(closes) < 205: + chart_data = await _fetch_db_chart("069500", limit=240) + if chart_data: + closes = list(reversed([float(item["stck_clpr"]) for item in chart_data])) + logger.info("📂 KODEX200 DB 폴백 조회: %d행", len(closes)) if len(closes) < 205: logger.warning("⚠️ KOSPI200 데이터 부족(%d행) — 레짐 판별 스킵", len(closes)) @@ -106,7 +127,7 @@ async def detect_and_log_market_regime() -> str: regime = detect_market_regime(closes) regime_emoji = {"bull": "📈 BULL", "bear": "📉 BEAR"}.get(regime.value, "➡️ " + regime.value.upper()) - logger.info("🌏 시장 레짐: %s (KODEX200 %d일 기준, DB)", regime_emoji, len(closes)) + logger.info("🌏 시장 레짐: %s (KODEX200 %d일 기준)", regime_emoji, len(closes)) return regime.value.upper() except Exception as exc: @@ -169,39 +190,40 @@ async def _get_stocks_from_db(limit: int = 500) -> list[tuple[str, str]]: async def get_all_tradable_stocks(limit: int = 500) -> list[tuple[str, str]]: - """KIS 거래량 순위 API로 거래 가능 종목 조회 (KOSPI+KOSDAQ 각 top 40). + """네이버 파이낸스 시가총액 순위로 거래 가능 종목 조회 (KOSPI 60% + KOSDAQ 40%). - KIS FHPST01710000: 거래량 순위 — 5,000원 이상 활성 종목. ETF / 레버리지 / 스팩 등 특수종목 자동 제외. - - KIS API 결과가 MIN_STOCK_COUNT(50) 미만이면 stock_daily_prices DB 폴백을 사용한다. + 네이버 파이낸스 결과가 MIN_STOCK_COUNT(50) 미만이면 stock_daily_prices DB 폴백을 사용한다. DB 폴백도 실패하면 RuntimeError를 발생시켜 배치를 중단한다. """ - from app.services.market_analyzer import get_all_tradable_stocks as _kis_get_stocks + from app.services.naver_finance import get_naver_stock_list - logger.info("📡 KIS 거래량 순위 종목 조회 (KOSPI+KOSDAQ 각 top 40)") - stocks = await _kis_get_stocks() + logger.info("📡 네이버 파이낸스 종목 조회 (KOSPI+KOSDAQ, limit=%d)", limit) + stocks = await get_naver_stock_list( + max_pages_per_market=10, + limit=limit, + kospi_ratio=0.6, # KOSPI 300 + KOSDAQ 200 (limit=500 기준) + ) if len(stocks) < MIN_STOCK_COUNT: logger.warning( - "⚠️ KIS API 결과 부족 (%d개 < %d개) — DB 폴백 사용", + "⚠️ 네이버 파이낸스 결과 부족 (%d개 < %d개) — DB 폴백 사용", len(stocks), MIN_STOCK_COUNT, ) db_stocks = await _get_stocks_from_db(limit) if len(db_stocks) < MIN_STOCK_COUNT: raise RuntimeError( - f"종목 조회 실패: KIS API({len(stocks)}개) 및 DB 폴백({len(db_stocks)}개) 모두 " - f"최소 기준({MIN_STOCK_COUNT}개) 미달. KIS API 설정 및 DB 데이터를 확인하세요." + f"종목 조회 실패: 네이버({len(stocks)}개) 및 DB 폴백({len(db_stocks)}개) 모두 " + f"최소 기준({MIN_STOCK_COUNT}개) 미달." ) result = db_stocks[:limit] logger.info("✅ DB 폴백 선정 %d개", len(result)) return result - result = stocks[:limit] - logger.info("✅ 선정 %d개", len(result)) - return result + logger.info("✅ 선정 %d개", len(stocks)) + return stocks # -------------------------------------------------------------------