Files
web-page-backend/stock-lab/app/price_fetcher.py
gahusb a826e00399 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>
2026-05-11 19:32:10 +09:00

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}