Files
kis_bot/kis_trader/ws/tick_recorder.py
Your Name 0780b2cdd0 feat: Enhance Optuna integration and logging for backtesting framework
Changes:
- Added new API endpoints for continuing and confirming Optuna jobs, allowing for better management of ongoing studies.
- Introduced detailed logging for tick feed tracking and order book processing, improving traceability of vendor performance during backtests.
- Updated database schema to include new fields for managing Optuna study results, enhancing the ability to track study progress and outcomes.
- Refactored existing functions to utilize the new logging and tracking features, ensuring consistency across the backtesting framework.

Impact:
- These enhancements improve the robustness and transparency of the Optuna backtesting process, facilitating better analysis and optimization of trading strategies.
2026-08-21 19:05:23 +09:00

304 lines
12 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).
- 국내 → ``ws_ticks`` (메인 TradeDB 커넥션)
- 해외 US → ``ws_ticks_us`` (전용 raw 커넥션 — 앱 TradeDB 락과 분리)
"""
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 마다 append INSERT
(KR→ws_ticks, US→ws_ticks_us).
"""
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
# 해외 틱 전용 DB 커넥션 (메인 TradeDB._lock 과 분리)
self._us_write_conn: Any = None
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, us→ws_ticks_us)",
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,
persist_db: bool = True,
cntr_str: Optional[float] = None,
upper_limit_time: Optional[str] = None,
tick_time_raw: Optional[str] = None,
) -> None:
"""체결 틱 1건 — WS 핫패스에서 호출 (논블로킹).
persist_db=False 이면 RAM 링버퍼만 갱신 (DB INSERT 생략).
LS 는 ``ls_ws_ticks`` 에 이미 적재하므로 ``ws_ticks`` 이중 저장 방지용.
"""
if not self._enabled or price <= 0:
return
code = (code or "").strip()
if not code:
return
mkt = (market or self._default_market).upper()[:8]
# 국내: WS_TICK_RECORD_SCOPE(candidates 등) 필터 적용.
# 해외(US): 영구구독이 국내 후보 집합에 없어 필터에 걸리면
# 봉(ws_candles)은 쌓이는데 틱(ws_ticks_us)만 끊기는 사고가 남.
# → US 는 필터 우회 (저장 ON/OFF 는 해외 WS 의 WS_TICK_SAVE_US_ENABLED).
if mkt != "US":
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": mkt,
"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],
# 봉과 동일: 경로. 체결 틱은 소켓만 적재 (ka10007 REST 현재가 INSERT 금지)
"channel": "ws",
"recv_ts": recv_ts,
}
if cntr_str is not None:
item["cntr_str"] = float(cntr_str)
if upper_limit_time:
item["upper_limit_time"] = str(upper_limit_time).strip()[:8]
if tick_time_raw:
# WS 원본 체결시각 — 자르지 않음 (초·밀리초·영업일 포함 그대로)
item["tick_time_raw"] = str(tick_time_raw).strip()[:64]
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)
# LS 등: 전용 테이블에 이미 쓰면 ws_ticks 중복 INSERT 생략
if not persist_db or 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 · 그 이상(밀리초 등) → tick_time 컬럼용 14자리+."""
raw = (tick_time or "").strip().replace(":", "").replace("-", "").replace(" ", "")
if len(raw) >= 14 and raw[:14].isdigit():
return raw[:14]
if len(raw) == 6 and raw.isdigit():
today = dt.datetime.now().strftime("%Y%m%d")
return today + raw
if len(raw) > 6 and raw.isdigit():
# KIS BSOP_DATE+STCK_CNTG_HOUR 등 — 앞 14자리가 유효하면 사용
if len(raw) >= 14:
return raw[:14]
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()
if hasattr(self.db, "ensure_ws_ticks_us_table"):
self.db.ensure_ws_ticks_us_table()
except Exception as e:
logger.warning("ws_ticks/_us 테이블 확인 실패: %s", e)
# 해외 INSERT 전용 커넥션 (메인 전략 SELECT 와 파이썬 락 비공유)
try:
if hasattr(self.db, "new_raw_connection"):
self._us_write_conn = self.db.new_raw_connection()
logger.info("✅ TickRecorder US writer 전용 커넥션 (ws_ticks_us)")
except Exception as e:
self._us_write_conn = None
logger.warning("US 전용 커넥션 실패 — 메인 DB로 폴백: %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
us_batch = [x for x in batch if str(x.get("market") or "").upper() == "US"]
kr_batch = [x for x in batch if str(x.get("market") or "").upper() != "US"]
try:
if kr_batch and hasattr(self.db, "insert_ws_ticks_batch"):
self.db.insert_ws_ticks_batch(kr_batch)
logger.debug("💾 [틱배치] %d건 → ws_ticks", len(kr_batch))
except Exception as e:
logger.warning("ws_ticks 배치 INSERT 실패 (%d건): %s", len(kr_batch), e)
try:
if us_batch and hasattr(self.db, "insert_ws_ticks_us_batch"):
self.db.insert_ws_ticks_us_batch(
us_batch, conn=self._us_write_conn,
)
logger.debug("💾 [틱배치] %d건 → ws_ticks_us", len(us_batch))
elif us_batch and hasattr(self.db, "insert_ws_ticks_batch"):
# 구버전 DB 헬퍼 폴백 (분리 전)
self.db.insert_ws_ticks_batch(us_batch)
logger.debug("💾 [틱배치] %d건 → ws_ticks(폴백 US)", len(us_batch))
except Exception as e:
logger.warning("ws_ticks_us 배치 INSERT 실패 (%d건): %s", len(us_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)
try:
if hasattr(self.db, "cleanup_old_ws_ticks_us"):
self.db.cleanup_old_ws_ticks_us(keep_days=self._keep_days)
except Exception as e:
logger.debug("ws_ticks_us 정리 실패: %s", e)