Files
auto-stock/backend/app/services/naver_finance.py
shuim deb7e7dca8 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>
2026-03-10 17:33:32 +09:00

149 lines
5.1 KiB
Python

"""네이버 파이낸스 종목 목록 크롤러
KIS volume-rank API 대신 네이버 시가총액 순위 페이지에서
KOSPI/KOSDAQ 종목 목록을 가져온다.
사용처:
- batch_daily_analysis.get_all_tradable_stocks() DB 폴백 이전 1차 시도
- market_analyzer.get_all_tradable_stocks() 대체
URL: https://finance.naver.com/sise/sise_market_sum.naver?sosok=0&page=N
sosok=0 : KOSPI
sosok=1 : KOSDAQ
"""
import asyncio
import logging
from typing import List, Tuple
import httpx
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
_HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; autostock-batch/1.0)"}
_BASE_URL = "https://finance.naver.com/sise/sise_market_sum.naver"
SKIP_KEYWORDS = [
"ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG",
"KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", "RISE",
"선물", "스팩", "리츠", "SPAC",
]
_MIN_PRICE = 5_000 # 5,000원 미만 제외
def _parse_page(html: bytes) -> List[Tuple[str, str, int]]:
"""HTML 파싱 → [(종목코드, 종목명, 현재가), ...]"""
soup = BeautifulSoup(html, "html.parser", from_encoding="euc-kr")
table = soup.find("table", {"class": "type_2"})
if not table:
return []
result = []
for row in table.find_all("tr"):
a = row.find("a", class_="tltle")
if not a:
continue
href = a.get("href", "")
if "code=" not in href:
continue
code = href.split("code=")[1]
name = a.text.strip()
# 현재가
tds = row.find_all("td", class_="number")
try:
price = int(tds[0].text.strip().replace(",", "")) if tds else 0
except (ValueError, IndexError):
price = 0
result.append((code, name, price))
return result
async def _fetch_page(client: httpx.AsyncClient, sosok: int, page: int) -> List[Tuple[str, str, int]]:
try:
resp = await client.get(
_BASE_URL,
params={"sosok": sosok, "page": page},
headers=_HEADERS,
timeout=10,
)
resp.raise_for_status()
return _parse_page(resp.content)
except Exception as exc:
logger.warning("네이버 파이낸스 조회 실패 sosok=%d page=%d: %s", sosok, page, exc)
return []
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/레버리지/스팩 제외, 가격 필터 적용
"""
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: List[Tuple[str, str]] = []
for page in range(1, max_pages_per_market + 1):
rows = await _fetch_page(client, sosok, page)
if not rows:
break # 마지막 페이지
for code, name, price in rows:
if code in seen_codes:
continue
if price < min_price:
continue
name_upper = name.upper()
if any(kw.upper() in name_upper for kw in SKIP_KEYWORDS):
continue
seen_codes.add(code)
market_stocks.append((code, name))
await asyncio.sleep(0.3) # 서버 부하 방지
logger.info("네이버 %s: %d개 종목 수집", market_name, len(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