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

@@ -117,6 +117,7 @@ class VolumeRankManager:
self._running = False
self._current: Dict[str, Set[str]] = {}
self._current_order: Dict[str, List[str]] = {}
self._name_map: Dict[str, str] = {}
self._initialized: Set[str] = set()
self._lock = threading.Lock()
@@ -161,7 +162,9 @@ class VolumeRankManager:
"""BaseStrategy._load_candidates 와 호환되는 dict 리스트."""
sid = (strategy_id or "").upper()
with self._lock:
codes = list(self._current.get(sid, set()))
codes = list(self._current_order.get(sid) or [])
if not codes:
codes = list(self._current.get(sid, set()))
nm = dict(self._name_map)
out = []
for c in codes:
@@ -170,6 +173,7 @@ class VolumeRankManager:
"name": nm.get(c, c),
"scalp_on": True,
"tail_on": True,
"updow_on": True,
"score": 0.0,
"price": 0.0,
})
@@ -206,9 +210,14 @@ class VolumeRankManager:
time.sleep(0.5)
def _tick_all(self) -> None:
from ..utils.universe_source import universe_source_active
# (blng, market, limit) 조합이 같으면 REST 1회만 호출
cache: Dict[Tuple[str, str, int, bool], List[Dict]] = {}
for cfg in self._configs:
sid = cfg["strategy_id"]
if not universe_source_active(sid, "ranking"):
continue
key = (cfg["blng"], cfg["market"], cfg["limit"], cfg["exclude_non_stock"])
if key in cache:
rows = cache[key]
@@ -252,6 +261,20 @@ class VolumeRankManager:
enters = new_set - prev
exits = prev - new_set
self._current[strategy_id] = new_set
ordered: List[str] = []
seen: Set[str] = set()
for r in rows:
code = (
r.get("mksc_shrn_iscd") or r.get("stk_cd")
or r.get("code") or ""
).strip()
if code and code in new_set and code not in seen:
seen.add(code)
ordered.append(code)
for c in new_set:
if c not in seen:
ordered.append(c)
self._current_order[strategy_id] = ordered
self._initialized.add(strategy_id)
for c, n in new_names.items():
self._name_map[c] = n
@@ -282,22 +305,36 @@ class VolumeRankManager:
# 변동 또는 첫 tick → 풀 스냅샷 저장 (condition 과 동일 테이블/포맷)
if changed or first_tick:
self._save_snapshot(strategy_id, new_set, new_names)
if not self._history_save_allowed(strategy_id):
return
with self._lock:
ordered = list(self._current_order.get(strategy_id) or [])
self._save_snapshot(strategy_id, ordered, new_names)
@staticmethod
def _history_save_allowed(strategy_id: str) -> bool:
"""active UNIVERSE_SOURCE 가 ``ranking`` 일 때만 ranking history 기록."""
from ..utils.universe_source import universe_source_active
sid = (strategy_id or "").upper()
if not sid:
return True
try:
return universe_source_active(sid, "ranking")
except Exception:
return True
def _save_snapshot(
self, strategy_id: str, codes: Set[str], names: Dict[str, str],
self, strategy_id: str, codes_ordered: List[str], names: Dict[str, str],
) -> None:
"""변동 감지 tick 마다 즉시 DB 에 풀 스냅샷 INSERT.
* ``event_time`` 은 REST 응답 직후 찍은 초단위 시각 (YYYY-MM-DD HH:MM:SS).
* 이 값이 백테스트 매칭 기준 시각이며, DB INSERT 가 1ms 지연되든 100ms
지연되든 행에 박히는 event_time 은 불변이므로 정합성은 보존.
* 폴링은 10초 주기라 DB 부하 매우 적음. 버퍼링으로 얻을 이득 없음.
"""
"""변동 감지 tick 마다 즉시 DB 에 풀 스냅샷 INSERT (랭킹 응답 순서 유지)."""
if not (self.history_enabled and self.db is not None):
return
event_time = dt.now().strftime("%Y-%m-%d %H:%M:%S")
items = [{"code": c, "name": names.get(c, c)} for c in sorted(codes)]
items = [
{"code": c, "name": names.get(c, c)}
for c in codes_ordered
if c
]
try:
n = self.db.insert_condition_universe_snapshot(
strategy_id=strategy_id,