feat(stock): NXT 시간외 거래가를 정규장 마감 후 자동 연결
네이버 모바일 주식 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>
This commit is contained in:
@@ -3,7 +3,8 @@ import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from typing import Optional
|
||||
|
||||
_cache: dict[str, tuple[Optional[int], float]] = {} # ticker -> (price, timestamp)
|
||||
# 캐시는 detail 단위(가격+세션+as_of)로 보관. 호환용 단순 가격은 여기서 추출.
|
||||
_cache: dict[str, tuple[Optional[dict], float]] = {} # ticker -> (detail | None, timestamp)
|
||||
_CACHE_TTL = 180 # 3분
|
||||
|
||||
_HEADERS = {
|
||||
@@ -15,22 +16,74 @@ _HEADERS = {
|
||||
}
|
||||
|
||||
|
||||
def _fetch_from_mobile_api(ticker: str) -> Optional[int]:
|
||||
"""네이버 모바일 주식 API로 현재가 조회"""
|
||||
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()
|
||||
data = resp.json()
|
||||
price_str = data.get("closePrice") or data.get("stockEndPrice") or ""
|
||||
price_str = str(price_str).replace(",", "").strip()
|
||||
return int(price_str) if price_str.isdigit() else None
|
||||
return resp.json()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_from_html_fallback(ticker: str) -> Optional[int]:
|
||||
"""네이버 금융 HTML 폴백 (.no_today .blind 파싱)"""
|
||||
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)
|
||||
@@ -38,31 +91,49 @@ def _fetch_from_html_fallback(ticker: str) -> Optional[int]:
|
||||
soup = BeautifulSoup(resp.content, "html.parser", from_encoding="cp949")
|
||||
tag = soup.select_one(".no_today .blind")
|
||||
if tag:
|
||||
price_str = tag.get_text(strip=True).replace(",", "")
|
||||
return int(price_str) if price_str.isdigit() else None
|
||||
return _parse_price_str(tag.get_text(strip=True))
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_price(ticker: str) -> Optional[int]:
|
||||
"""단건 현재가 조회 (3분 캐시)"""
|
||||
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]
|
||||
|
||||
price = _fetch_from_mobile_api(ticker)
|
||||
if price is None:
|
||||
price = _fetch_from_html_fallback(ticker)
|
||||
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 # 폴백 시도
|
||||
|
||||
_cache[ticker] = (price, now)
|
||||
return price
|
||||
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]]:
|
||||
"""배치 현재가 조회 (캐시 미스 종목만 실제 호출)"""
|
||||
result: dict[str, Optional[int]] = {}
|
||||
for ticker in tickers:
|
||||
result[ticker] = get_current_price(ticker)
|
||||
return result
|
||||
"""배치 현재가 조회 — 호환용."""
|
||||
return {ticker: get_current_price(ticker) for ticker in tickers}
|
||||
|
||||
Reference in New Issue
Block a user