fix: 시장 레짐 감지 KIS API 전환 + KOSPI/KOSDAQ 균형 배분

- detect_and_log_market_regime(): DB 조회 → KIS API 직접 호출 (KODEX200 069500)
  - 1차 KIS API (320일), 2차 DB 폴백
  - 이전: DB에 KODEX200 데이터 없으면 UNKNOWN → 레짐 필터 미작동
- naver_finance: limit + kospi_ratio 파라미터 추가
  - 기본 KOSPI 60% + KOSDAQ 40% 균형 배분
  - 이전: KOSPI만 500개 채워지고 KOSDAQ 0개 되는 문제 해결
- batch_daily_analysis: 네이버 파이낸스 직접 호출로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 17:33:32 +09:00
parent f6480407f2
commit deb7e7dca8
2 changed files with 75 additions and 26 deletions

View File

@@ -80,22 +80,27 @@ async def _fetch_page(client: httpx.AsyncClient, sosok: int, page: int) -> List[
async def get_naver_stock_list( async def get_naver_stock_list(
max_pages_per_market: int = 10, max_pages_per_market: int = 10,
min_price: int = _MIN_PRICE, min_price: int = _MIN_PRICE,
limit: int = 0,
kospi_ratio: float = 0.6,
) -> List[Tuple[str, str]]: ) -> List[Tuple[str, str]]:
"""KOSPI + KOSDAQ 시가총액 순위 종목 조회. """KOSPI + KOSDAQ 시가총액 순위 종목 조회.
Args: Args:
max_pages_per_market: 시장별 최대 페이지 수 (페이지당 ~50종목) max_pages_per_market: 시장별 최대 페이지 수 (페이지당 ~50종목)
min_price: 최소 현재가 필터 (기본 5,000원) min_price: 최소 현재가 필터 (기본 5,000원)
limit: 최종 반환 종목 수 제한 (0=제한 없음)
kospi_ratio: limit 적용 시 KOSPI 비율 (기본 0.6 = 60%)
Returns: Returns:
[(종목코드, 종목명), ...] — ETF/레버리지/스팩 제외, 가격 필터 적용 [(종목코드, 종목명), ...] — ETF/레버리지/스팩 제외, 가격 필터 적용
""" """
stocks: List[Tuple[str, str]] = []
seen_codes: set = set() seen_codes: set = set()
kospi_stocks: List[Tuple[str, str]] = []
kosdaq_stocks: List[Tuple[str, str]] = []
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
for sosok, market_name in ((0, "KOSPI"), (1, "KOSDAQ")): 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): for page in range(1, max_pages_per_market + 1):
rows = await _fetch_page(client, sosok, page) rows = await _fetch_page(client, sosok, page)
if not rows: if not rows:
@@ -115,7 +120,29 @@ async def get_naver_stock_list(
await asyncio.sleep(0.3) # 서버 부하 방지 await asyncio.sleep(0.3) # 서버 부하 방지
logger.info("네이버 %s: %d개 종목 수집", market_name, len(market_stocks)) 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 return stocks

View File

@@ -20,7 +20,7 @@ Cron: 0 9 * * 1-5 cd /path/to/auto-stock/backend && python batch_daily_analysis.
import asyncio import asyncio
import sys import sys
from datetime import datetime, date from datetime import datetime, date, timedelta
from pathlib import Path from pathlib import Path
from typing import Optional 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: async def detect_and_log_market_regime() -> str:
"""배치 시작 시 KOSPI200 레짐 판별 — DB에서 KODEX200 데이터 조회. """배치 시작 시 KOSPI200 레짐 판별 — KIS API로 KODEX200(069500) 일봉 조회.
BULL / BEAR 중 하나를 리턴하며 배치 전체에 로깅된다. BULL / BEAR 중 하나를 리턴하며 배치 전체에 로깅된다.
실패 시 UNKNOWN을 리턴하여 배치를 차단하지 않는다. 실패 시 UNKNOWN을 리턴하여 배치를 차단하지 않는다.
1차: KIS API get_daily_ohlcv_range (069500, 320일)
2차 폴백: DB stock_daily_prices
""" """
try: try:
from app.services.mean_reversion_strategy import detect_market_regime 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) closes: list[float] = []
if not chart_data:
logger.warning("⚠️ KOSPI200(KODEX200) DB 데이터 없음 — 레짐 판별 스킵")
return "UNKNOWN"
# DB 데이터는 newest first → oldest first 변환 후 detect_market_regime 호출 # 1차: KIS API — KODEX200 일봉 320캘린더일
closes = list(reversed([float(item["stck_clpr"]) for item in chart_data])) 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: if len(closes) < 205:
logger.warning("⚠️ KOSPI200 데이터 부족(%d행) — 레짐 판별 스킵", len(closes)) logger.warning("⚠️ KOSPI200 데이터 부족(%d행) — 레짐 판별 스킵", len(closes))
@@ -106,7 +127,7 @@ async def detect_and_log_market_regime() -> str:
regime = detect_market_regime(closes) regime = detect_market_regime(closes)
regime_emoji = {"bull": "📈 BULL", "bear": "📉 BEAR"}.get(regime.value, "➡️ " + regime.value.upper()) 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() return regime.value.upper()
except Exception as exc: 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]]: 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 / 레버리지 / 스팩 등 특수종목 자동 제외. ETF / 레버리지 / 스팩 등 특수종목 자동 제외.
네이버 파이낸스 결과가 MIN_STOCK_COUNT(50) 미만이면 stock_daily_prices DB 폴백을 사용한다.
KIS API 결과가 MIN_STOCK_COUNT(50) 미만이면 stock_daily_prices DB 폴백을 사용한다.
DB 폴백도 실패하면 RuntimeError를 발생시켜 배치를 중단한다. 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)") logger.info("📡 네이버 파이낸스 종목 조회 (KOSPI+KOSDAQ, limit=%d)", limit)
stocks = await _kis_get_stocks() 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: if len(stocks) < MIN_STOCK_COUNT:
logger.warning( logger.warning(
"⚠️ KIS API 결과 부족 (%d개 < %d개) — DB 폴백 사용", "⚠️ 네이버 파이낸스 결과 부족 (%d개 < %d개) — DB 폴백 사용",
len(stocks), MIN_STOCK_COUNT, len(stocks), MIN_STOCK_COUNT,
) )
db_stocks = await _get_stocks_from_db(limit) db_stocks = await _get_stocks_from_db(limit)
if len(db_stocks) < MIN_STOCK_COUNT: if len(db_stocks) < MIN_STOCK_COUNT:
raise RuntimeError( raise RuntimeError(
f"종목 조회 실패: KIS API({len(stocks)}개) 및 DB 폴백({len(db_stocks)}개) 모두 " f"종목 조회 실패: 네이버({len(stocks)}개) 및 DB 폴백({len(db_stocks)}개) 모두 "
f"최소 기준({MIN_STOCK_COUNT}개) 미달. KIS API 설정 및 DB 데이터를 확인하세요." f"최소 기준({MIN_STOCK_COUNT}개) 미달."
) )
result = db_stocks[:limit] result = db_stocks[:limit]
logger.info("✅ DB 폴백 선정 %d개", len(result)) logger.info("✅ DB 폴백 선정 %d개", len(result))
return result return result
result = stocks[:limit] logger.info("✅ 선정 %d개", len(stocks))
logger.info("✅ 선정 %d개", len(result)) return stocks
return result
# ------------------------------------------------------------------- # -------------------------------------------------------------------