변경 사항 ---- - _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>
116 lines
3.5 KiB
Python
116 lines
3.5 KiB
Python
"""
|
|
kis_trader/backtest/breakout_tick_loader.py — ws_ticks 로드·분봉 인덱싱 (B안 백테)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
|
|
from ..utils.env import get_env_from_db
|
|
from ..utils.logger import get_logger
|
|
|
|
logger = get_logger("kis_trader.breakout_tick_loader")
|
|
|
|
|
|
def _candle_keys_to_tick_range(start_key: str, end_key: str) -> Tuple[str, str]:
|
|
"""ws_candles 키(12자리) → ws_ticks tick_time(14자리) 범위."""
|
|
s = (start_key or "")[:12]
|
|
e = (end_key or "")[:12]
|
|
return s + "00", e + "59"
|
|
|
|
|
|
def load_breakout_ticks_by_code(
|
|
db,
|
|
start_key: str,
|
|
end_key: str,
|
|
codes: Optional[Set[str]] = None,
|
|
*,
|
|
market: Optional[str] = None,
|
|
) -> Tuple[Dict[str, Dict[str, List[Dict[str, Any]]]], int]:
|
|
"""
|
|
기간 내 ``ws_ticks`` 를 종목·분봉(YYYYMMDDHHMM) 단위로 로드.
|
|
|
|
Returns:
|
|
(``{code: {minute_key: [tick, ...]}}``, total_tick_rows)
|
|
"""
|
|
mkt = (market or get_env_from_db("WS_TICK_DEFAULT_MARKET", "KR") or "KR").strip().upper()
|
|
tt_start, tt_end = _candle_keys_to_tick_range(start_key, end_key)
|
|
|
|
try:
|
|
if hasattr(db, "ensure_ws_ticks_table"):
|
|
db.ensure_ws_ticks_table()
|
|
except Exception:
|
|
pass
|
|
|
|
code_filter = ""
|
|
params: List[Any] = [mkt, tt_start, tt_end]
|
|
if codes:
|
|
placeholders = ",".join(["%s"] * len(codes))
|
|
code_filter = f" AND code IN ({placeholders})"
|
|
params.extend(sorted(codes))
|
|
|
|
sql = f"""
|
|
SELECT code, tick_time, price, volume, source
|
|
FROM ws_ticks
|
|
WHERE market = %s
|
|
AND tick_time >= %s
|
|
AND tick_time <= %s
|
|
{code_filter}
|
|
ORDER BY code, tick_time
|
|
"""
|
|
try:
|
|
rows = db.conn.execute(sql, tuple(params)).fetchall()
|
|
except Exception as e:
|
|
logger.warning("ws_ticks 조회 실패 — OHLC 폴백만 사용: %s", e)
|
|
return {}, 0
|
|
|
|
out: Dict[str, Dict[str, List[Dict[str, Any]]]] = defaultdict(dict)
|
|
total = 0
|
|
for r in rows:
|
|
code = str(r["code"]).strip()
|
|
tt = str(r["tick_time"])[:14]
|
|
if len(tt) < 12:
|
|
continue
|
|
minute_key = tt[:12]
|
|
tick = {
|
|
"code": code,
|
|
"tick_time": tt,
|
|
"price": float(r["price"] or 0),
|
|
"volume": int(r.get("volume") or 0),
|
|
"source": r.get("source") or "",
|
|
}
|
|
bucket = out[code].setdefault(minute_key, [])
|
|
bucket.append(tick)
|
|
total += 1
|
|
|
|
return dict(out), total
|
|
|
|
|
|
def tick_coverage_stats(
|
|
codes_candles: Dict[str, List[Dict]],
|
|
ticks_by_code: Dict[str, Dict[str, List[Dict]]],
|
|
) -> Dict[str, Any]:
|
|
"""분봉 대비 틱 보유 비율 (백테 메타용)."""
|
|
total_bars = 0
|
|
bars_with_ticks = 0
|
|
codes_with_any = 0
|
|
for code, candles in codes_candles.items():
|
|
minute_map = ticks_by_code.get(code) or {}
|
|
if minute_map:
|
|
codes_with_any += 1
|
|
for c in candles:
|
|
ct = str(c.get("candle_time") or "")[:12]
|
|
if not ct:
|
|
continue
|
|
total_bars += 1
|
|
if minute_map.get(ct):
|
|
bars_with_ticks += 1
|
|
ratio = (bars_with_ticks / total_bars * 100.0) if total_bars else 0.0
|
|
return {
|
|
"tick_bars_total": total_bars,
|
|
"tick_bars_covered": bars_with_ticks,
|
|
"tick_bar_coverage_pct": round(ratio, 2),
|
|
"tick_codes_with_data": codes_with_any,
|
|
"tick_codes_total": len(codes_candles),
|
|
}
|