""" 공공데이터포털 금융위원회 주식시세 API 기반 일봉 데이터 수집 스크립트 API: GetStockSecuritiesInfoService (apis.data.go.kr) - API 키 필요 (data.go.kr 에서 무료 신청) - KOSPI / KOSDAQ 전종목 일별 OHLCV 제공 - KRX / pykrx / FinanceDataReader 의존성 없음 사용법: 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 sys import time from datetime import date, datetime, timedelta from pathlib import Path import httpx import pandas as pd # 프로젝트 루트를 Python 경로에 추가 sys.path.insert(0, str(Path(__file__).parent)) from sqlalchemy import text from sqlalchemy.dialects.postgresql import insert as pg_insert from app.core.config import settings from app.core.database import AsyncSessionLocal, engine, Base from app.models.models import StockDailyPrice # ETF/특수 종목 제외 키워드 SKIP_KEYWORDS = [ "ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG", "KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", "선물", "스팩", "리츠", "SPAC", ] # 벤치마크/국면 판정용 ETF (SKIP_KEYWORDS 로 제외되지만 --include-benchmarks 시 허용). # 기본 OFF: 켜면 이 종목들이 DB 스캐너·배치 유니버스에 들어간다(레버리지/인버스 포함). BENCHMARK_ETF_CODES = frozenset({"069500", "122630", "252670", "114800"}) _include_benchmarks = False # 실전 유니버스 ETF(주식시세 API 에 없음) 중 ETF API 로 추가 수집할 코드. 기본 비어 있음(OFF). _extra_etf_codes: frozenset = frozenset() _etf_from_universe = False # True 면 collect_all_v2 시작 시 stock_analysis 기준 후보를 합친다 # 증분 모드에서 종목수 부족(한쪽 시장 누락) 날짜를 재검사할 달력일 범위와 판정 비율 INCOMPLETE_LOOKBACK_DAYS = 90 INCOMPLETE_LOCAL_WINDOW_DAYS = 10 # 지역 기준선: 전후 달력일 (상장 종목수 증가 추세 보정) INCOMPLETE_RATIO = 0.85 # 공공데이터포털 API 설정 DATAGOKR_BASE_URL = ( "https://apis.data.go.kr/1160100/service" "/GetStockSecuritiesInfoService/getStockPriceInfo" ) # 벤치마크 ETF 는 주식시세 API 에 없을 가능성이 높아 증권상품시세(ETF) 서비스에서 별도 수집한다. # 경로는 공개 문서 기억 기반의 추정이며 실호출로 확인되지 않았다 — 응답이 비면 warning 을 남긴다. DATAGOKR_ETF_URL = ( "https://apis.data.go.kr/1160100/service" "/GetSecuritiesProductInfoService/getETFPriceInfo" ) ROWS_PER_PAGE = 1000 # API 최대 허용값 # 수정주가 미반영(분할·병합·권리락) 이벤트 감지: |실제 종가비 - 1 - fltRt| 가 이 값을 넘으면 이벤트 PRICE_EVENT_TOL = 0.25 # 날짜별 요청 간격 (초) REQUEST_INTERVAL = 0.2 logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) logger = logging.getLogger(__name__) # ─── 유틸 ───────────────────────────────────────────────────────── 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: dates.append(d.strftime("%Y%m%d")) d += timedelta(days=1) return dates # ─── 공공데이터포털 API 수집 ─────────────────────────────────────── async def fetch_daily_prices(market: str, bas_dd: str) -> pd.DataFrame: """기존 시그니처 유지 래퍼. 실패/무데이터 구분이 필요하면 fetch_daily_prices_v2.""" df, _status = await fetch_daily_prices_v2(market, bas_dd) return df async def fetch_daily_prices_v2(market: str, bas_dd: str) -> tuple[pd.DataFrame, str]: """공공데이터포털 금융위원회 주식시세 API로 전종목 OHLCV 조회. market: 'kospi' | 'kosdaq' bas_dd: 'YYYYMMDD' 반환: (DataFrame, status) status = "ok"(데이터 있음) | "empty"(응답 정상·항목 없음) | "error"(요청 실패) DataFrame 컬럼: (stock_code, stock_name, date, open, high, low, close, volume, change_rate) """ mrkt_cls = "KOSPI" if market == "kospi" else "KOSDAQ" api_key = settings.DATAGOKR_API_KEY if not api_key: raise RuntimeError( "DATAGOKR_API_KEY 가 설정되지 않았습니다. " ".env 에 DATAGOKR_API_KEY=<발급키> 를 추가하세요." ) trade_date = datetime.strptime(bas_dd, "%Y%m%d").date() rows: list[dict] = [] page = 1 status = "empty" async with httpx.AsyncClient(timeout=30.0) as client: while True: params = { "serviceKey": api_key, "numOfRows": str(ROWS_PER_PAGE), "pageNo": str(page), "resultType": "json", "basDt": bas_dd, "mrktCls": mrkt_cls, } try: resp = await client.get(DATAGOKR_BASE_URL, params=params) resp.raise_for_status() data = resp.json() except Exception as exc: logger.error("API 요청 실패 [%s %s page%d]: %s", market, bas_dd, page, exc) status = "error" break body = data.get("response", {}).get("body", {}) total_count = int(body.get("totalCount", 0)) items_wrap = body.get("items", {}) # 데이터 없음 (휴장일) if not items_wrap: break items = items_wrap.get("item", []) if not isinstance(items, list): items = [items] # 1건이면 dict로 올 수 있음 for item in items: name = item.get("itmsNm", "") code_raw = str(item.get("srtnCd", "")).strip().zfill(6) keep_benchmark = _include_benchmarks and code_raw in BENCHMARK_ETF_CODES if not keep_benchmark and any(kw in name for kw in SKIP_KEYWORDS): continue volume = int(item.get("trqu", 0) or 0) close = int(item.get("clpr", 0) or 0) if close == 0: continue try: change_rate = float(item.get("fltRt", 0) or 0) / 100.0 except (TypeError, ValueError): change_rate = 0.0 # srtnCd: 단축코드(6자리), 앞에 공백 있을 수 있어 strip stock_code = str(item.get("srtnCd", "")).strip().zfill(6) o_, h_, l_, c_ = halted_aware_ohlc( int(item.get("mkp", 0) or 0), int(item.get("hipr", 0) or 0), int(item.get("lopr", 0) or 0), close, volume, ) if volume == 0: change_rate = 0.0 # 거래정지 봉: 가격 변동 없음(종가 = 직전 기준가) rows.append({ "stock_code": stock_code, "stock_name": name, "date": trade_date, "open": o_, "high": h_, "low": l_, "close": c_, "volume": volume, "change_rate": change_rate, "market_cap": int(item.get("mrktTotAmt", 0) or 0) or None, }) # 페이지네이션 종료 조건 fetched_so_far = (page - 1) * ROWS_PER_PAGE + len(items) if fetched_so_far >= total_count or len(items) < ROWS_PER_PAGE: break page += 1 await asyncio.sleep(REQUEST_INTERVAL) if rows: status = "ok" if status != "error" else "error" # 중간 페이지 실패 = 불완전 return (pd.DataFrame(rows) if rows else pd.DataFrame()), status def normalize_ohlc(open_: int, high: int, low: int, close: int) -> tuple[int, int, int, int]: """시가·고가·저가가 0(단일가 체결 등 미산출)이면 종가로 채우고 high/low 정합을 보장. 순수 함수. close 는 호출부에서 >0 보장. 0 OHLC 가 저장되면 백테스트 TR·손절 판정이 왜곡된다. """ o = open_ if open_ > 0 else close h = high if high > 0 else close l = low if low > 0 else close h = max(h, o, l, close) l = min(l, o, h, close) return o, h, l, close def halted_aware_ohlc(open_: int, high: int, low: int, close: int, volume: int) -> tuple[int, int, int, int]: """거래량 0(거래정지) 봉은 OHLC 를 종가로 평탄화해 저장한다. 순수 함수. 정지 봉을 버리면 재개일 봉이 정지 직전 봉에 바로 붙어 MA·RSI·ATR 기간이 짧아지고 재개 갭이 1봉 수익률이 된다. volume=0 봉은 소비처의 거래량 하한 게이트에서 진입이 거부된다. """ if volume <= 0: return close, close, close, close return normalize_ohlc(open_, high, low, close) def is_price_event(close: float, prev_close: float, change_rate: float | None, tol: float | None = None) -> bool: """원주가 불연속(액면분할·병합·권리락) 판정. 순수 함수. fltRt(거래소 산출 등락률, 소수)와 실제 종가비 괴리가 tol 초과면 True. stock_daily_prices 는 수정주가가 아니므로 이 구간의 수익률은 소비처에서 쓰면 안 된다. """ tol = PRICE_EVENT_TOL if tol is None else tol if not prev_close or prev_close <= 0 or not close or close <= 0: return False if change_rate is None: return False return abs((close / prev_close - 1.0) - float(change_rate)) > tol async def get_prev_closes(session, codes: list[str], before: date) -> dict[str, tuple[date, int]]: """before 이전 종목별 마지막 종가 (읽기 전용).""" if not codes: return {} result = await session.execute( text( "SELECT DISTINCT ON (stock_code) stock_code, trade_date, close_price " "FROM stock_daily_prices WHERE trade_date < :before AND stock_code = ANY(:codes) " "ORDER BY stock_code, trade_date DESC" ), {"before": before, "codes": list(codes)}, ) return {r[0]: (r[1], int(r[2])) for r in result.all()} def detect_price_events(df: pd.DataFrame, prev: dict[str, tuple[date, int]], max_gap_days: int = 4) -> list[tuple[str, date, float, float]]: """수집 프레임과 직전 종가로 이벤트 목록 [(code, date, 실제등락, fltRt)]. 순수 함수.""" out: list[tuple[str, date, float, float]] = [] for _, row in df.iterrows(): p = prev.get(row["stock_code"]) if not p: continue d = row["date"].date() if isinstance(row["date"], pd.Timestamp) else row["date"] if (d - p[0]).days > max_gap_days: continue # 누락 구간은 이벤트가 아니라 갭 cr = row["change_rate"] cr = float(cr) if pd.notna(cr) else None if is_price_event(float(row["close"]), float(p[1]), cr): out.append((row["stock_code"], d, float(row["close"]) / p[1] - 1.0, cr)) return out async def fetch_benchmark_etf_prices(bas_dd: str) -> pd.DataFrame: """벤치마크 ETF(BENCHMARK_ETF_CODES) 일봉 — 증권상품시세 API (경로 추정, 미검증).""" return await fetch_etf_prices_v2(bas_dd, BENCHMARK_ETF_CODES) async def fetch_etf_prices_v2(bas_dd: str, codes) -> pd.DataFrame: """지정한 ETF/ETN 코드(codes)의 일봉 — 증권상품시세 API (경로 추정, 미검증). 전체 ETF 를 저장하면 DB 스캐너 유니버스가 오염되므로 반드시 codes 로 한정한다. """ wanted = {str(c).strip().zfill(6) for c in (codes or ())} if not wanted: return pd.DataFrame() api_key = settings.DATAGOKR_API_KEY if not api_key: raise RuntimeError("DATAGOKR_API_KEY 가 설정되지 않았습니다.") trade_date = datetime.strptime(bas_dd, "%Y%m%d").date() rows: list[dict] = [] page = 1 async with httpx.AsyncClient(timeout=30.0) as client: while True: params = { "serviceKey": api_key, "numOfRows": str(ROWS_PER_PAGE), "pageNo": str(page), "resultType": "json", "basDt": bas_dd, } try: resp = await client.get(DATAGOKR_ETF_URL, params=params) resp.raise_for_status() data = resp.json() except Exception as exc: logger.warning("ETF API 요청 실패 [%s page%d]: %s", bas_dd, page, exc) break body = data.get("response", {}).get("body", {}) total_count = int(body.get("totalCount", 0) or 0) items_wrap = body.get("items", {}) if not items_wrap: break items = items_wrap.get("item", []) if not isinstance(items, list): items = [items] for item in items: code = str(item.get("srtnCd", "")).strip().zfill(6) if code not in wanted: continue volume = int(float(item.get("trqu", 0) or 0)) close = int(float(item.get("clpr", 0) or 0)) if close == 0: continue if volume == 0: cr = 0.0 else: try: cr = float(item.get("fltRt", 0) or 0) / 100.0 except (TypeError, ValueError): cr = 0.0 o_, h_, l_, c_ = halted_aware_ohlc( int(float(item.get("mkp", 0) or 0)), int(float(item.get("hipr", 0) or 0)), int(float(item.get("lopr", 0) or 0)), close, volume, ) rows.append({ "stock_code": code, "stock_name": item.get("itmsNm", ""), "date": trade_date, "open": o_, "high": h_, "low": l_, "close": c_, "volume": volume, "change_rate": cr, "market_cap": int(float(item.get("mrktTotAmt", 0) or 0)) or None, }) if (page - 1) * ROWS_PER_PAGE + len(items) >= total_count or len(items) < ROWS_PER_PAGE: break page += 1 await asyncio.sleep(REQUEST_INTERVAL) return pd.DataFrame(rows) if rows else pd.DataFrame() async def get_universe_etf_candidate_codes(session, lookback_days: int = 30) -> list[str]: """실전 분석 유니버스(stock_analysis)에 있으나 일봉 DB 에 행이 없는 종목 코드 (읽기 전용 SELECT). 대부분 ETF/ETN 이다(추정: 주식시세 API 에 없음). --etf-from-universe 로 ETF API 수집 대상이 된다. """ since = date.today() - timedelta(days=lookback_days) result = await session.execute( text( "SELECT DISTINCT a.stock_code FROM stock_analysis a " "WHERE a.analysis_date >= :since AND NOT EXISTS (" "SELECT 1 FROM stock_daily_prices p WHERE p.stock_code = a.stock_code)" ), {"since": since}, ) return sorted(str(r[0]).strip().zfill(6) for r in result.all()) # ─── DB 헬퍼 ────────────────────────────────────────────────────── async def create_tables(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) logger.info("DB 테이블 확인/생성 완료") async def get_last_trade_date(session) -> date | None: result = await session.execute( text("SELECT MAX(trade_date) FROM stock_daily_prices") ) return result.scalar() async def upsert_daily_prices(session, df: pd.DataFrame) -> int: """DB에 일봉 데이터 UPSERT (배치 500건).""" if df.empty: return 0 records = [] for _, row in df.iterrows(): trade_date = row["date"] if isinstance(trade_date, pd.Timestamp): trade_date = trade_date.date() 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"]), "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")) and row.get("market_cap") else None, }) batch_size = 500 inserted = 0 for i in range(0, len(records), 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, "close_price": stmt.excluded.close_price, "volume": stmt.excluded.volume, "change_rate": stmt.excluded.change_rate, "market_cap": stmt.excluded.market_cap, }, ) await session.execute(stmt) inserted += len(batch) await session.commit() return inserted def find_incomplete_dates( counts: dict[date, int], window_start: date, window_end: date, is_holiday=None, ratio: float = INCOMPLETE_RATIO, ) -> list[date]: """[window_start, window_end] 평일 중 행 수 누락/부족(한쪽 시장 누락 의심) 날짜. 기준 = 창 내 양수 행 수의 중앙값 x ratio. 휴장일(is_holiday)은 제외한다. 순수 함수 (DB·네트워크 없음). """ positives = sorted(v for v in counts.values() if v > 0) if not positives: return [] median = positives[len(positives) // 2] threshold = median * ratio out: list[date] = [] d = window_start while d <= window_end: if d.weekday() < 5 and not (is_holiday and is_holiday(d)): if counts.get(d, 0) < threshold: out.append(d) d += timedelta(days=1) return out def find_incomplete_dates_v2( counts: dict[date, int], window_start: date, window_end: date, is_holiday=None, ratio: float = INCOMPLETE_RATIO, local_days: int | None = None, ) -> list[date]: """find_incomplete_dates 의 지역 기준선 버전. 순수 함수. 기준 = 해당 날짜 전후 local_days(달력일) 내 양수 행 수 중앙값 x ratio. 넓은 창에서 전역 중앙값을 쓰면 종목수 증가 추세로 과거 정상일이 오탐된다. 이웃이 없으면 창 전체 중앙값으로 폴백. 휴장일(is_holiday)·주말 제외. """ local_days = INCOMPLETE_LOCAL_WINDOW_DAYS if local_days is None else local_days all_pos = sorted(v for v in counts.values() if v > 0) if not all_pos: return [] global_median = all_pos[len(all_pos) // 2] out: list[date] = [] d = window_start while d <= window_end: if d.weekday() < 5 and not (is_holiday and is_holiday(d)): neigh = sorted( v for k, v in counts.items() if v > 0 and k != d and abs((k - d).days) <= local_days ) base = neigh[len(neigh) // 2] if neigh else global_median if counts.get(d, 0) < base * ratio: out.append(d) d += timedelta(days=1) return out async def get_recent_date_counts(session, since: date) -> dict[date, int]: result = await session.execute( text( "SELECT trade_date, COUNT(*) FROM stock_daily_prices " "WHERE trade_date >= :since GROUP BY trade_date" ), {"since": since}, ) return {r[0]: int(r[1]) for r in result.all()} # ─── 날짜별 수집 ────────────────────────────────────────────────── async def collect_date( i: int, total: int, bas_dd: str, market: str, semaphore: asyncio.Semaphore, ) -> 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] = [] statuses: dict[str, str] = {} for mkt in markets: df, st = await fetch_daily_prices_v2(mkt, bas_dd) statuses[mkt] = st if not df.empty: frames.append(df) await asyncio.sleep(REQUEST_INTERVAL) if _include_benchmarks and frames: try: etf_df = await fetch_benchmark_etf_prices(bas_dd) except Exception as exc: logger.warning("[%s] 벤치마크 ETF 수집 실패: %s", bas_dd, exc) etf_df = pd.DataFrame() got = set(etf_df["stock_code"]) if not etf_df.empty else set() if not got: logger.warning( "[%s] --include-benchmarks 인데 벤치마크 ETF(%s)가 응답에 없음 — " "regime_hmm/stock_pattern_memory DB 폴백은 계속 빈 결과", bas_dd, ",".join(sorted(BENCHMARK_ETF_CODES)), ) else: frames.append(etf_df) if _extra_etf_codes and frames: try: extra_df = await fetch_etf_prices_v2(bas_dd, _extra_etf_codes) except Exception as exc: logger.warning("[%s] 유니버스 ETF 수집 실패: %s", bas_dd, exc) extra_df = pd.DataFrame() if extra_df.empty: logger.warning( "[%s] --etf-codes/--etf-from-universe 지정 %d종목이 ETF API 응답에 없음 " "(엔드포인트 경로 미검증 — 실호출 확인 필요)", bas_dd, len(_extra_etf_codes), ) else: frames.append(extra_df) any_error = any(st == "error" for st in statuses.values()) # 일부 시장만 비었거나 실패 = 불완전 (휴장일이면 전 시장이 비므로 해당 없음) partial = bool(frames) and any(st != "ok" for st in statuses.values()) if not frames: if any_error: logger.error("[%d/%d] %s: API 오류로 수집 실패 %s", i, total, bas_dd, statuses) return False, 0 logger.info("[%d/%d] %s: 데이터 없음 (휴장일?)", i, total, bas_dd) return True, 0 combined = pd.concat(frames, ignore_index=True) combined = combined.drop_duplicates(subset=["stock_code", "date"]) async with AsyncSessionLocal() as db: # 수정주가 불연속 감지 (읽기 전용 · 경고 전용 — 저장은 막지 않는다) try: td = combined["date"].iloc[0] td = td.date() if isinstance(td, pd.Timestamp) else td prev = await get_prev_closes(db, list(combined["stock_code"]), td) events = detect_price_events(combined, prev) if events: logger.warning( "[%s] 원주가 불연속(분할/병합/권리락 의심) %d건 — 소비처에서 해당 구간 수익률 제외 필요: %s", bas_dd, len(events), [(c, round(a, 3), round(b, 3)) for c, _d, a, b in events[:10]], ) except Exception as exc: logger.warning("[%s] 가격 이벤트 감지 실패(건너뜀): %s", bas_dd, exc) count = await upsert_daily_prices(db, combined) if partial: logger.warning( "[%d/%d] %s: %d종목 저장했으나 불완전 %s — 다음 --update 에서 재수집", i, total, bas_dd, count, statuses, ) return False, count 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 = "20200101", update_mode: bool = False, ): """메인 수집 로직 (공공데이터포털 금융위원회 주식시세 API). 시그니처 유지 래퍼.""" return await collect_all_v2( market=market, limit=limit, start_date=start_date, update_mode=update_mode, ) async def collect_all_v2( market: str = "all", limit: int = 0, start_date: str = "20200101", update_mode: bool = False, dates_override: list[str] | None = None, rescan_days: int | None = None, ): """collect_all + 명시 날짜 백필(dates_override) / 부분일 장기 재검사(rescan_days).""" await create_tables() global _extra_etf_codes if _etf_from_universe: async with AsyncSessionLocal() as db: cand = await get_universe_etf_candidate_codes(db) _extra_etf_codes = frozenset(_extra_etf_codes | set(cand)) logger.info("유니버스 ETF 후보 %d종목 추가 수집 대상", len(cand)) if dates_override: # 1회성 백필: 지정 날짜만 재수집 (부분 수집일 복구용) dates = sorted({d.strip() for d in dates_override if d.strip()}) total = len(dates) logger.info("명시 날짜 백필: %s", dates) semaphore = asyncio.Semaphore(3) results = await asyncio.gather( *[collect_date(i, total, d, market, semaphore) for i, d in enumerate(dates, 1)], return_exceptions=True, ) ok = sum(1 for r in results if isinstance(r, tuple) and r[0]) logger.info("백필 완료: 성공 %d/%d", ok, total) return lookback = INCOMPLETE_LOOKBACK_DAYS if rescan_days is None else max(1, int(rescan_days)) end_date = datetime.now().strftime("%Y%m%d") actual_start = start_date extra_dates: list[str] = [] if update_mode: async with AsyncSessionLocal() as db: last_date = await get_last_trade_date(db) counts: dict[date, int] = {} if last_date and market == "all": try: since = date.today() - timedelta(days=lookback) counts = await get_recent_date_counts(db, since) except Exception as exc: logger.warning("누락일 점검 실패(건너뜀): %s", exc) if last_date: if market == "all" and counts: try: from app.core.market_schedule import is_krx_holiday except Exception: is_krx_holiday = None gaps = find_incomplete_dates_v2( counts, date.today() - timedelta(days=lookback), last_date, is_holiday=is_krx_holiday, ) extra_dates = [g.strftime("%Y%m%d") for g in gaps] if extra_dates: logger.warning("불완전/누락 날짜 재수집 대상: %s", extra_dates) next_day = last_date + timedelta(days=1) actual_start = next_day.strftime("%Y%m%d") if next_day >= date.today() and not extra_dates: logger.info("이미 최신 데이터입니다 (마지막: %s)", last_date) return logger.info("증분 업데이트: %s ~", actual_start) dates = generate_trading_dates(actual_start, end_date) if actual_start <= end_date else [] if extra_dates: dates = sorted(set(dates) | set(extra_dates)) if not dates: logger.warning("수집할 날짜가 없습니다.") return if limit > 0: dates = dates[-limit:] total = len(dates) logger.info("수집 날짜: %d일 (%s ~ %s)", total, dates[0], dates[-1]) # 공공데이터 API: 동시 3 요청까지 허용 semaphore = asyncio.Semaphore(3) results = await asyncio.gather( *[ collect_date(i, total, d, market, semaphore) for i, d in enumerate(dates, 1) ], return_exceptions=True, ) 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("수집 완료!") 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="일봉 데이터 수집 (공공데이터포털 금융위원회 주식시세 API)" ) parser.add_argument( "--market", choices=["kospi", "kosdaq", "all"], default="all", help="수집 시장 (기본: all)", ) parser.add_argument( "--limit", type=int, default=0, help="최근 N거래일만 수집 (0=전체, 테스트용)", ) parser.add_argument( "--start-date", default="20200101", help="수집 시작일 YYYYMMDD (기본: 20200101)", ) parser.add_argument( "--include-benchmarks", action="store_true", help="벤치마크 ETF(069500 등)를 ETF 제외 필터에서 허용 (기본 OFF)", ) parser.add_argument( "--etf-codes", default="", help="쉼표 구분 ETF/ETN 6자리 코드를 ETF 시세 API 로 추가 수집(기본 OFF, 경로 미검증)", ) parser.add_argument( "--etf-from-universe", action="store_true", help="stock_analysis 에 있으나 일봉 DB 에 없는 종목(대부분 ETF)을 ETF API 로 추가 수집", ) parser.add_argument( "--dates", default="", help="쉼표 구분 YYYYMMDD 만 재수집 (부분 수집일 백필, 예: 20221020,20221101,20221102,20221103,20221104,20221107,20221130,20230120,20260625,20260720,20260914)", ) parser.add_argument( "--rescan-days", type=int, default=0, help="--update 시 부분 수집일 재검사 달력일 범위 (기본 %d, 예: 365)" % INCOMPLETE_LOOKBACK_DAYS, ) parser.add_argument( "--update", action="store_true", help="증분 업데이트 모드 (마지막 저장일 이후만 수집)", ) args = parser.parse_args() global _include_benchmarks _include_benchmarks = bool(args.include_benchmarks) global _extra_etf_codes codes = {c.strip().zfill(6) for c in args.etf_codes.split(",") if c.strip()} _extra_etf_codes = frozenset(codes) global _etf_from_universe _etf_from_universe = bool(args.etf_from_universe) logger.info("일봉 데이터 수집 시작 (공공데이터포털)") 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_v2( market=args.market, limit=args.limit, start_date=args.start_date, update_mode=args.update, dates_override=[d for d in args.dates.split(",") if d.strip()] or None, rescan_days=args.rescan_days or None, )) elapsed = time.time() - start_time logger.info("총 소요시간: %.1f초 (%.1f분)", elapsed, elapsed / 60) if __name__ == "__main__": main()