Files
auto-stock/backend/app/services/naver_finance.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:01:31 +09:00

449 lines
16 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"
async def get_naver_daily_chart(
stock_code: str,
days: int = 80,
client: Optional[httpx.AsyncClient] = 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개만
return result[-days:] if len(result) > days else 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()
# ── 세계지수 크롤링 ──────────────────────────────────────────────────────────────
# 네이버 세계지수 심볼 매핑
_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