Files
kis_bot/kis_trader/ws/tick_recorder.py
Hwang 61c72a8a4c 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>
2026-07-06 01:27:00 +09:00

243 lines
9.0 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
kis_trader/ws/tick_recorder.py — 실시간 체결 틱 RAM 링버퍼 + ws_ticks 배치 저장 (C안)
==================================================================================
[트랙 1] 종목별 deque 링버퍼 — B안 봉 내 돌파·get_recent_ticks() 용 (DB 대기 없음)
[트랙 2] 제한 큐 → 백그라운드 스레드 배치 INSERT (journalctl 파싱 아님)
해외 확장: market / exchange / currency / session 컬럼 (국내 기본 KR/KRX/KRW).
"""
from __future__ import annotations
import datetime as dt
import queue
import threading
import time
from collections import deque
from typing import Any, Deque, Dict, List, Optional, Set
from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
from ..utils.logger import get_logger
logger = get_logger("kis_trader.tick_recorder")
class TickRecorder:
"""
WebSocket 체결 틱 기록기.
- ``set_record_codes()`` 로 후보(또는 구독) 종목만 필터.
- RAM: 종목당 최대 N틱 (링버퍼).
- DB: BATCH_SIZE / FLUSH_SEC 마다 ws_ticks append INSERT.
"""
def __init__(self, db=None) -> None:
self.db = db
self._enabled = get_env_bool("WS_TICK_SAVE_ENABLED", True)
self._buffer_max = get_env_int("WS_TICK_BUFFER_MAX_PER_CODE", 500)
self._batch_size = get_env_int("WS_TICK_DB_BATCH_SIZE", 200)
self._flush_sec = get_env_float("WS_TICK_DB_FLUSH_SEC", 3.0)
self._queue_max = get_env_int("WS_TICK_WRITE_QUEUE_MAX", 50000)
self._keep_days = get_env_int("WS_TICK_KEEP_DAYS", 7)
self._default_market = (get_env_from_db("WS_TICK_DEFAULT_MARKET", "KR") or "KR").strip().upper()
self._default_exchange = (get_env_from_db("WS_TICK_DEFAULT_EXCHANGE", "KRX") or "KRX").strip()
self._default_currency = (get_env_from_db("WS_TICK_DEFAULT_CURRENCY", "KRW") or "KRW").strip().upper()
self._lock = threading.Lock()
self._buffers: Dict[str, Deque[dict]] = {}
self._record_codes: Optional[Set[str]] = None
self._write_queue: queue.Queue = queue.Queue(maxsize=max(1000, self._queue_max))
self._writer_thread: Optional[threading.Thread] = None
self._last_cleanup_ts = 0.0
self._drops = 0
if self._enabled and self.db is not None:
self._ensure_table()
self._start_writer()
logger.info(
"✅ TickRecorder 활성 (buf=%d/종목, batch=%d, flush=%.1fs, keep=%dd)",
self._buffer_max, self._batch_size, self._flush_sec, self._keep_days,
)
elif self._enabled and self.db is None:
logger.warning("⚠️ TickRecorder: db=None → RAM 링버퍼만 (DB 저장 비활성)")
else:
logger.info(" TickRecorder 비활성 (WS_TICK_SAVE_ENABLED=false)")
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def set_record_codes(self, codes: Optional[Set[str]]) -> None:
"""None=필터 없음(구독 전체), 빈 set=저장 안 함."""
with self._lock:
if codes is None:
self._record_codes = None
else:
self._record_codes = {str(c).strip() for c in codes if c}
def on_tick(
self,
code: str,
price: float,
volume: int,
tick_time: str,
*,
market: Optional[str] = None,
exchange: Optional[str] = None,
currency: Optional[str] = None,
source: str = "kis",
session: Optional[str] = None,
tick_seq: Optional[int] = None,
) -> None:
"""체결 틱 1건 — WS 핫패스에서 호출 (논블로킹)."""
if not self._enabled or price <= 0:
return
code = (code or "").strip()
if not code:
return
with self._lock:
filt = self._record_codes
if filt is not None and code not in filt:
return
tick_time_full = self._normalize_tick_time(tick_time)
recv_ts = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
item = {
"market": (market or self._default_market).upper()[:8],
"exchange": (exchange or self._default_exchange)[:16] if (exchange or self._default_exchange) else None,
"code": code[:32],
"tick_time": tick_time_full,
"price": float(price),
"volume": int(volume or 0),
"tick_seq": tick_seq,
"session": (session or "")[:8] or None,
"currency": (currency or self._default_currency).upper()[:8],
"source": (source or "kis")[:16],
"recv_ts": recv_ts,
}
with self._lock:
buf = self._buffers.get(code)
if buf is None:
buf = deque(maxlen=max(10, self._buffer_max))
self._buffers[code] = buf
buf.append(item)
if self.db is None:
return
try:
self._write_queue.put_nowait(item)
except queue.Full:
self._drops += 1
if self._drops == 1 or self._drops % 5000 == 0:
logger.warning(
"⚠️ TickRecorder 쓰기 Queue 가득참 — 틱 DROP 누적=%d (code=%s)",
self._drops, code,
)
def get_recent_ticks(self, code: str, limit: int = 100) -> List[dict]:
"""RAM 링버퍼 최근 틱 (오래된→최신 순)."""
code = (code or "").strip()
if not code:
return []
with self._lock:
buf = self._buffers.get(code)
if not buf:
return []
rows = list(buf)
n = max(1, int(limit))
return rows[-n:]
def get_last_price(self, code: str) -> Optional[float]:
ticks = self.get_recent_ticks(code, limit=1)
if not ticks:
return None
p = ticks[-1].get("price")
return float(p) if p and float(p) > 0 else None
def remove_code(self, code: str) -> None:
"""구독 해제 시 RAM 버퍼 정리."""
code = (code or "").strip()
if not code:
return
with self._lock:
self._buffers.pop(code, None)
# ------------------------------------------------------------------
# 내부
# ------------------------------------------------------------------
@staticmethod
def _normalize_tick_time(tick_time: str) -> str:
"""HHMMSS 또는 YYYYMMDDHHMMSS → 14자리 시장 시각."""
raw = (tick_time or "").strip().replace(":", "").replace("-", "").replace(" ", "")
if len(raw) >= 14:
return raw[:14]
if len(raw) == 6 and raw.isdigit():
today = dt.datetime.now().strftime("%Y%m%d")
return today + raw
return dt.datetime.now().strftime("%Y%m%d%H%M%S")
def _ensure_table(self) -> None:
try:
if hasattr(self.db, "ensure_ws_ticks_table"):
self.db.ensure_ws_ticks_table()
except Exception as e:
logger.warning("ws_ticks 테이블 확인 실패: %s", e)
def _start_writer(self) -> None:
self._writer_thread = threading.Thread(
target=self._writer_loop,
name="TickDBWriter",
daemon=True,
)
self._writer_thread.start()
def _writer_loop(self) -> None:
batch: List[dict] = []
last_flush = time.time()
while True:
try:
timeout = max(0.1, self._flush_sec - (time.time() - last_flush))
item = self._write_queue.get(timeout=timeout)
if item is None:
break
batch.append(item)
self._write_queue.task_done()
except queue.Empty:
pass
now = time.time()
should = len(batch) >= self._batch_size or (
batch and (now - last_flush) >= self._flush_sec
)
if should and batch:
self._flush_batch(batch)
batch = []
last_flush = now
if now - self._last_cleanup_ts >= 3600.0:
self._maybe_cleanup_old()
self._last_cleanup_ts = now
if batch:
self._flush_batch(batch)
def _flush_batch(self, batch: List[dict]) -> None:
if not batch or not self.db:
return
try:
if hasattr(self.db, "insert_ws_ticks_batch"):
self.db.insert_ws_ticks_batch(batch)
logger.debug("💾 [틱배치] %d건 → ws_ticks", len(batch))
except Exception as e:
logger.warning("ws_ticks 배치 INSERT 실패 (%d건): %s", len(batch), e)
def _maybe_cleanup_old(self) -> None:
if self._keep_days <= 0:
return
try:
if hasattr(self.db, "cleanup_old_ws_ticks"):
self.db.cleanup_old_ws_ticks(keep_days=self._keep_days)
except Exception as e:
logger.debug("ws_ticks 정리 실패: %s", e)