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:
1
kis_trader/share/__init__.py
Normal file
1
kis_trader/share/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""전략 공통 — 유통/상장주식수 메타 (키움 ka10001, DB stock_share_meta)."""
|
||||
210
kis_trader/share/stock_share.py
Normal file
210
kis_trader/share/stock_share.py
Normal 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
|
||||
Reference in New Issue
Block a user