네이버 모바일 주식 API의 overMarketPriceInfo를 인식해 NXT 프리/애프터마켓 운영 중이면 overPrice를 current_price로 자동 전환. 포트폴리오 응답에 price_session(REGULAR/NXT_PRE/NXT_AFTER/CLOSED)과 price_as_of 메타 동봉. 이전엔 closePrice만 사용해 15:30 이후 NXT 거래가 진행 중이어도 평가금액이 동결됐음. 이제 가격이 자연스럽게 이어짐. _select_price_from_response는 순수 함수로 분리, unittest 8케이스로 회귀 방지. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
140 lines
5.2 KiB
Python
140 lines
5.2 KiB
Python
import time
|
|
import requests
|
|
from bs4 import BeautifulSoup
|
|
from typing import Optional
|
|
|
|
# 캐시는 detail 단위(가격+세션+as_of)로 보관. 호환용 단순 가격은 여기서 추출.
|
|
_cache: dict[str, tuple[Optional[dict], float]] = {} # ticker -> (detail | None, timestamp)
|
|
_CACHE_TTL = 180 # 3분
|
|
|
|
_HEADERS = {
|
|
"User-Agent": (
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
"Chrome/90.0.4430.93 Safari/537.36"
|
|
)
|
|
}
|
|
|
|
|
|
def _parse_price_str(value) -> Optional[int]:
|
|
if value is None:
|
|
return None
|
|
s = str(value).replace(",", "").strip()
|
|
if not s:
|
|
return None
|
|
# 음수/소수점도 일단 정수 라운드(국내 주식은 정수)
|
|
try:
|
|
return int(float(s))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _select_price_from_response(payload: dict) -> dict:
|
|
"""네이버 모바일 주식 API 응답 dict에서 (price, session, as_of)를 결정.
|
|
|
|
세션 분류:
|
|
- "REGULAR" : 정규장(KRX) 운영중 — closePrice가 실시간
|
|
- "NXT_PRE" : 정규장 마감 + NXT 프리마켓 운영중 → overPrice 사용
|
|
- "NXT_AFTER" : 정규장 마감 + NXT 애프터마켓 운영중 → overPrice 사용
|
|
- "CLOSED" : 정규장 마감 + NXT 비운영/거래중지 → closePrice 사용
|
|
|
|
반환 dict: {"price": int | None, "session": str, "as_of": str | None}
|
|
"""
|
|
close_price = _parse_price_str(payload.get("closePrice") or payload.get("stockEndPrice"))
|
|
top_as_of = payload.get("localTradedAt")
|
|
|
|
market_status = (payload.get("marketStatus") or "").upper()
|
|
if market_status == "OPEN":
|
|
return {"price": close_price, "session": "REGULAR", "as_of": top_as_of}
|
|
|
|
over = payload.get("overMarketPriceInfo")
|
|
if isinstance(over, dict):
|
|
over_status = (over.get("overMarketStatus") or "").upper()
|
|
trade_stop_name = ((over.get("tradeStopType") or {}).get("name") or "").upper()
|
|
if over_status == "OPEN" and trade_stop_name == "TRADING":
|
|
over_price = _parse_price_str(over.get("overPrice"))
|
|
if over_price is not None:
|
|
session_type = (over.get("tradingSessionType") or "").upper()
|
|
if session_type == "PRE_MARKET":
|
|
session = "NXT_PRE"
|
|
elif session_type == "AFTER_MARKET":
|
|
session = "NXT_AFTER"
|
|
else:
|
|
# 알 수 없는 NXT 세션은 보수적으로 AFTER 취급
|
|
session = "NXT_AFTER"
|
|
return {
|
|
"price": over_price,
|
|
"session": session,
|
|
"as_of": over.get("localTradedAt") or top_as_of,
|
|
}
|
|
|
|
return {"price": close_price, "session": "CLOSED", "as_of": top_as_of}
|
|
|
|
|
|
def _fetch_mobile_api_payload(ticker: str) -> Optional[dict]:
|
|
"""네이버 모바일 주식 API 응답 dict 반환."""
|
|
url = f"https://m.stock.naver.com/api/stock/{ticker}/basic"
|
|
try:
|
|
resp = requests.get(url, headers=_HEADERS, timeout=5)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _fetch_close_price_from_html(ticker: str) -> Optional[int]:
|
|
"""네이버 금융 HTML 폴백 (정규장 종가만 가능, NXT 정보 없음)."""
|
|
url = f"https://finance.naver.com/item/main.naver?code={ticker}"
|
|
try:
|
|
resp = requests.get(url, headers=_HEADERS, timeout=5)
|
|
resp.raise_for_status()
|
|
soup = BeautifulSoup(resp.content, "html.parser", from_encoding="cp949")
|
|
tag = soup.select_one(".no_today .blind")
|
|
if tag:
|
|
return _parse_price_str(tag.get_text(strip=True))
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def get_current_price_info(ticker: str) -> Optional[dict]:
|
|
"""단건 상세 가격 정보 조회 (3분 캐시).
|
|
|
|
반환: {"price": int | None, "session": str, "as_of": str | None} | None
|
|
"""
|
|
now = time.time()
|
|
cached = _cache.get(ticker)
|
|
if cached and (now - cached[1]) < _CACHE_TTL:
|
|
return cached[0]
|
|
|
|
detail: Optional[dict] = None
|
|
payload = _fetch_mobile_api_payload(ticker)
|
|
if isinstance(payload, dict):
|
|
detail = _select_price_from_response(payload)
|
|
if detail.get("price") is None:
|
|
detail = None # 폴백 시도
|
|
|
|
if detail is None:
|
|
fallback_price = _fetch_close_price_from_html(ticker)
|
|
if fallback_price is not None:
|
|
detail = {"price": fallback_price, "session": "CLOSED", "as_of": None}
|
|
|
|
_cache[ticker] = (detail, now)
|
|
return detail
|
|
|
|
|
|
def get_current_prices_detail(tickers: list[str]) -> dict[str, Optional[dict]]:
|
|
"""배치 상세 가격 조회 (캐시 미스 종목만 실제 호출)."""
|
|
return {ticker: get_current_price_info(ticker) for ticker in tickers}
|
|
|
|
|
|
def get_current_price(ticker: str) -> Optional[int]:
|
|
"""단건 현재가 조회 — 호환용. detail에서 price만 추출."""
|
|
detail = get_current_price_info(ticker)
|
|
return detail["price"] if detail else None
|
|
|
|
|
|
def get_current_prices(tickers: list[str]) -> dict[str, Optional[int]]:
|
|
"""배치 현재가 조회 — 호환용."""
|
|
return {ticker: get_current_price(ticker) for ticker in tickers}
|