"""네이버 파이낸스 종목 목록 크롤러 + 일봉 차트 조회 KIS volume-rank API 대신 네이버 시가총액 순위 페이지에서 KOSPI/KOSDAQ 종목 목록을 가져온다. 일봉 차트 데이터도 네이버 API(JSON)에서 조회하여 KIS API 호출량을 절감한다. 사용처: - batch_daily_analysis.get_all_tradable_stocks() DB 폴백 이전 1차 시도 - market_analyzer.get_all_tradable_stocks() 대체 - market_analyzer.analyze_stock() 배치 분석용 일봉 데이터 제공 URL (종목 목록): https://m.stock.naver.com/api/stocks/marketValue/{KOSPI|KOSDAQ}?page=N&pageSize=50 URL (일봉 차트): https://fchart.stock.naver.com/siseJson.naver?symbol=CODE&requestType=1&startTime=YYYYMMDD&endTime=YYYYMMDD&timeframe=day """ import asyncio import logging from datetime import datetime, timedelta from typing import Dict, List, Optional, Tuple import httpx from bs4 import BeautifulSoup from app.core.log_throttle import warn_throttled # noqa: E402 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 _LIST_API_URL = "https://m.stock.naver.com/api/stocks/marketValue/{market}" _LIST_PAGE_SIZE = 50 # 구 HTML 페이지(~50종목/페이지)와 동일한 페이지 의미 유지 _SOSOK_TO_MARKET = {0: "KOSPI", 1: "KOSDAQ"} def _parse_json_page(data: Dict) -> List[Tuple[str, str, int]]: """m.stock.naver.com JSON → [(종목코드, 종목명, 현재가), ...]""" result = [] for item in data.get("stocks") or []: code = item.get("itemCode") or "" name = (item.get("stockName") or "").strip() if not code or not name: continue try: price = int(str(item.get("closePrice") or "0").replace(",", "")) except ValueError: 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]]: """시가총액 순위 1페이지 조회. 2026-09 네이버가 finance.naver.com/sise/sise_market_sum.naver(HTML)를 폐기하고 stock.naver.com으로 302 리다이렉트 → m.stock.naver.com JSON API로 교체. """ market = _SOSOK_TO_MARKET.get(sosok, "KOSPI") try: resp = await client.get( _LIST_API_URL.format(market=market), params={"page": page, "pageSize": _LIST_PAGE_SIZE}, headers=_HEADERS, timeout=10, ) resp.raise_for_status() return _parse_json_page(resp.json()) 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 # ───────────────────────────────────────────────────────────────────────────── # 네이버 일봉 차트 데이터 조회 (KIS API 대체) # ───────────────────────────────────────────────────────────────────────────── _CHART_URL = "https://fchart.stock.naver.com/siseJson.naver" async def get_naver_daily_chart( stock_code: str, days: int = 80, client: Optional[httpx.AsyncClient] = None, ) -> Optional[List[Dict]]: """네이버 일봉 차트 → KIS 형식 dict 리스트 (oldest first). Returns: [{"stck_bsop_date": "20260316", "stck_clpr": "55000", "stck_oprc": "54500", "stck_hgpr": "55500", "stck_lwpr": "54000", "acml_vol": "1234567"}, ...] or None on failure """ end_dt = datetime.now() start_dt = end_dt - timedelta(days=days + 30) # 여유분 확보 params = { "symbol": stock_code, "requestType": "1", "startTime": start_dt.strftime("%Y%m%d"), "endTime": end_dt.strftime("%Y%m%d"), "timeframe": "day", } close_client = False if client is None: client = httpx.AsyncClient() close_client = True try: resp = await client.get( _CHART_URL, params=params, headers=_HEADERS, timeout=10, ) resp.raise_for_status() text = resp.text.strip() # 네이버 응답: JS 배열 형태 문자열 — 첫 줄 헤더, 이후 데이터 # 예: [["날짜","시가","고가","저가","종가","거래량"],\n["20260314", ...], ...] lines = [ line.strip().strip(",") for line in text.strip("[]").split("\n") if line.strip() and line.strip().strip(",") ] if len(lines) < 2: return None result: List[Dict] = [] for line in lines[1:]: # 첫 줄(헤더) 스킵 line = line.strip().strip("[]") if not line: continue parts = [p.strip().strip('"').strip("'") for p in line.split(",")] if len(parts) < 6: continue try: dt_str = parts[0].strip().replace(".", "").replace("-", "").replace("/", "")[:8] if len(dt_str) != 8: continue result.append({ "stck_bsop_date": dt_str, "stck_oprc": parts[1].strip(), "stck_hgpr": parts[2].strip(), "stck_lwpr": parts[3].strip(), "stck_clpr": parts[4].strip(), "acml_vol": parts[5].strip(), }) except (ValueError, IndexError): continue if not result: return None # 최신 days개만 return result[-days:] if len(result) > days else result except Exception as exc: warn_throttled(logger, "naver_finance:264", "네이버 차트 조회 실패 %s: %s", stock_code, exc) return None finally: if close_client: await client.aclose() # ── 세계지수 크롤링 ────────────────────────────────────────────────────────────── # 네이버 세계지수 심볼 매핑 _WORLD_INDEX_SYMBOLS = { "SP500": "SPI@SPX", "NASDAQ": "NAS@IXIC", "DOW": "DJI@DJI", } _WORLD_INDEX_URL = "https://finance.naver.com/world/sise.naver" def _parse_world_index_page(soup: BeautifulSoup) -> Optional[Dict]: """네이버 세계지수 상세 페이지에서 현재가/변동률 파싱. HTML 구조:

숫자 span들

전일대비 XX.XX (+Y.YY%)

""" try: # 현재가 price_em = soup.select_one("p.no_today em") if not price_em: return None price_text = price_em.get_text(strip=True).replace(",", "") price = float(price_text) # 방향: em 클래스가 no_up / no_down direction = 1.0 cls_list = price_em.get("class", []) if "no_down" in cls_list: direction = -1.0 # 변동값 + 변동률: "전일대비35.53(+0.54%)" exday_el = soup.select_one("p.no_exday") change = 0.0 change_pct = 0.0 if exday_el: text = exday_el.get_text(strip=True) # 변동값: em 내부 숫자 change_em = exday_el.select_one("em") if change_em: change_text = change_em.get_text(strip=True).replace(",", "") try: change = float(change_text) * direction except ValueError: pass # 변동률: 괄호 안 퍼센트 import re pct_match = re.search(r"\(([+-]?\d+\.?\d*)%\)", text) if pct_match: change_pct = float(pct_match.group(1)) return { "price": price, "change": change, "change_pct": change_pct, } except Exception as e: logger.warning("세계지수 파싱 실패: %s", e) return None async def get_world_indices() -> Dict[str, Dict]: """네이버 금융에서 주요 세계지수(S&P500, NASDAQ, DOW) 조회. Returns: {"SP500": {"price": 5500.0, "change": 35.5, "change_pct": 0.54}, ...} """ result: Dict[str, Dict] = {} try: async with httpx.AsyncClient() as client: for name, symbol in _WORLD_INDEX_SYMBOLS.items(): try: resp = await client.get( _WORLD_INDEX_URL, params={"symbol": symbol}, headers=_HEADERS, timeout=10, ) resp.raise_for_status() soup = BeautifulSoup(resp.content, "html.parser") parsed = _parse_world_index_page(soup) if parsed: result[name] = parsed await asyncio.sleep(0.3) except Exception as e: logger.warning("세계지수 %s 조회 실패: %s", name, e) except Exception as e: logger.error("세계지수 크롤링 전체 실패: %s", e) return result async def get_vix_index() -> Optional[Dict]: """Yahoo Finance API에서 VIX 지수 조회 (네이버에 VIX 없음). Returns: {"price": 25.3, "prev_close": 24.0, "change": 1.3, "change_pct": 5.4} """ try: url = "https://query1.finance.yahoo.com/v8/finance/chart/%5EVIX" params = {"range": "5d", "interval": "1d"} headers = {"User-Agent": "Mozilla/5.0"} async with httpx.AsyncClient() as client: resp = await client.get(url, params=params, headers=headers, timeout=10) resp.raise_for_status() data = resp.json() chart_result = data.get("chart", {}).get("result", []) if not chart_result: return None meta = chart_result[0].get("meta", {}) price = float(meta.get("regularMarketPrice", 0)) prev_close = float(meta.get("chartPreviousClose", 0)) change = price - prev_close change_pct = (change / prev_close * 100) if prev_close > 0 else 0.0 return { "price": round(price, 2), "prev_close": round(prev_close, 2), "change": round(change, 2), "change_pct": round(change_pct, 2), } except Exception as e: logger.warning("VIX 조회 실패: %s", e) return None # ── 종목 펀더멘털(실적·컨센서스·목표주가) — 매수 스토리 기록용 (Phase 1) ──────────── _FUNDAMENTALS_CACHE: Dict[str, Tuple[float, Optional[Dict]]] = {} _FUNDAMENTALS_TTL = 600 # 10분 캐시 async def get_stock_fundamentals(stock_code: str) -> Optional[Dict]: """네이버 m.stock integration API로 실적·컨센서스·목표주가 조회. 매수 시점 "스토리" 기록용(매매 판단엔 미사용 — Phase 1). 실패/타임아웃 시 None (호출측에서 기술 스토리로 폴백). 10분 TTL 캐시. Returns: {"per","eps","target_price","recomm_mean"} 중 확보된 값. 없으면 None. """ import time as _t _now = _t.monotonic() _c = _FUNDAMENTALS_CACHE.get(stock_code) if _c and _now - _c[0] < _FUNDAMENTALS_TTL: return _c[1] try: url = f"https://m.stock.naver.com/api/stock/{stock_code}/integration" async with httpx.AsyncClient(verify=True, timeout=5.0, headers=_HEADERS) as client: resp = await client.get(url) if resp.status_code != 200: _FUNDAMENTALS_CACHE[stock_code] = (_now, None) return None data = resp.json() out: Dict = {} for it in (data.get("totalInfos") or []): k = it.get("key") if k == "PER": out["per"] = it.get("value") elif k == "EPS": out["eps"] = it.get("value") cons = data.get("consensusInfo") or {} if cons.get("priceTargetMean"): out["target_price"] = cons.get("priceTargetMean") if cons.get("recommMean"): out["recomm_mean"] = cons.get("recommMean") result = out or None _FUNDAMENTALS_CACHE[stock_code] = (_now, result) return result except Exception as e: warn_throttled(logger, "naver_finance:444", "[FUNDAMENTALS] %s 조회 실패: %s", stock_code, e) _FUNDAMENTALS_CACHE[stock_code] = (_now, None) return None