- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
618 lines
24 KiB
Python
618 lines
24 KiB
Python
"""네이버 파이낸스 종목 목록 크롤러 + 일봉 차트 조회
|
|
|
|
KIS volume-rank API 대신 네이버 시가총액 순위 페이지에서
|
|
KOSPI/KOSDAQ 종목 목록을 가져온다.
|
|
일봉 차트 데이터도 네이버 API(JSON)에서 조회하여
|
|
KIS API 호출량을 절감한다.
|
|
|
|
사용처:
|
|
- batch_daily_analysis.get_all_tradable_stocks() DB 폴백 이전 1차 시도
|
|
- market_analyzer.get_all_tradable_stocks() 대체
|
|
- market_analyzer.analyze_stock() 배치 분석용 일봉 데이터 제공
|
|
|
|
URL (종목 목록): https://m.stock.naver.com/api/stocks/marketValue/{KOSPI|KOSDAQ}?page=N&pageSize=50
|
|
URL (일봉 차트): https://fchart.stock.naver.com/siseJson.naver?symbol=CODE&requestType=1&startTime=YYYYMMDD&endTime=YYYYMMDD&timeframe=day
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
import httpx
|
|
from bs4 import BeautifulSoup
|
|
|
|
from app.core.log_throttle import warn_throttled # noqa: E402
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; autostock-batch/1.0)"}
|
|
_BASE_URL = "https://finance.naver.com/sise/sise_market_sum.naver"
|
|
|
|
SKIP_KEYWORDS = [
|
|
"ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG",
|
|
"KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", "RISE",
|
|
"선물", "스팩", "리츠", "SPAC",
|
|
]
|
|
|
|
_MIN_PRICE = 5_000 # 5,000원 미만 제외
|
|
|
|
|
|
def _parse_page(html: bytes) -> List[Tuple[str, str, int]]:
|
|
"""HTML 파싱 → [(종목코드, 종목명, 현재가), ...]"""
|
|
soup = BeautifulSoup(html, "html.parser", from_encoding="euc-kr")
|
|
table = soup.find("table", {"class": "type_2"})
|
|
if not table:
|
|
return []
|
|
|
|
result = []
|
|
for row in table.find_all("tr"):
|
|
a = row.find("a", class_="tltle")
|
|
if not a:
|
|
continue
|
|
href = a.get("href", "")
|
|
if "code=" not in href:
|
|
continue
|
|
code = href.split("code=")[1]
|
|
name = a.text.strip()
|
|
|
|
# 현재가
|
|
tds = row.find_all("td", class_="number")
|
|
try:
|
|
price = int(tds[0].text.strip().replace(",", "")) if tds else 0
|
|
except (ValueError, IndexError):
|
|
price = 0
|
|
|
|
result.append((code, name, price))
|
|
return result
|
|
|
|
|
|
_LIST_API_URL = "https://m.stock.naver.com/api/stocks/marketValue/{market}"
|
|
_LIST_PAGE_SIZE = 50 # 구 HTML 페이지(~50종목/페이지)와 동일한 페이지 의미 유지
|
|
_SOSOK_TO_MARKET = {0: "KOSPI", 1: "KOSDAQ"}
|
|
|
|
|
|
def _parse_json_page(data: Dict) -> List[Tuple[str, str, int]]:
|
|
"""m.stock.naver.com JSON → [(종목코드, 종목명, 현재가), ...]"""
|
|
result = []
|
|
for item in data.get("stocks") or []:
|
|
code = item.get("itemCode") or ""
|
|
name = (item.get("stockName") or "").strip()
|
|
if not code or not name:
|
|
continue
|
|
try:
|
|
price = int(str(item.get("closePrice") or "0").replace(",", ""))
|
|
except ValueError:
|
|
price = 0
|
|
result.append((code, name, price))
|
|
return result
|
|
|
|
|
|
async def _fetch_page(client: httpx.AsyncClient, sosok: int, page: int) -> List[Tuple[str, str, int]]:
|
|
"""시가총액 순위 1페이지 조회.
|
|
|
|
2026-09 네이버가 finance.naver.com/sise/sise_market_sum.naver(HTML)를 폐기하고
|
|
stock.naver.com으로 302 리다이렉트 → m.stock.naver.com JSON API로 교체.
|
|
"""
|
|
market = _SOSOK_TO_MARKET.get(sosok, "KOSPI")
|
|
try:
|
|
resp = await client.get(
|
|
_LIST_API_URL.format(market=market),
|
|
params={"page": page, "pageSize": _LIST_PAGE_SIZE},
|
|
headers=_HEADERS,
|
|
timeout=10,
|
|
)
|
|
resp.raise_for_status()
|
|
return _parse_json_page(resp.json())
|
|
except Exception as exc:
|
|
logger.warning("네이버 파이낸스 조회 실패 sosok=%d page=%d: %s", sosok, page, exc)
|
|
return []
|
|
|
|
|
|
async def get_naver_stock_list(
|
|
max_pages_per_market: int = 10,
|
|
min_price: int = _MIN_PRICE,
|
|
limit: int = 0,
|
|
kospi_ratio: float = 0.6,
|
|
) -> List[Tuple[str, str]]:
|
|
"""KOSPI + KOSDAQ 시가총액 순위 종목 조회.
|
|
|
|
Args:
|
|
max_pages_per_market: 시장별 최대 페이지 수 (페이지당 ~50종목)
|
|
min_price: 최소 현재가 필터 (기본 5,000원)
|
|
limit: 최종 반환 종목 수 제한 (0=제한 없음)
|
|
kospi_ratio: limit 적용 시 KOSPI 비율 (기본 0.6 = 60%)
|
|
|
|
Returns:
|
|
[(종목코드, 종목명), ...] — ETF/레버리지/스팩 제외, 가격 필터 적용
|
|
"""
|
|
seen_codes: set = set()
|
|
kospi_stocks: List[Tuple[str, str]] = []
|
|
kosdaq_stocks: List[Tuple[str, str]] = []
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
for sosok, market_name in ((0, "KOSPI"), (1, "KOSDAQ")):
|
|
market_stocks: List[Tuple[str, str]] = []
|
|
for page in range(1, max_pages_per_market + 1):
|
|
rows = await _fetch_page(client, sosok, page)
|
|
if not rows:
|
|
break # 마지막 페이지
|
|
|
|
for code, name, price in rows:
|
|
if code in seen_codes:
|
|
continue
|
|
if price < min_price:
|
|
continue
|
|
name_upper = name.upper()
|
|
if any(kw.upper() in name_upper for kw in SKIP_KEYWORDS):
|
|
continue
|
|
seen_codes.add(code)
|
|
market_stocks.append((code, name))
|
|
|
|
await asyncio.sleep(0.3) # 서버 부하 방지
|
|
|
|
logger.info("네이버 %s: %d개 종목 수집", market_name, len(market_stocks))
|
|
if sosok == 0:
|
|
kospi_stocks = market_stocks
|
|
else:
|
|
kosdaq_stocks = market_stocks
|
|
|
|
# limit가 있으면 KOSPI/KOSDAQ 균형 배분
|
|
if limit > 0:
|
|
kospi_limit = int(limit * kospi_ratio)
|
|
kosdaq_limit = limit - kospi_limit
|
|
# 한쪽이 부족하면 다른 쪽에서 채움
|
|
if len(kospi_stocks) < kospi_limit:
|
|
kosdaq_limit += kospi_limit - len(kospi_stocks)
|
|
kospi_limit = len(kospi_stocks)
|
|
elif len(kosdaq_stocks) < kosdaq_limit:
|
|
kospi_limit += kosdaq_limit - len(kosdaq_stocks)
|
|
kosdaq_limit = len(kosdaq_stocks)
|
|
stocks = kospi_stocks[:kospi_limit] + kosdaq_stocks[:kosdaq_limit]
|
|
logger.info(
|
|
"네이버 파이낸스 총 %d개 종목 (KOSPI %d + KOSDAQ %d)",
|
|
len(stocks), min(len(kospi_stocks), kospi_limit), min(len(kosdaq_stocks), kosdaq_limit),
|
|
)
|
|
else:
|
|
stocks = kospi_stocks + kosdaq_stocks
|
|
logger.info("네이버 파이낸스 총 %d개 종목 수집", len(stocks))
|
|
|
|
return stocks
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 네이버 일봉 차트 데이터 조회 (KIS API 대체)
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
_CHART_URL = "https://fchart.stock.naver.com/siseJson.naver"
|
|
|
|
|
|
# 호가단위 계수에서 제외할 ETF/ETN/벤치마크 코드. 종목명을 모르는 이 모듈은 코드로만 판정하므로
|
|
# 배치/호출부가 register_etf_codes_v2 로 알려주거나 get_naver_daily_chart_v2(is_etf=True) 로 넘긴다.
|
|
_BENCHMARK_ETF_CODES = frozenset({"069500", "122630", "252670", "114800"})
|
|
_registered_etf_codes: set = set()
|
|
|
|
|
|
def register_etf_codes_v2(codes) -> int:
|
|
"""ETF/ETN 코드를 등록해 이후 차트 조회의 호가단위 계수에서 제외한다. 등록 후 총 개수 반환."""
|
|
for c in codes or []:
|
|
if c:
|
|
_registered_etf_codes.add(str(c).strip())
|
|
return len(_registered_etf_codes)
|
|
|
|
|
|
def is_non_stock_code_v2(stock_code: str) -> bool:
|
|
"""주식 호가단위로 검사하면 안 되는 코드인가(벤치마크·등록 ETF·6자리 숫자가 아닌 코드)."""
|
|
code = str(stock_code or "").strip()
|
|
return (
|
|
code in _BENCHMARK_ETF_CODES
|
|
or code in _registered_etf_codes
|
|
or not (len(code) == 6 and code.isdigit())
|
|
)
|
|
|
|
|
|
async def get_naver_daily_chart(
|
|
stock_code: str,
|
|
days: int = 80,
|
|
client: Optional[httpx.AsyncClient] = None,
|
|
) -> Optional[List[Dict]]:
|
|
"""기존 시그니처 유지 래퍼. ETF 여부는 코드 기반(is_non_stock_code_v2)으로 판정한다."""
|
|
return await get_naver_daily_chart_v2(stock_code, days=days, client=client)
|
|
|
|
|
|
async def get_naver_daily_chart_v2(
|
|
stock_code: str,
|
|
days: int = 80,
|
|
client: Optional[httpx.AsyncClient] = None,
|
|
is_etf: Optional[bool] = None,
|
|
) -> Optional[List[Dict]]:
|
|
"""네이버 일봉 차트 → KIS 형식 dict 리스트 (oldest first).
|
|
|
|
Returns:
|
|
[{"stck_bsop_date": "20260316", "stck_clpr": "55000",
|
|
"stck_oprc": "54500", "stck_hgpr": "55500", "stck_lwpr": "54000",
|
|
"acml_vol": "1234567"}, ...] or None on failure
|
|
"""
|
|
end_dt = datetime.now()
|
|
start_dt = end_dt - timedelta(days=days + 30) # 여유분 확보
|
|
|
|
params = {
|
|
"symbol": stock_code,
|
|
"requestType": "1",
|
|
"startTime": start_dt.strftime("%Y%m%d"),
|
|
"endTime": end_dt.strftime("%Y%m%d"),
|
|
"timeframe": "day",
|
|
}
|
|
|
|
close_client = False
|
|
if client is None:
|
|
client = httpx.AsyncClient()
|
|
close_client = True
|
|
|
|
try:
|
|
resp = await client.get(
|
|
_CHART_URL, params=params, headers=_HEADERS, timeout=10,
|
|
)
|
|
resp.raise_for_status()
|
|
text = resp.text.strip()
|
|
|
|
# 네이버 응답: JS 배열 형태 문자열 — 첫 줄 헤더, 이후 데이터
|
|
# 예: [["날짜","시가","고가","저가","종가","거래량"],\n["20260314", ...], ...]
|
|
lines = [
|
|
line.strip().strip(",")
|
|
for line in text.strip("[]").split("\n")
|
|
if line.strip() and line.strip().strip(",")
|
|
]
|
|
|
|
if len(lines) < 2:
|
|
return None
|
|
|
|
result: List[Dict] = []
|
|
for line in lines[1:]: # 첫 줄(헤더) 스킵
|
|
line = line.strip().strip("[]")
|
|
if not line:
|
|
continue
|
|
parts = [p.strip().strip('"').strip("'") for p in line.split(",")]
|
|
if len(parts) < 6:
|
|
continue
|
|
try:
|
|
dt_str = parts[0].strip().replace(".", "").replace("-", "").replace("/", "")[:8]
|
|
if len(dt_str) != 8:
|
|
continue
|
|
result.append({
|
|
"stck_bsop_date": dt_str,
|
|
"stck_oprc": parts[1].strip(),
|
|
"stck_hgpr": parts[2].strip(),
|
|
"stck_lwpr": parts[3].strip(),
|
|
"stck_clpr": parts[4].strip(),
|
|
"acml_vol": parts[5].strip(),
|
|
})
|
|
except (ValueError, IndexError):
|
|
continue
|
|
|
|
if not result:
|
|
return None
|
|
|
|
# 최신 days개만
|
|
result = result[-days:] if len(result) > days else result
|
|
# 호가단위 의심 봉 표식(값 불변). ETF/벤치마크는 호가단위가 달라 계수에서 제외.
|
|
etf = is_non_stock_code_v2(stock_code) if is_etf is None else bool(is_etf)
|
|
annotate_naver_bars_v2(result, is_etf=etf)
|
|
return result
|
|
|
|
except Exception as exc:
|
|
warn_throttled(logger, "naver_finance:264", "네이버 차트 조회 실패 %s: %s", stock_code, exc)
|
|
return None
|
|
finally:
|
|
if close_client:
|
|
await client.aclose()
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 네이버 일봉 종가 신뢰성 검증 (KRX 호가단위 / KRX 종가 대조)
|
|
# 운영 DB 실측: 네이버 종가에 KRX 호가단위로 체결 불가능한 값이 섞이고 전일 종가가 KRX와 다르다.
|
|
# 원인(NXT 통합 체결가 여부)은 미확인(추정). 값은 바꾸지 않고 의심 봉을 계수·노출만 한다.
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
_naver_close_stats: Dict[str, int] = {
|
|
"checked": 0, "off_tick": 0, "krx_compared": 0, "krx_mismatch": 0,
|
|
}
|
|
|
|
|
|
def krx_tick_size(price: float) -> int:
|
|
"""KRX 주식 호가단위(2023-01 통합 기준). ETF/ETN 은 별도(5원 등)라 호출부가 제외해야 한다."""
|
|
if price < 2000:
|
|
return 1
|
|
if price < 5000:
|
|
return 5
|
|
if price < 20000:
|
|
return 10
|
|
if price < 50000:
|
|
return 50
|
|
if price < 200000:
|
|
return 100
|
|
if price < 500000:
|
|
return 500
|
|
return 1000
|
|
|
|
|
|
def is_krx_tick_valid(price: float) -> bool:
|
|
"""가격이 KRX 주식 호가단위 배수인가. 0 이하/비정상 값은 False."""
|
|
try:
|
|
p = int(round(float(price)))
|
|
except (TypeError, ValueError):
|
|
return False
|
|
if p <= 0 or abs(float(price) - p) > 1e-9:
|
|
return False
|
|
return p % krx_tick_size(p) == 0
|
|
|
|
|
|
def annotate_naver_bars_v2(rows: Optional[List[Dict]], is_etf: bool = False) -> Optional[List[Dict]]:
|
|
"""각 봉에 종가 호가단위 적합 여부(`krx_tick_ok`)를 붙이고 통계를 누적한다.
|
|
|
|
값(종가 등)은 변경하지 않는다. ETF/ETN 은 호가단위가 달라 검사에서 제외한다.
|
|
"""
|
|
if not rows or is_etf:
|
|
return rows
|
|
for r in rows:
|
|
try:
|
|
ok = is_krx_tick_valid(float(r.get("stck_clpr", 0)))
|
|
except (TypeError, ValueError):
|
|
ok = False
|
|
r["krx_tick_ok"] = ok
|
|
_naver_close_stats["checked"] += 1
|
|
if not ok:
|
|
_naver_close_stats["off_tick"] += 1
|
|
return rows
|
|
|
|
|
|
def compare_naver_with_krx_v2(naver_rows: Optional[List[Dict]], krx_rows: Optional[List[Dict]]) -> Dict[str, int]:
|
|
"""같은 날짜의 네이버 종가를 KRX 종가(stck_bsop_date/stck_clpr 형식)와 대조한다.
|
|
|
|
불일치 봉에는 `krx_close_mismatch=True` 를 붙이고 통계를 누적한다. 값은 바꾸지 않는다.
|
|
"""
|
|
krx = {}
|
|
for r in krx_rows or []:
|
|
try:
|
|
krx[str(r["stck_bsop_date"])] = float(r["stck_clpr"])
|
|
except (KeyError, TypeError, ValueError):
|
|
continue
|
|
compared = mismatch = 0
|
|
for r in naver_rows or []:
|
|
k = krx.get(str(r.get("stck_bsop_date")))
|
|
if k is None:
|
|
continue
|
|
try:
|
|
n = float(r.get("stck_clpr", 0))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
compared += 1
|
|
bad = abs(n - k) > 1e-9
|
|
r["krx_close_mismatch"] = bad
|
|
if bad:
|
|
mismatch += 1
|
|
_naver_close_stats["krx_compared"] += compared
|
|
_naver_close_stats["krx_mismatch"] += mismatch
|
|
return {"compared": compared, "mismatch": mismatch}
|
|
|
|
|
|
def get_naver_close_quality() -> Dict[str, float]:
|
|
"""/health 노출용 누적 통계. off_tick_ratio·krx_mismatch_ratio 는 표본 0이면 0.0."""
|
|
st = dict(_naver_close_stats)
|
|
st["off_tick_ratio"] = (st["off_tick"] / st["checked"]) if st["checked"] else 0.0
|
|
st["krx_mismatch_ratio"] = (st["krx_mismatch"] / st["krx_compared"]) if st["krx_compared"] else 0.0
|
|
return st
|
|
|
|
|
|
def naver_close_quality_alert_v2(max_krx_mismatch_ratio: float = 0.05, min_samples: int = 20) -> Optional[str]:
|
|
"""KRX 대조 불일치 비율이 임계 초과면 경보 문구, 아니면 None. /health/detail 배선용(main.py 몫)."""
|
|
q = get_naver_close_quality()
|
|
if q["krx_compared"] >= min_samples and q["krx_mismatch_ratio"] > max_krx_mismatch_ratio:
|
|
return (f"네이버-KRX 종가 불일치 {q['krx_mismatch_ratio']:.1%} "
|
|
f"({q['krx_mismatch']}/{q['krx_compared']}) > {max_krx_mismatch_ratio:.0%}")
|
|
return None
|
|
|
|
|
|
# ── 세계지수 크롤링 ──────────────────────────────────────────────────────────────
|
|
|
|
# 네이버 세계지수 심볼 매핑
|
|
_WORLD_INDEX_SYMBOLS = {
|
|
"SP500": "SPI@SPX",
|
|
"NASDAQ": "NAS@IXIC",
|
|
"DOW": "DJI@DJI",
|
|
}
|
|
|
|
_WORLD_INDEX_URL = "https://finance.naver.com/world/sise.naver"
|
|
|
|
|
|
def _parse_world_index_page(soup: BeautifulSoup) -> Optional[Dict]:
|
|
"""네이버 세계지수 상세 페이지에서 현재가/변동률 파싱.
|
|
|
|
HTML 구조:
|
|
<p class="no_today"><em class="no_up|no_down">숫자 span들</em></p>
|
|
<p class="no_exday">전일대비 XX.XX (+Y.YY%)</p>
|
|
"""
|
|
try:
|
|
# 현재가
|
|
price_em = soup.select_one("p.no_today em")
|
|
if not price_em:
|
|
return None
|
|
price_text = price_em.get_text(strip=True).replace(",", "")
|
|
price = float(price_text)
|
|
|
|
# 방향: em 클래스가 no_up / no_down
|
|
direction = 1.0
|
|
cls_list = price_em.get("class", [])
|
|
if "no_down" in cls_list:
|
|
direction = -1.0
|
|
|
|
# 변동값 + 변동률: "전일대비35.53(+0.54%)"
|
|
exday_el = soup.select_one("p.no_exday")
|
|
change = 0.0
|
|
change_pct = 0.0
|
|
if exday_el:
|
|
text = exday_el.get_text(strip=True)
|
|
# 변동값: em 내부 숫자
|
|
change_em = exday_el.select_one("em")
|
|
if change_em:
|
|
change_text = change_em.get_text(strip=True).replace(",", "")
|
|
try:
|
|
change = float(change_text) * direction
|
|
except ValueError:
|
|
pass
|
|
# 변동률: 괄호 안 퍼센트
|
|
import re
|
|
pct_match = re.search(r"\(([+-]?\d+\.?\d*)%\)", text)
|
|
if pct_match:
|
|
change_pct = float(pct_match.group(1))
|
|
|
|
return {
|
|
"price": price,
|
|
"change": change,
|
|
"change_pct": change_pct,
|
|
}
|
|
except Exception as e:
|
|
logger.warning("세계지수 파싱 실패: %s", e)
|
|
return None
|
|
|
|
|
|
async def get_world_indices() -> Dict[str, Dict]:
|
|
"""네이버 금융에서 주요 세계지수(S&P500, NASDAQ, DOW) 조회.
|
|
|
|
Returns:
|
|
{"SP500": {"price": 5500.0, "change": 35.5, "change_pct": 0.54}, ...}
|
|
"""
|
|
result: Dict[str, Dict] = {}
|
|
try:
|
|
async with httpx.AsyncClient() as client:
|
|
for name, symbol in _WORLD_INDEX_SYMBOLS.items():
|
|
try:
|
|
resp = await client.get(
|
|
_WORLD_INDEX_URL,
|
|
params={"symbol": symbol},
|
|
headers=_HEADERS,
|
|
timeout=10,
|
|
)
|
|
resp.raise_for_status()
|
|
soup = BeautifulSoup(resp.content, "html.parser")
|
|
parsed = _parse_world_index_page(soup)
|
|
if parsed:
|
|
result[name] = parsed
|
|
await asyncio.sleep(0.3)
|
|
except Exception as e:
|
|
logger.warning("세계지수 %s 조회 실패: %s", name, e)
|
|
except Exception as e:
|
|
logger.error("세계지수 크롤링 전체 실패: %s", e)
|
|
return result
|
|
|
|
|
|
async def get_vix_index() -> Optional[Dict]:
|
|
"""Yahoo Finance API에서 VIX 지수 조회 (네이버에 VIX 없음).
|
|
|
|
Returns:
|
|
{"price": 25.3, "prev_close": 24.0, "change": 1.3, "change_pct": 5.4}
|
|
"""
|
|
try:
|
|
url = "https://query1.finance.yahoo.com/v8/finance/chart/%5EVIX"
|
|
params = {"range": "5d", "interval": "1d"}
|
|
headers = {"User-Agent": "Mozilla/5.0"}
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.get(url, params=params, headers=headers, timeout=10)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
chart_result = data.get("chart", {}).get("result", [])
|
|
if not chart_result:
|
|
return None
|
|
|
|
meta = chart_result[0].get("meta", {})
|
|
price = float(meta.get("regularMarketPrice", 0))
|
|
prev_close = float(meta.get("chartPreviousClose", 0))
|
|
change = price - prev_close
|
|
change_pct = (change / prev_close * 100) if prev_close > 0 else 0.0
|
|
|
|
return {
|
|
"price": round(price, 2),
|
|
"prev_close": round(prev_close, 2),
|
|
"change": round(change, 2),
|
|
"change_pct": round(change_pct, 2),
|
|
}
|
|
except Exception as e:
|
|
logger.warning("VIX 조회 실패: %s", e)
|
|
return None
|
|
|
|
|
|
# ── 종목 펀더멘털(실적·컨센서스·목표주가) — 매수 스토리 기록용 (Phase 1) ────────────
|
|
_FUNDAMENTALS_CACHE: Dict[str, Tuple[float, Optional[Dict]]] = {}
|
|
_FUNDAMENTALS_TTL = 600 # 10분 캐시
|
|
|
|
|
|
async def get_stock_fundamentals(stock_code: str) -> Optional[Dict]:
|
|
"""네이버 m.stock integration API로 실적·컨센서스·목표주가 조회.
|
|
|
|
매수 시점 "스토리" 기록용(매매 판단엔 미사용 — Phase 1). 실패/타임아웃 시 None
|
|
(호출측에서 기술 스토리로 폴백). 10분 TTL 캐시.
|
|
|
|
Returns:
|
|
{"per","eps","target_price","recomm_mean"} 중 확보된 값. 없으면 None.
|
|
"""
|
|
import time as _t
|
|
_now = _t.monotonic()
|
|
_c = _FUNDAMENTALS_CACHE.get(stock_code)
|
|
if _c and _now - _c[0] < _FUNDAMENTALS_TTL:
|
|
return _c[1]
|
|
try:
|
|
url = f"https://m.stock.naver.com/api/stock/{stock_code}/integration"
|
|
async with httpx.AsyncClient(verify=True, timeout=5.0, headers=_HEADERS) as client:
|
|
resp = await client.get(url)
|
|
if resp.status_code != 200:
|
|
_FUNDAMENTALS_CACHE[stock_code] = (_now, None)
|
|
return None
|
|
data = resp.json()
|
|
out: Dict = {}
|
|
for it in (data.get("totalInfos") or []):
|
|
k = it.get("key")
|
|
if k == "PER":
|
|
out["per"] = it.get("value")
|
|
elif k == "EPS":
|
|
out["eps"] = it.get("value")
|
|
cons = data.get("consensusInfo") or {}
|
|
if cons.get("priceTargetMean"):
|
|
out["target_price"] = cons.get("priceTargetMean")
|
|
if cons.get("recommMean"):
|
|
out["recomm_mean"] = cons.get("recommMean")
|
|
result = out or None
|
|
_FUNDAMENTALS_CACHE[stock_code] = (_now, result)
|
|
return result
|
|
except Exception as e:
|
|
warn_throttled(logger, "naver_finance:444", "[FUNDAMENTALS] %s 조회 실패: %s", stock_code, e)
|
|
_FUNDAMENTALS_CACHE[stock_code] = (_now, None)
|
|
return None
|
|
|
|
|
|
# ── 종목명 단건 조회 (KIS 시세 응답에 종목명이 없을 때의 폴백) ─────────────────────
|
|
_STOCK_NAME_CACHE: Dict[str, Tuple[float, str]] = {}
|
|
_STOCK_NAME_TTL = 86400.0 # 성공 캐시 24h (종목명은 거의 불변)
|
|
_STOCK_NAME_NEG_TTL = 300.0 # 실패 캐시 5분 (장애 시 요청마다 외부 호출 방지)
|
|
|
|
|
|
async def get_naver_stock_name(stock_code: str) -> str:
|
|
"""네이버 m.stock basic API 로 종목명 조회. 실패 시 "" (호출측이 다른 폴백 사용)."""
|
|
import time as _t
|
|
_now = _t.monotonic()
|
|
_c = _STOCK_NAME_CACHE.get(stock_code)
|
|
if _c and _now - _c[0] < (_STOCK_NAME_TTL if _c[1] else _STOCK_NAME_NEG_TTL):
|
|
return _c[1]
|
|
name = ""
|
|
try:
|
|
url = f"https://m.stock.naver.com/api/stock/{stock_code}/basic"
|
|
async with httpx.AsyncClient(verify=True, timeout=2.5, headers=_HEADERS) as client:
|
|
resp = await client.get(url)
|
|
if resp.status_code == 200:
|
|
name = str((resp.json() or {}).get("stockName") or "").strip()
|
|
except Exception as e:
|
|
warn_throttled(logger, "naver_finance:stock_name", "[NAME] %s 종목명 조회 실패: %s", stock_code, e)
|
|
_STOCK_NAME_CACHE[stock_code] = (_now, name)
|
|
return name
|