변경 사항 ---- - _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가 - _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가 - _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가 영향 ---- - 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임 - 기존 기능에 대한 영향 없음 Co-authored-by: Cursor <cursoragent@cursor.com>
191 lines
6.5 KiB
Python
191 lines
6.5 KiB
Python
#!/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())
|