""" 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)