#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 키움 ka10001 로 유통주식수(dstr_stk)를 수집해 ``stock_share_meta`` 테이블에 저장. 백테스트 회전율 필터(BREAKOUT_MIN_TURNOVER_1M_PCT 등)는 유통주식수가 필요하다. 라이브 봇(WSManager)도 동일 테이블을 쓰므로, 백테 전에 한 번 채워두면 웹/파서치와 정합된다. 대상 종목 (기본) ---------------- * ``ws_candles`` 에 기간 내 데이터가 있는 DISTINCT code * ``--codes`` 로 수동 지정 가능 사용 예 -------- python3 kis_trader/scripts/fill_stock_share_meta.py python3 kis_trader/scripts/fill_stock_share_meta.py --days 14 python3 kis_trader/scripts/fill_stock_share_meta.py --codes 005930,035810 python3 kis_trader/scripts/fill_stock_share_meta.py --dry-run --codes 035810 주의 ---- 키움 ka10001 은 초당 호출 한도가 있어(오류 1700 / HTTP 429) 기본 호출 간격 1초. 로그의 ``ka10001 실패`` 대부분은 레이트리밋 — ``--sleep`` 을 늘리거나 재실행하면 된다. """ from __future__ import annotations import argparse import logging import random import sys import time from collections import Counter from datetime import datetime, timedelta from pathlib import Path from typing import Dict, List, Optional, Sequence HERE = Path(__file__).resolve() ROOT = HERE.parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from database import TradeDB # noqa: E402 from kis_trader.share.stock_share import ( # noqa: E402 apply_fetched_meta, codes_missing_dstr, load_stock_share_meta_map, ) from kis_trader.utils.env import get_env_float # noqa: E402 from kis_trader.ws.kis_ws import ( # noqa: E402 _get_kiwoom_creds, fetch_kiwoom_stock_meta_detail, ) logger = logging.getLogger("fill_stock_share_meta") def _parse_codes(raw: str) -> List[str]: return [c.strip() for c in str(raw or "").split(",") if c.strip()] def _codes_from_ws_candles(db: TradeDB, days: int) -> List[str]: end = datetime.now() start = end - timedelta(days=max(1, days)) start_key = start.strftime("%Y%m%d") + "0000" end_key = end.strftime("%Y%m%d") + "2359" rows = db.conn.execute( "SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 " "AND candle_time >= %s AND candle_time <= %s ORDER BY code", [start_key, end_key], ).fetchall() return [str(r["code"]).strip() for r in rows if r.get("code")] def _format_fail_detail(res: Dict) -> str: http_st = res.get("http_status") rc = res.get("return_code") msg = str(res.get("return_msg") or "").strip() reason = str(res.get("reason") or "").strip() parts = [f"reason={reason}"] if http_st: parts.append(f"http={http_st}") if rc is not None: parts.append(f"rc={rc}") if msg: parts.append(f"msg={msg}") return " | ".join(parts) def fill_share_meta( db: TradeDB, codes: Sequence[str], *, dry_run: bool = False, sleep_sec: Optional[float] = None, ) -> int: kw_key, kw_secret, kw_mock = _get_kiwoom_creds(db) if not kw_key or not kw_secret: logger.error("키움 API 키 없음 — env_config KIWOOM_APP_KEY_* 확인") return 0 if sleep_sec is None: sleep_sec = get_env_float("STOCK_SHARE_FILL_SLEEP_SEC", 1.0) cache = load_stock_share_meta_map(db, codes) missing = codes_missing_dstr(cache, codes) if not missing: logger.info("모든 종목 stock_share_meta 적재 완료 (%d종)", len(codes)) return 0 logger.info( "ka10001 대상: %d/%d종목 (호출간격 %.2fs, 레이트리밋 시 자동 재시도)", len(missing), len(codes), sleep_sec, ) ok = 0 fail_reasons: Counter = Counter() fail_samples: Dict[str, List[str]] = {} for i, code in enumerate(missing, 1): if dry_run: logger.info("[%d/%d] dry-run %s", i, len(missing), code) continue res = fetch_kiwoom_stock_meta_detail( code, kw_key, kw_secret, is_mock=kw_mock, ) meta = res.get("meta") if res.get("ok") and meta: apply_fetched_meta(cache, db, code, meta) ok += 1 logger.info( "[%d/%d] %s flo=%s dstr=%s", i, len(missing), code, meta.get("flo_stk"), meta.get("dstr_stk"), ) else: reason = str(res.get("reason") or "unknown") fail_reasons[reason] += 1 fail_samples.setdefault(reason, []) if len(fail_samples[reason]) < 3: fail_samples[reason].append(code) logger.warning( "[%d/%d] %s ka10001 실패 — %s", i, len(missing), code, _format_fail_detail(res), ) time.sleep(max(0.1, sleep_sec) + random.uniform(0.05, 0.2)) if fail_reasons: logger.warning("── 실패 요약 ──") for reason, cnt in fail_reasons.most_common(): samples = ", ".join(fail_samples.get(reason, [])[:3]) logger.warning(" %s: %d건 (예: %s)", reason, cnt, samples or "-") if fail_reasons.get("rate_limit_1700", 0) > 0: logger.warning( " 💡 대부분 키움 API 호출한도(1700)입니다. " "--sleep 1.5 이상으로 재실행하거나 잠시 후 이어서 실행하세요.", ) return ok def main(argv: Optional[List[str]] = None) -> int: logging.basicConfig(level=logging.INFO, format="%(message)s") p = argparse.ArgumentParser(description="stock_share_meta 일괄 적재 (ka10001)") p.add_argument("--days", type=int, default=7, help="ws_candles 조회 일수 (기본 7)") p.add_argument("--codes", type=str, default="", help="종목코드 콤마 구분") p.add_argument("--dry-run", action="store_true", help="API 호출 없이 대상만 출력") p.add_argument( "--sleep", type=float, default=None, help="종목 간 대기초 (기본 env STOCK_SHARE_FILL_SLEEP_SEC=1.0)", ) args = p.parse_args(argv) db = TradeDB() try: if args.codes: codes = _parse_codes(args.codes) else: codes = _codes_from_ws_candles(db, args.days) if not codes: logger.warning("대상 종목 없음") return 1 n = fill_share_meta(db, codes, dry_run=args.dry_run, sleep_sec=args.sleep) if not args.dry_run: logger.info("완료: %d종목 저장 (대상 %d종 중)", n, len(codes)) finally: db.close() return 0 if __name__ == "__main__": raise SystemExit(main())