- GET /api/v1/market/indices — 네이버 JSON API(비-KIS), 45초 캐시 - 부분 실패 시 직전값 병합, 전부 실패 시 12초 쿨다운 + 직전 캐시 반환 - 방향 code 해석 불가 시 원 부호 폴백, NaN/inf 거부 - MarketIndexBar: 60초 갱신, 상승 빨강/하락 파랑, 장 마감 배지 - 테스트 12건 (전체 877 passed) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
130 lines
4.9 KiB
Python
130 lines
4.9 KiB
Python
"""
|
|
전체시장 지수 조회 (읽기 전용, 비-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
|