""" 전략 공통 유통주식수 메타 — 라이브(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