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:
2026-07-06 01:27:00 +09:00
parent d8ba01afa4
commit 61c72a8a4c
171 changed files with 176914 additions and 7329 deletions

View File

@@ -0,0 +1 @@
"""전략 공통 — 유통/상장주식수 메타 (키움 ka10001, DB stock_share_meta)."""

View File

@@ -0,0 +1,210 @@
"""
전략 공통 유통주식수 메타 — 라이브(WSManager) · 백테(stock_share_meta 테이블) 공용.
키움 ka10001 만 ``dstr_stk``(유통주식수)를 제공한다. 일괄 API(ka10095/ka10099)는 상장주식수만
있어 회전율 분모로는 부적합 → 후보·보유 종목만 종목별 ka10001 호출 + DB 캐시.
"""
from __future__ import annotations
import datetime
from typing import Any, Dict, Iterable, List, Optional
from kis_trader.utils.env import get_env_from_db
from kis_trader.utils.logger import get_logger
logger = get_logger("kis_trader.share")
def share_denom_key() -> str:
"""회전율 분모 필드 — ``STOCK_SHARE_DENOM`` 우선, 레거시 ``BREAKOUT_SHARE_DENOM`` 폴백."""
raw = (
get_env_from_db("STOCK_SHARE_DENOM", "")
or get_env_from_db("BREAKOUT_SHARE_DENOM", "dstr_stk")
or "dstr_stk"
)
return str(raw).strip().lower()
def share_denom_from_meta(meta: Optional[Dict[str, int]]) -> float:
"""
캐시/DB 행에서 회전율 분모(float) 반환.
유통(`dstr_stk`) 우선 설정이어도 값이 없으면 상장(`flo_stk`) 폴백.
"""
if not meta:
return 0.0
key = share_denom_key()
flo = float(meta.get("flo_stk") or 0)
dstr = float(meta.get("dstr_stk") or 0)
if key in ("flo_stk", "flo", "listed", "상장"):
return flo if flo > 0 else dstr
return dstr if dstr > 0 else flo
def upsert_stock_share_meta_row(
db,
code: str,
flo_stk: int,
dstr_stk: int,
*,
dstr_rt: Optional[float] = None,
source: str = "ka10001",
) -> None:
"""stock_share_meta 테이블에 즉시 저장 (atomic)."""
code = str(code or "").strip()
if not code or not db:
return
now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
try:
with db.conn:
db.conn.execute(
"""
INSERT INTO stock_share_meta
(code, flo_stk, dstr_stk, dstr_rt, source, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
flo_stk = VALUES(flo_stk),
dstr_stk = VALUES(dstr_stk),
dstr_rt = COALESCE(VALUES(dstr_rt), dstr_rt),
source = VALUES(source),
updated_at = VALUES(updated_at)
""",
(
code,
int(flo_stk or 0),
int(dstr_stk or 0),
float(dstr_rt) if dstr_rt is not None else None,
str(source or "ka10001"),
now_str,
),
)
except Exception as e:
logger.warning("stock_share_meta 저장 실패 (%s): %s", code, e)
def load_stock_share_meta_map(
db,
codes: Optional[Iterable[str]] = None,
) -> Dict[str, Dict[str, int]]:
"""DB → {code: {flo_stk, dstr_stk}}."""
out: Dict[str, Dict[str, int]] = {}
if not db:
return out
try:
if codes is not None:
code_list = sorted({str(c).strip() for c in codes if c})
if not code_list:
return out
placeholders = ",".join("?" * len(code_list))
rows = db.conn.execute(
f"""
SELECT code, flo_stk, dstr_stk
FROM stock_share_meta
WHERE code IN ({placeholders})
""",
code_list,
).fetchall()
else:
rows = db.conn.execute(
"SELECT code, flo_stk, dstr_stk FROM stock_share_meta",
).fetchall()
for r in rows:
code = str(r["code"]).strip()
out[code] = {
"flo_stk": int(r["flo_stk"] or 0),
"dstr_stk": int(r["dstr_stk"] or 0),
}
except Exception as e:
logger.debug("stock_share_meta 로드 실패: %s", e)
return out
def load_share_denom_map(
db,
codes: Iterable[str],
) -> Dict[str, float]:
"""백테스트용 — 종목별 회전율 분모."""
meta = load_stock_share_meta_map(db, codes)
return {c: share_denom_from_meta(meta.get(c)) for c in codes}
def merge_share_meta_into_cache(
cache: Dict[str, Dict[str, int]],
db_map: Dict[str, Dict[str, int]],
) -> None:
"""메모리 캐시에 DB 맵 병합."""
for code, info in db_map.items():
if code not in cache:
cache[code] = dict(info)
continue
for k in ("flo_stk", "dstr_stk"):
v = int(info.get(k) or 0)
if v > 0:
cache[code][k] = v
def codes_missing_dstr(
cache: Dict[str, Dict[str, int]],
codes: Iterable[str],
) -> List[str]:
"""유통주식수가 아직 없는 종목 (ka10001 대상)."""
missing: List[str] = []
for raw in codes:
code = str(raw or "").strip()
if not code:
continue
info = cache.get(code)
if not info or int(info.get("dstr_stk") or 0) <= 0:
missing.append(code)
return missing
def apply_fetched_meta(
cache: Dict[str, Dict[str, int]],
db,
code: str,
meta: Dict[str, int],
*,
dstr_rt: Optional[float] = None,
) -> None:
"""ka10001 응답 → RAM + DB."""
code = str(code or "").strip()
if not code or not meta:
return
flo = int(meta.get("flo_stk") or 0)
dstr = int(meta.get("dstr_stk") or 0)
if flo <= 0 and dstr <= 0:
return
cache[code] = {"flo_stk": flo, "dstr_stk": dstr}
upsert_stock_share_meta_row(db, code, flo, dstr, dstr_rt=dstr_rt, source="ka10001")
def share_denom_for_code(params: Dict[str, Any], code: str) -> float:
"""백테/라이브 공통 — params 또는 share_denom_by_code 에서 분모 조회."""
by_code = params.get("share_denom_by_code") or {}
if code in by_code:
v = float(by_code.get(code) or 0)
if v > 0:
return v
return float(params.get("share_denom", 0) or 0)
def attach_share_denoms_to_params(
params: Dict[str, Any],
db,
codes: Iterable[str],
) -> Dict[str, Any]:
"""백테 엔진 params에 ``share_denom_by_code`` 주입."""
out = dict(params)
code_list = list(codes)
if not code_list:
out.setdefault("share_denom_by_code", {})
return out
out["share_denom_by_code"] = load_share_denom_map(db, code_list)
missing = [c for c, v in out["share_denom_by_code"].items() if v <= 0]
if missing:
logger.info(
" stock_share_meta 미적재 %d종목 — 회전율 필터는 해당 종목 스킵 "
"(fill_stock_share_meta.py 로 보강 가능)",
len(missing),
)
return out