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:
205
kis_trader/backtest/universe_timeline.py
Normal file
205
kis_trader/backtest/universe_timeline.py
Normal file
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
유니버스 타임라인 (전략 공통) — 스캔 시각(초)별 ``get_universe_at`` 재현 + EXIT 디바운스.
|
||||
|
||||
실매: 조건검색 변동 시 초단위 스냅샷(``target_candidates_history``) → 루프에서 그 시각
|
||||
최신 유니버스. 백테(구): 1분 슬롯 strict 집계 → 깜빡임 EXIT·분봉 끝 집계로 실매와 어긋남.
|
||||
|
||||
이 모듈은 전략 무관(strategy_id 파라미터)으로, 모멘텀·돌파 등 모든 전략이 **하나의
|
||||
``UniverseTimeline``** 로 실매(``get_universe_at``)와 초단위 정합을 맞추도록 통일한다.
|
||||
(기존 ``MomentumUniverseTimeline`` 은 이 클래스의 alias 로 유지 — 모멘텀 코드 무변경)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from bisect import bisect_right
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
||||
|
||||
|
||||
def _event_time_to_key(event_time: str) -> str:
|
||||
et = str(event_time or "")
|
||||
if len(et) < 19:
|
||||
return ""
|
||||
return (
|
||||
et[0:4] + et[5:7] + et[8:10]
|
||||
+ et[11:13] + et[14:16] + et[17:19]
|
||||
)
|
||||
|
||||
|
||||
def _parse_scan_key(scan_key: str) -> datetime:
|
||||
return datetime.strptime(str(scan_key)[:14], "%Y%m%d%H%M%S")
|
||||
|
||||
|
||||
def _add_minutes_to_scan_key(scan_key: str, minutes: int) -> str:
|
||||
dt = _parse_scan_key(scan_key)
|
||||
return (dt + timedelta(minutes=int(minutes))).strftime("%Y%m%d%H%M%S")
|
||||
|
||||
|
||||
def _dedupe_codes_preserve_order(codes: Sequence[str]) -> List[str]:
|
||||
"""스냅샷 내 종목 순서 유지 + 중복 제거 (HTS/DB insert 순)."""
|
||||
seen: Set[str] = set()
|
||||
out: List[str] = []
|
||||
for c in codes:
|
||||
cs = str(c).strip()
|
||||
if cs and cs not in seen:
|
||||
seen.add(cs)
|
||||
out.append(cs)
|
||||
return out
|
||||
|
||||
|
||||
def normalize_universe_events(events: Sequence[Dict[str, Any]]) -> List[Tuple[str, List[str]]]:
|
||||
"""iter_universe_events 결과 → (YYYYMMDDHHMMSS, codes) 시간순 리스트."""
|
||||
out: List[Tuple[str, List[str]]] = []
|
||||
for ev in events:
|
||||
et_key = _event_time_to_key(str(ev.get("event_time") or ""))
|
||||
if not et_key:
|
||||
continue
|
||||
codes = _dedupe_codes_preserve_order(
|
||||
[str(it["code"]) for it in ev.get("items", []) if it.get("code")]
|
||||
)
|
||||
out.append((et_key, codes))
|
||||
out.sort(key=lambda x: x[0])
|
||||
return out
|
||||
|
||||
|
||||
def debounce_universe_snapshots(
|
||||
normalized: Sequence[Tuple[str, List[str]]],
|
||||
debounce_sec: int,
|
||||
) -> List[Tuple[str, List[str]]]:
|
||||
"""
|
||||
짧은 EXIT 후 재편입은 EXIT 로 치지 않음 (HTS 조건검색 노이즈 완화).
|
||||
각 스냅샷 시각에 **디바운스 적용 후** 유효 종목 리스트를 기록 (가나다 재정렬 없음).
|
||||
"""
|
||||
if debounce_sec <= 0 or not normalized:
|
||||
return list(normalized)
|
||||
|
||||
effective_order: List[str] = []
|
||||
effective_set: Set[str] = set()
|
||||
pending_exit: Dict[str, Tuple[str, datetime]] = {}
|
||||
out: List[Tuple[str, List[str]]] = []
|
||||
|
||||
def _eff_remove(code: str) -> None:
|
||||
if code not in effective_set:
|
||||
return
|
||||
effective_set.discard(code)
|
||||
try:
|
||||
effective_order.remove(code)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _eff_add(code: str) -> None:
|
||||
if code in effective_set:
|
||||
return
|
||||
effective_set.add(code)
|
||||
effective_order.append(code)
|
||||
|
||||
for et_key, codes in normalized:
|
||||
cur_list = _dedupe_codes_preserve_order([str(c) for c in codes if c])
|
||||
cur_set = set(cur_list)
|
||||
et_dt = _parse_scan_key(et_key)
|
||||
|
||||
for code, (ex_key, ex_dt) in list(pending_exit.items()):
|
||||
if (et_dt - ex_dt).total_seconds() >= debounce_sec:
|
||||
if code not in cur_set:
|
||||
_eff_remove(code)
|
||||
pending_exit.pop(code, None)
|
||||
|
||||
for code in list(effective_order):
|
||||
if code not in cur_set and code not in pending_exit:
|
||||
pending_exit[code] = (et_key, et_dt)
|
||||
|
||||
for code in cur_list:
|
||||
pending_exit.pop(code, None)
|
||||
_eff_add(code)
|
||||
|
||||
out.append((et_key, list(effective_order)))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
class UniverseTimeline:
|
||||
"""스캔 시각별 유니버스 — 이진 탐색으로 O(log N) 조회 (전략 공통)."""
|
||||
|
||||
__slots__ = ("_keys", "_codes", "_strict_avail")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
snapshots: Sequence[Tuple[str, List[str]]],
|
||||
*,
|
||||
strict: bool = False,
|
||||
strict_lag_minutes: int = 0,
|
||||
) -> None:
|
||||
keys: List[str] = []
|
||||
codes_list: List[List[str]] = []
|
||||
strict_avail: Dict[str, str] = {}
|
||||
prev_set: Set[str] = set()
|
||||
|
||||
for et_key, codes in snapshots:
|
||||
keys.append(et_key)
|
||||
ordered = _dedupe_codes_preserve_order([str(c) for c in codes if c])
|
||||
cur_set = set(ordered)
|
||||
if strict:
|
||||
lag = max(0, int(strict_lag_minutes))
|
||||
for c in cur_set - prev_set:
|
||||
strict_avail[c] = (
|
||||
_add_minutes_to_scan_key(et_key, lag) if lag > 0 else et_key
|
||||
)
|
||||
for c in prev_set - cur_set:
|
||||
strict_avail.pop(c, None)
|
||||
prev_set = cur_set
|
||||
codes_list.append(ordered)
|
||||
|
||||
self._keys = keys
|
||||
self._codes = codes_list
|
||||
self._strict_avail = strict_avail
|
||||
|
||||
def codes_at(self, scan_key: str) -> List[str]:
|
||||
"""scan_key(YYYYMMDDHHMMSS) 시점 유니버스 — 실매 get_universe_at 동일."""
|
||||
sk = str(scan_key or "")[:14]
|
||||
if not sk or not self._keys:
|
||||
return []
|
||||
idx = bisect_right(self._keys, sk) - 1
|
||||
if idx < 0:
|
||||
return []
|
||||
raw = list(self._codes[idx])
|
||||
if not self._strict_avail:
|
||||
return raw
|
||||
return [c for c in raw if sk >= self._strict_avail.get(c, "00000000000000")]
|
||||
|
||||
@property
|
||||
def snapshot_count(self) -> int:
|
||||
return len(self._keys)
|
||||
|
||||
|
||||
def build_universe_timeline(
|
||||
*,
|
||||
strategy_id: str,
|
||||
start_ymd: str,
|
||||
end_ymd: str,
|
||||
debounce_sec: int = 30,
|
||||
strict: bool = False,
|
||||
strict_lag_minutes: int = 1,
|
||||
) -> Optional[UniverseTimeline]:
|
||||
"""``target_candidates_history`` (strategy_id) → 초단위 유니버스 타임라인."""
|
||||
try:
|
||||
from kis_trader.database.db_manager import get_db
|
||||
ext = get_db()
|
||||
start_time = f"{start_ymd[:4]}-{start_ymd[4:6]}-{start_ymd[6:8]} 00:00:00"
|
||||
end_time = f"{end_ymd[:4]}-{end_ymd[4:6]}-{end_ymd[6:8]} 23:59:59"
|
||||
events = ext.iter_universe_events(
|
||||
strategy_id=strategy_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
preserve_insert_order=True,
|
||||
)
|
||||
normalized = normalize_universe_events(events)
|
||||
if not normalized:
|
||||
return None
|
||||
debounced = debounce_universe_snapshots(normalized, debounce_sec)
|
||||
return UniverseTimeline(
|
||||
debounced,
|
||||
strict=strict,
|
||||
strict_lag_minutes=strict_lag_minutes,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
Reference in New Issue
Block a user