변경 사항 ---- - _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>
211 lines
6.6 KiB
Python
211 lines
6.6 KiB
Python
"""
|
||
전략 공통 유통주식수 메타 — 라이브(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
|