""" 전체시장 지수 조회 (읽기 전용, 비-KIS) - 국내: https://m.stock.naver.com/api/index/{KOSPI|KOSDAQ|KPI200}/basic - 미국: https://api.stock.naver.com/index/{.INX|.IXIC}/basic - 인메모리 캐시 (TTL 45초). 실패한 지수는 결과에서 빠지고 warning 로그. - DB 쓰기·KIS 호출 없음. """ import asyncio import logging import math import time from typing import Any, Dict, List, Optional import httpx logger = logging.getLogger(__name__) _HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; autostock-batch/1.0)"} CACHE_TTL_SEC = 45.0 FAIL_COOLDOWN_SEC = 12.0 # 전부 실패 시 재조회 억제 # (key, 표시명, 시장, URL) INDEX_SOURCES = [ ("KOSPI", "코스피", "KR", "https://m.stock.naver.com/api/index/KOSPI/basic"), ("KOSDAQ", "코스닥", "KR", "https://m.stock.naver.com/api/index/KOSDAQ/basic"), ("KOSPI200", "코스피200", "KR", "https://m.stock.naver.com/api/index/KPI200/basic"), ("SP500", "S&P 500", "US", "https://api.stock.naver.com/index/.INX/basic"), ("NASDAQ", "나스닥", "US", "https://api.stock.naver.com/index/.IXIC/basic"), ] _cache: Dict[str, Any] = {"ts": 0.0, "data": None, "by_key": {}, "fail_ts": 0.0} _lock = asyncio.Lock() def _num(v: Any) -> Optional[float]: if v is None: return None try: f = float(str(v).replace(",", "").replace("+", "").strip()) except ValueError: return None return f if math.isfinite(f) else None def parse_index_payload(key: str, name: str, market: str, data: Dict) -> Optional[Dict]: """네이버 지수 basic JSON → 표준 dict. 필수 값 없으면 None. KR 응답은 변동값이 부호 없이 오고 방향은 compareToPreviousPrice.code 로 표현된다 (1 상한/2 상승/3 보합/4 하한/5 하락). US 는 부호가 포함된다 → 절대값 + code 로 통일. """ if not isinstance(data, dict): return None price = _num(data.get("closePrice")) if price is None: return None raw_change = _num(data.get("compareToPreviousClosePrice")) or 0.0 raw_pct = _num(data.get("fluctuationsRatio")) or 0.0 cmp_ = data.get("compareToPreviousPrice") code = str(cmp_.get("code", "")) if isinstance(cmp_, dict) else "" if code in ("1", "2"): change, pct = abs(raw_change), abs(raw_pct) elif code in ("4", "5"): change, pct = -abs(raw_change), -abs(raw_pct) elif code == "3": change, pct = 0.0, 0.0 else: # 방향 코드 해석 불가 → 원 값의 부호 유지 change, pct = raw_change, raw_pct return { "code": key, "name": name, "market": market, "price": price, "change": round(change, 2), "change_pct": round(pct, 2), "market_status": data.get("marketStatus"), "updated_at": data.get("localTradedAt"), } async def _fetch_one(client: httpx.AsyncClient, key: str, name: str, market: str, url: str) -> Optional[Dict]: try: resp = await client.get(url, headers=_HEADERS, timeout=5.0) resp.raise_for_status() parsed = parse_index_payload(key, name, market, resp.json()) if parsed is None: logger.warning("지수 %s 응답 파싱 불가", key) return parsed except Exception as e: logger.warning("지수 %s 조회 실패: %s", key, e) return None async def get_market_indices_cached() -> List[Dict]: """지수 목록. 45초 캐시. - 부분 실패: 실패한 key 만 직전 캐시 값으로 병합. - 전부 실패: 직전 캐시(없으면 [])를 반환하고 FAIL_COOLDOWN_SEC 동안 재조회하지 않는다. """ now = time.monotonic() if _cache["data"] is not None and now - _cache["ts"] < CACHE_TTL_SEC: return _cache["data"] if _cache["fail_ts"] and now - _cache["fail_ts"] < FAIL_COOLDOWN_SEC: return _cache["data"] or [] async with _lock: now = time.monotonic() if _cache["data"] is not None and now - _cache["ts"] < CACHE_TTL_SEC: return _cache["data"] if _cache["fail_ts"] and now - _cache["fail_ts"] < FAIL_COOLDOWN_SEC: return _cache["data"] or [] async with httpx.AsyncClient(verify=True) as client: results = await asyncio.gather( *[_fetch_one(client, k, n, m, u) for k, n, m, u in INDEX_SOURCES] ) if not any(results): logger.warning("지수 전부 조회 실패 — 직전 캐시 반환(%s초 쿨다운)", FAIL_COOLDOWN_SEC) _cache["fail_ts"] = now return _cache["data"] or [] by_key = _cache["by_key"] merged: List[Dict] = [] for (k, _n, _m, _u), r in zip(INDEX_SOURCES, results): if r: by_key[k] = r merged.append(r) elif k in by_key: logger.warning("지수 %s 실패 — 직전 값 유지", k) merged.append(by_key[k]) _cache.update(ts=now, data=merged, fail_ts=0.0) return merged