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,157 @@
"""
kis_trader/ws/program_cache.py — 키움 0w 종목프로그램매매 RAM 캐시
================================================================
키움 OpenAPI+ FID (종목프로그램매매 0w, KOA Studio 기준):
202 매수수량, 204 매도수량, 206 순매수수량(부호), 207 순매수대금(부호)
203 매수대금, 205 매도대금 (보조)
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
def _abs_int(v: Any) -> int:
try:
return int(abs(float(str(v or "0").replace(",", ""))))
except (TypeError, ValueError):
return 0
def _signed_int(v: Any) -> int:
try:
s = str(v or "0").replace(",", "").strip()
if not s or s in ("-", "+"):
return 0
return int(float(s))
except (TypeError, ValueError):
return 0
@dataclass
class ProgramSnapshot:
"""종목 1개 당일 프로그램매매 스냅샷."""
code: str
buy_qty: int = 0
sell_qty: int = 0
net_qty: int = 0
buy_amt: int = 0
sell_amt: int = 0
net_amt: int = 0
prev_net_qty: int = 0
delta_net_qty: int = 0
ts: float = 0.0
source: str = "kiwoom_0w"
snap_time: str = "" # YYYYMMDDHHMMSS — DB·판정 스냅 재생용
def is_net_buy(self) -> bool:
return self.net_qty > 0
def is_net_sell(self) -> bool:
return self.net_qty < 0
def sell_buy_qty_ratio(self) -> float:
if self.buy_qty <= 0:
return 999.0 if self.sell_qty > 0 else 0.0
return self.sell_qty / float(self.buy_qty)
def to_storage_dict(self) -> Dict[str, Any]:
"""``ws_program`` INSERT·백테 복원용."""
return {
"code": self.code,
"buy_qty": self.buy_qty,
"sell_qty": self.sell_qty,
"net_qty": self.net_qty,
"buy_amt": self.buy_amt,
"sell_amt": self.sell_amt,
"net_amt": self.net_amt,
"source": self.source,
"ts": self.ts,
"snap_time": self.snap_time or "",
}
def program_snapshot_from_storage(row: Dict[str, Any]) -> ProgramSnapshot:
"""DB 행 또는 storage dict → ``ProgramSnapshot``."""
ts_raw = row.get("ts")
try:
ts = float(ts_raw) if ts_raw not in (None, "") else time.time()
except (TypeError, ValueError):
ts = time.time()
st = str(row.get("snap_time") or "").strip()
return ProgramSnapshot(
code=str(row.get("code") or "").strip(),
buy_qty=_abs_int(row.get("buy_qty")),
sell_qty=_abs_int(row.get("sell_qty")),
net_qty=_signed_int(row.get("net_qty")),
buy_amt=_abs_int(row.get("buy_amt")),
sell_amt=_abs_int(row.get("sell_amt")),
net_amt=_signed_int(row.get("net_amt")),
ts=ts,
source=str(row.get("source") or "kiwoom_0w"),
snap_time=st[:14] if st else "",
)
def parse_kiwoom_0w_values(code: str, values: Dict[str, Any]) -> ProgramSnapshot:
"""키움 WS 0w ``values`` dict → ``ProgramSnapshot``."""
buy_qty = _abs_int(values.get("202"))
sell_qty = _abs_int(values.get("204"))
net_qty = _signed_int(values.get("206"))
if net_qty == 0 and (buy_qty > 0 or sell_qty > 0):
net_qty = buy_qty - sell_qty
buy_amt = _abs_int(values.get("203"))
sell_amt = _abs_int(values.get("205"))
net_amt = _signed_int(values.get("207"))
if net_amt == 0 and (buy_amt > 0 or sell_amt > 0):
net_amt = buy_amt - sell_amt
return ProgramSnapshot(
code=str(code).strip(),
buy_qty=buy_qty,
sell_qty=sell_qty,
net_qty=net_qty,
buy_amt=buy_amt,
sell_amt=sell_amt,
net_amt=net_amt,
ts=time.time(),
source="kiwoom_0w",
)
class ProgramCache:
"""스레드 세이프 종목별 프로그램매매 캐시."""
def __init__(self) -> None:
self._data: Dict[str, ProgramSnapshot] = {}
self._lock = threading.Lock()
def update_from_kiwoom_0w(self, code: str, values: Dict[str, Any]) -> ProgramSnapshot:
snap = parse_kiwoom_0w_values(code, values)
with self._lock:
prev = self._data.get(snap.code)
if prev is not None:
snap.prev_net_qty = prev.net_qty
snap.delta_net_qty = snap.net_qty - prev.net_qty
self._data[snap.code] = snap
return snap
def get(self, code: str, max_age_sec: float = 30.0) -> Optional[ProgramSnapshot]:
c = str(code or "").strip()
if not c:
return None
with self._lock:
snap = self._data.get(c)
if not snap:
return None
if max_age_sec > 0 and (time.time() - snap.ts) > max_age_sec:
return None
return snap
def remove(self, code: str) -> None:
c = str(code or "").strip()
with self._lock:
self._data.pop(c, None)