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:
@@ -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))
|
||||
|
||||
return stocks
|
||||
|
||||
@@ -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 호출
|
||||
# 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
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user