feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항 ---- - _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>
This commit is contained in:
190
kis_trader/scripts/fill_stock_share_meta.py
Normal file
190
kis_trader/scripts/fill_stock_share_meta.py
Normal file
@@ -0,0 +1,190 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user