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:
157
kis_trader/ws/program_cache.py
Normal file
157
kis_trader/ws/program_cache.py
Normal 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)
|
||||
Reference in New Issue
Block a user