추천로직

This commit is contained in:
2026-03-06 10:01:13 +09:00
parent d6841a06f7
commit c93b35ba04
11 changed files with 1051 additions and 586 deletions

View File

@@ -1,26 +1,31 @@
"""
KRX 히스토리컬 데이터 수집 스크립트
pykrx를 사용하여 KOSPI/KOSDAQ 종목의 일봉 OHLCV 데이터를 DB에 저장합니다.
KRX OpenAPI (data-dbg.krx.co.kr)를 사용하여 KOSPI/KOSDAQ 종목의
일봉 OHLCV 데이터를 DB에 저장합니다.
- KIS API 의존성 완전 제거
- 날짜 1건 호출 → 해당일 전 종목 OHLCV 일괄 수신 (효율적)
사용법:
python collect_krx_data.py # 전체 수집 (10년, 주요 200종목)
python collect_krx_data.py --limit 5 --start-date 20240101 # 테스트 (5종목, 2024년~)
python collect_krx_data.py --update # 증분 업데이트 (마지막 저장일 이후)
python collect_krx_data.py --market kospi # KOSPI만
python collect_krx_data.py # 전체 수집 (2020-01-01~)
python collect_krx_data.py --limit 5 # 최근 5거래일 (테스트)
python collect_krx_data.py --start-date 20240101 # 시작일 지정
python collect_krx_data.py --update # 증분 업데이트
python collect_krx_data.py --market kospi # KOSPI만
"""
import argparse
import asyncio
import logging
import os
import sys
import time
from datetime import date, datetime, timedelta
from pathlib import Path
import numpy as np
import aiohttp
import pandas as pd
from pykrx import stock as pykrx_stock
# 프로젝트 루트를 Python 경로에 추가
sys.path.insert(0, str(Path(__file__).parent))
@@ -31,131 +36,186 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from app.core.database import AsyncSessionLocal, engine, Base
from app.models.models import StockDailyPrice
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
# ─── KRX OpenAPI 설정 ─────────────────────────────────────────────
# 실제 데이터 엔드포인트 (openapi.krx.co.kr 는 포털, 데이터는 data-dbg)
KRX_DATA_BASE = "https://data-dbg.krx.co.kr/svc/apis"
# ETF/특수 종목 제외 키워드
try:
from app.core.config import settings as _cfg
KRX_API_KEY: str = _cfg.KRX_API_KEY
except Exception:
from dotenv import load_dotenv
load_dotenv(Path(__file__).parent.parent / ".env")
KRX_API_KEY = os.environ.get("KRX_API_KEY", "")
# ─── ETF/특수 종목 제외 키워드 ────────────────────────────────────
SKIP_KEYWORDS = [
"ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG",
"KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX",
"선물", "스팩", "리츠", "SPAC",
]
# 날짜별 동시 요청 수 (KRX API 쓰로틀링 고려)
COLLECT_CONCURRENCY = 3
REQUEST_INTERVAL = 0.5 # 요청 간 최소 대기(초)
def get_stock_list(market: str = "all", limit: int = 0) -> list:
"""pykrx로 종목 목록 조회
Args:
market: "kospi", "kosdaq", "all"
limit: 0이면 전체, N이면 상위 N개
Returns:
[(stock_code, stock_name), ...]
"""
today = datetime.now().strftime("%Y%m%d")
stocks = []
markets = []
if market in ("kospi", "all"):
markets.append("KOSPI")
if market in ("kosdaq", "all"):
markets.append("KOSDAQ")
for mkt in markets:
try:
tickers = pykrx_stock.get_market_ticker_list(today, market=mkt)
for ticker in tickers:
name = pykrx_stock.get_market_ticker_name(ticker)
if any(k in name for k in SKIP_KEYWORDS):
continue
stocks.append((ticker, name))
logger.info(f"{mkt}: {len(tickers)}개 조회, 필터링 후 {len(stocks)}개")
except Exception as e:
logger.error(f"{mkt} 종목 목록 조회 실패: {e}")
# 중복 제거
seen = set()
unique = []
for code, name in stocks:
if code not in seen:
seen.add(code)
unique.append((code, name))
if limit > 0:
unique = unique[:limit]
logger.info(f"총 {len(unique)}개 종목 선정")
return unique
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
def fetch_ohlcv(stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
"""pykrx로 일봉 OHLCV 데이터 조회
# ─── 유틸 ─────────────────────────────────────────────────────────
Returns:
DataFrame with columns: date, open, high, low, close, volume, change_rate, market_cap
"""
def generate_trading_dates(start: str, end: str) -> list[str]:
"""start ~ end 사이 평일(월~금) 날짜 문자열 목록 생성 (YYYYMMDD)."""
d = datetime.strptime(start, "%Y%m%d").date()
e = datetime.strptime(end, "%Y%m%d").date()
dates: list[str] = []
while d <= e:
if d.weekday() < 5: # 0=Mon … 4=Fri
dates.append(d.strftime("%Y%m%d"))
d += timedelta(days=1)
return dates
def _int(val) -> int:
"""문자열/숫자를 int로 안전 변환 (쉼표 포함 처리)."""
try:
df = pykrx_stock.get_market_ohlcv_by_date(
start_date, end_date, stock_code, adjusted=True
)
if df.empty:
return pd.DataFrame()
return int(str(val).replace(",", "").strip() or "0")
except (ValueError, TypeError):
return 0
# 시가총액 조회
try:
cap_df = pykrx_stock.get_market_cap_by_date(start_date, end_date, stock_code)
if not cap_df.empty:
df["시가총액"] = cap_df["시가총액"]
except Exception:
df["시가총액"] = None
df = df.reset_index()
df.columns = [c.replace("날짜", "date") for c in df.columns]
def _float(val) -> float:
"""문자열/숫자를 float로 안전 변환."""
try:
return float(str(val).replace(",", "").strip() or "0")
except (ValueError, TypeError):
return 0.0
result = pd.DataFrame({
"date": df.iloc[:, 0], # 날짜
"open": df["시가"].astype(int),
"high": df["고가"].astype(int),
"low": df["저가"].astype(int),
"close": df["종가"].astype(int),
"volume": df["거래량"].astype(int),
"change_rate": df["등락률"] / 100.0 if "등락률" in df.columns else 0,
"market_cap": df.get("시가총액"),
})
# 거래량 0인 날 제거 (거래정지일)
result = result[result["volume"] > 0].reset_index(drop=True)
return result
# ─── KRX API 호출 ─────────────────────────────────────────────────
except Exception as e:
logger.error(f"{stock_code} OHLCV 조회 실패: {e}")
async def _fetch_krx(
session: aiohttp.ClientSession,
endpoint: str,
bas_dd: str,
) -> list:
"""KRX OpenAPI 단건 호출 → OutBlock_1 리스트 반환.
endpoint 예: "sto/stk_bydd_trd"
"""
url = f"{KRX_DATA_BASE}/{endpoint}"
try:
async with session.get(
url,
params={"basDd": bas_dd},
headers={"AUTH_KEY": KRX_API_KEY},
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status != 200:
logger.warning("KRX %s %s → HTTP %s", endpoint, bas_dd, resp.status)
return []
data = await resp.json(content_type=None)
return data.get("OutBlock_1") or []
except Exception as exc:
logger.error("KRX API 오류 [%s %s]: %s", endpoint, bas_dd, exc)
return []
async def fetch_daily_prices(
session: aiohttp.ClientSession,
market: str,
bas_dd: str,
) -> pd.DataFrame:
"""특정 날짜의 전 종목 OHLCV 조회.
KRX 응답 필드:
ISU_SRT_CD : 단축 종목코드 (e.g. "005930")
ISU_ABBRV : 종목 약칭
TDD_OPNPRC : 시가
TDD_HGPRC : 고가
TDD_LWPRC : 저가
TDD_CLSPRC : 종가
ACC_TRDVOL : 누적 거래량
FLUC_RT : 등락률 (%)
"""
endpoint = "sto/stk_bydd_trd" if market == "kospi" else "sto/ksq_bydd_trd"
items = await _fetch_krx(session, endpoint, bas_dd)
if not items:
return pd.DataFrame()
trade_date = datetime.strptime(bas_dd, "%Y%m%d").date()
rows: list[dict] = []
for item in items:
name = (item.get("ISU_ABBRV") or item.get("ISU_NM") or "").strip()
if any(kw in name for kw in SKIP_KEYWORDS):
continue
code = item.get("ISU_SRT_CD", "").strip()
if not code:
continue
volume = _int(item.get("ACC_TRDVOL"))
if volume == 0:
continue # 거래정지일 제외
close = _int(item.get("TDD_CLSPRC"))
if close == 0:
continue
rows.append({
"stock_code": code,
"stock_name": name,
"date": trade_date,
"open": _int(item.get("TDD_OPNPRC")),
"high": _int(item.get("TDD_HGPRC")),
"low": _int(item.get("TDD_LWPRC")),
"close": close,
"volume": volume,
"change_rate": _float(item.get("FLUC_RT")) / 100.0,
})
return pd.DataFrame(rows) if rows else pd.DataFrame()
async def get_stock_codes(
session: aiohttp.ClientSession,
market: str,
bas_dd: str,
) -> set[str]:
"""특정 날짜 기준 상장 종목 코드 집합 조회 (선택적 필터용)."""
endpoint = "sto/stk_isu_base_info" if market == "kospi" else "sto/ksq_isu_base_info"
items = await _fetch_krx(session, endpoint, bas_dd)
codes: set[str] = set()
for item in items:
code = item.get("ISU_SRT_CD", "").strip()
if code:
codes.add(code)
return codes
# ─── DB 헬퍼 ──────────────────────────────────────────────────────
async def create_tables():
"""DB 테이블 생성"""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("DB 테이블 확인/생성 완료")
async def get_last_trade_date(session, stock_code: str) -> date | None:
"""특정 종목의 DB 내 마지막 거래일 조회"""
async def get_last_trade_date(session) -> date | None:
"""DB 내 마지막 거래일 조회 (전 종목 공통)."""
result = await session.execute(
text(
"SELECT MAX(trade_date) FROM stock_daily_prices WHERE stock_code = :code"
),
{"code": stock_code},
text("SELECT MAX(trade_date) FROM stock_daily_prices")
)
row = result.scalar()
return row
return result.scalar()
async def upsert_daily_prices(session, stock_code: str, stock_name: str, df: pd.DataFrame):
"""DB에 일봉 데이터 upsert (중복 시 업데이트)"""
async def upsert_daily_prices(session, df: pd.DataFrame) -> int:
"""DB에 일봉 데이터 UPSERT (배치 500건)."""
if df.empty:
return 0
@@ -164,38 +224,35 @@ async def upsert_daily_prices(session, stock_code: str, stock_name: str, df: pd.
trade_date = row["date"]
if isinstance(trade_date, pd.Timestamp):
trade_date = trade_date.date()
record = {
"stock_code": stock_code,
"stock_name": stock_name,
"trade_date": trade_date,
"open_price": int(row["open"]),
"high_price": int(row["high"]),
"low_price": int(row["low"]),
records.append({
"stock_code": row["stock_code"],
"stock_name": row["stock_name"],
"trade_date": trade_date,
"open_price": int(row["open"]),
"high_price": int(row["high"]),
"low_price": int(row["low"]),
"close_price": int(row["close"]),
"volume": int(row["volume"]),
"volume": int(row["volume"]),
"change_rate": float(row["change_rate"]) if pd.notna(row["change_rate"]) else None,
"market_cap": int(row["market_cap"]) if pd.notna(row.get("market_cap")) else None,
}
records.append(record)
"market_cap": None,
})
# PostgreSQL ON CONFLICT upsert (batch)
batch_size = 500
inserted = 0
for i in range(0, len(records), batch_size):
batch = records[i:i + batch_size]
batch = records[i : i + batch_size]
stmt = pg_insert(StockDailyPrice).values(batch)
stmt = stmt.on_conflict_do_update(
constraint="uq_stock_daily",
set_={
"stock_name": stmt.excluded.stock_name,
"open_price": stmt.excluded.open_price,
"high_price": stmt.excluded.high_price,
"low_price": stmt.excluded.low_price,
"stock_name": stmt.excluded.stock_name,
"open_price": stmt.excluded.open_price,
"high_price": stmt.excluded.high_price,
"low_price": stmt.excluded.low_price,
"close_price": stmt.excluded.close_price,
"volume": stmt.excluded.volume,
"volume": stmt.excluded.volume,
"change_rate": stmt.excluded.change_rate,
"market_cap": stmt.excluded.market_cap,
"market_cap": stmt.excluded.market_cap,
},
)
await session.execute(stmt)
@@ -205,108 +262,146 @@ async def upsert_daily_prices(session, stock_code: str, stock_name: str, df: pd.
return inserted
# ─── 날짜별 수집 ──────────────────────────────────────────────────
async def collect_date(
i: int,
total: int,
bas_dd: str,
market: str,
semaphore: asyncio.Semaphore,
http_session: aiohttp.ClientSession,
) -> tuple[bool, int]:
"""단일 날짜 수집 (세마포어 적용)."""
async with semaphore:
try:
markets = []
if market in ("kospi", "all"):
markets.append("kospi")
if market in ("kosdaq", "all"):
markets.append("kosdaq")
frames: list[pd.DataFrame] = []
for mkt in markets:
df = await fetch_daily_prices(http_session, mkt, bas_dd)
if not df.empty:
frames.append(df)
await asyncio.sleep(REQUEST_INTERVAL)
if not frames:
logger.info("[%d/%d] %s: 데이터 없음 (휴장일?)", i, total, bas_dd)
return True, 0 # 휴장일은 성공으로 처리
combined = pd.concat(frames, ignore_index=True)
# 같은 날짜 내 코드 중복 제거 (KOSPI/KOSDAQ 겹침 방지)
combined = combined.drop_duplicates(subset=["stock_code", "date"])
async with AsyncSessionLocal() as db:
count = await upsert_daily_prices(db, combined)
logger.info("[%d/%d] %s: %d종목 저장", i, total, bas_dd, count)
return True, count
except Exception as exc:
logger.error("[%d/%d] %s 실패: %s", i, total, bas_dd, exc)
return False, 0
# ─── 메인 수집 로직 ───────────────────────────────────────────────
async def collect_all(
market: str = "all",
limit: int = 0,
start_date: str = "20160101",
start_date: str = "20200101",
update_mode: bool = False,
):
"""메인 수집 로직"""
"""메인 수집 로직 (KRX OpenAPI 기반, 날짜별 전종목 일괄 수집)."""
if not KRX_API_KEY:
logger.error(
"KRX_API_KEY가 설정되지 않았습니다.\n"
" 1) https://openapi.krx.co.kr 에서 API 키 발급\n"
" 2) .env 파일에 KRX_API_KEY=<발급받은키> 추가\n"
" 3) 다시 실행하세요."
)
sys.exit(1)
await create_tables()
end_date = datetime.now().strftime("%Y%m%d")
actual_start = start_date
# 종목 목록
stock_list = get_stock_list(market=market, limit=limit)
if not stock_list:
logger.error("종목 목록이 비어있습니다")
if update_mode:
async with AsyncSessionLocal() as db:
last_date = await get_last_trade_date(db)
if last_date:
next_day = last_date + timedelta(days=1)
actual_start = next_day.strftime("%Y%m%d")
if next_day >= date.today():
logger.info("이미 최신 데이터입니다 (마지막: %s)", last_date)
return
logger.info("증분 업데이트: %s ~", actual_start)
dates = generate_trading_dates(actual_start, end_date)
if not dates:
logger.warning("수집할 날짜가 없습니다.")
return
total = len(stock_list)
total_inserted = 0
success_count = 0
fail_count = 0
# --limit N → 최근 N거래일만 (테스트용)
if limit > 0:
dates = dates[-limit:]
async with AsyncSessionLocal() as session:
for i, (stock_code, stock_name) in enumerate(stock_list):
try:
actual_start = start_date
total = len(dates)
logger.info("수집 날짜: %d일 (%s ~ %s)", total, dates[0], dates[-1])
if update_mode:
last_date = await get_last_trade_date(session, stock_code)
if last_date:
# 마지막 저장일 다음 날부터
next_day = last_date + timedelta(days=1)
actual_start = next_day.strftime("%Y%m%d")
if next_day >= date.today():
logger.info(f"[{i+1}/{total}] {stock_code} ({stock_name}): 이미 최신")
success_count += 1
continue
semaphore = asyncio.Semaphore(COLLECT_CONCURRENCY)
conn = aiohttp.TCPConnector(limit=10)
logger.info(
f"[{i+1}/{total}] {stock_code} ({stock_name}): "
f"{actual_start} ~ {end_date} 수집 중..."
)
async with aiohttp.ClientSession(connector=conn) as http_session:
results = await asyncio.gather(
*[
collect_date(i, total, d, market, semaphore, http_session)
for i, d in enumerate(dates, 1)
],
return_exceptions=True,
)
df = fetch_ohlcv(stock_code, actual_start, end_date)
if df.empty:
logger.warning(f" {stock_code}: 데이터 없음")
fail_count += 1
continue
count = await upsert_daily_prices(session, stock_code, stock_name, df)
total_inserted += count
success_count += 1
logger.info(f" {stock_code}: {count}개 행 저장 (총 {len(df)}일)")
except Exception as e:
logger.error(f" {stock_code} ({stock_name}) 실패: {e}")
fail_count += 1
# 에러 시에도 계속 진행
try:
await session.rollback()
except Exception:
pass
# pykrx rate limit 방지
time.sleep(0.5)
success = sum(1 for r in results if isinstance(r, tuple) and r[0])
fail = total - success
total_rows = sum(r[1] for r in results if isinstance(r, tuple))
logger.info("=" * 60)
logger.info(f"수집 완료!")
logger.info(f" 성공: {success_count}/{total}")
logger.info(f" 실패: {fail_count}/{total}")
logger.info(f" 총 저장 행: {total_inserted:,}")
logger.info("수집 완료!")
logger.info(" 성공: %d/%d일", success, total)
logger.info(" 실패: %d/%d일", fail, total)
logger.info(" 총 저장 행: %s", f"{total_rows:,}")
logger.info("=" * 60)
def main():
parser = argparse.ArgumentParser(description="KRX 히스토리컬 데이터 수집")
parser = argparse.ArgumentParser(description="KRX 히스토리컬 데이터 수집 (KRX OpenAPI)")
parser.add_argument(
"--market",
choices=["kospi", "kosdaq", "all"],
default="all",
"--market", choices=["kospi", "kosdaq", "all"], default="all",
help="수집 시장 (기본: all)",
)
parser.add_argument(
"--limit", type=int, default=0, help="종목 수 제한 (0=전체, 테스트용)"
"--limit", type=int, default=0,
help="최근 N거래일만 수집 (0=전체, 테스트용)",
)
parser.add_argument(
"--start-date",
default="20160101",
help="수집 시작일 YYYYMMDD (기본: 20160101)",
"--start-date", default="20200101",
help="수집 시작일 YYYYMMDD (기본: 20200101)",
)
parser.add_argument(
"--update",
action="store_true",
"--update", action="store_true",
help="증분 업데이트 모드 (마지막 저장일 이후만 수집)",
)
args = parser.parse_args()
logger.info(f"KRX 데이터 수집 시작")
logger.info(f" 시장: {args.market}")
logger.info(f" 종목 제한: {'전체' if args.limit == 0 else args.limit}")
logger.info(f" 시작일: {args.start_date}")
logger.info(f" 증분 모드: {args.update}")
logger.info("KRX 데이터 수집 시작 (KRX OpenAPI)")
logger.info(" 시장: %s", args.market)
logger.info(" 날짜 제한: %s", "전체" if args.limit == 0 else f"최근 {args.limit}거래일")
logger.info(" 시작일: %s", args.start_date)
logger.info(" 증분 모드: %s", args.update)
start_time = time.time()
asyncio.run(collect_all(
@@ -316,7 +411,7 @@ def main():
update_mode=args.update,
))
elapsed = time.time() - start_time
logger.info(f"총 소요시간: {elapsed:.1f}초 ({elapsed/60:.1f}분)")
logger.info("총 소요시간: %.1f초 (%.1f분)", elapsed, elapsed / 60)
if __name__ == "__main__":