feat: Add new files and enhance backtesting functionality

Changes:
- Introduced new files for strategy definitions and study names.
- Enhanced `backtest_web.py` with functions to handle integer display prices and trade data formatting.
- Updated backtesting logic to incorporate end-of-day (EOD) parameters for breakout and momentum strategies.
- Added EOD configuration options in the database and parameter search files.

Impact:
- These changes improve the modularity and usability of the backtesting framework, allowing for better integration of EOD strategies and clearer trade data presentation.
This commit is contained in:
2026-07-06 19:11:34 +09:00
parent 336d637b72
commit 78edb75e01
33 changed files with 1479 additions and 303 deletions

View File

@@ -53,7 +53,8 @@ import json
import logging
import threading
import time
from typing import Any, Dict, Iterable, List, Optional, Set
from collections import defaultdict
from typing import Any, Callable, Dict, Iterable, List, Optional, Set
logger = logging.getLogger("KiwoomWebSocket")
@@ -192,6 +193,12 @@ class KiwoomWebSocketPriceCache:
self._reg_timer: Optional[threading.Timer] = None
self._reg_timer_lock = threading.Lock()
# 조건검색 등 외부 모듈 — **동일 WS 세션 공유** (키움은 토큰당 1접속)
self._ext_handler_lock = threading.Lock()
self._ext_handlers: Dict[str, List[Callable]] = defaultdict(list)
self._login_callbacks: List[Callable] = []
self._login_cb_lock = threading.Lock()
# websocket-client lib
try:
import websocket as _ws_lib # type: ignore
@@ -267,6 +274,73 @@ class KiwoomWebSocketPriceCache:
def _program_ws_enabled(self) -> bool:
return get_env_bool("KIWOOM_WS_PROGRAM_ENABLED", True)
def is_available(self) -> bool:
"""websocket-client 설치 및 키 설정 여부."""
return bool(self._available and self.app_key and self.app_secret)
def is_authenticated(self) -> bool:
"""LOGIN OK 이후 REG/조건검색 전송 가능."""
return bool(self._connected and self._authenticated)
def register_trnm_handler(self, trnm: str, fn: Callable) -> None:
"""외부 모듈(조건검색 CNSR* 등)용 trnm 핸들러 — 시세 WS 와 세션 공유."""
key = str(trnm or "").strip().upper()
if not key or not callable(fn):
return
with self._ext_handler_lock:
if fn not in self._ext_handlers[key]:
self._ext_handlers[key].append(fn)
def unregister_trnm_handler(self, trnm: str, fn: Callable) -> None:
key = str(trnm or "").strip().upper()
with self._ext_handler_lock:
lst = self._ext_handlers.get(key)
if lst and fn in lst:
lst.remove(fn)
def add_on_login_callback(self, fn: Callable) -> None:
"""LOGIN OK 직후(재접속마다) 호출 — 조건검색 CNSRLST 등."""
if not callable(fn):
return
with self._login_cb_lock:
if fn not in self._login_callbacks:
self._login_callbacks.append(fn)
def remove_on_login_callback(self, fn: Callable) -> None:
with self._login_cb_lock:
if fn in self._login_callbacks:
self._login_callbacks.remove(fn)
def send_json(self, msg: dict) -> bool:
"""인증된 WS 에 JSON 전송 (조건검색 CNSRREQ 등)."""
if not self.is_authenticated() or not self._ws:
return False
try:
self._ws.send(json.dumps(msg))
return True
except Exception as e:
logger.debug("키움 WS send_json 실패: %s", e)
return False
def _fire_login_callbacks(self, ws) -> None:
with self._login_cb_lock:
cbs = list(self._login_callbacks)
for cb in cbs:
try:
cb(ws)
except Exception as e:
logger.debug("키움 WS login callback 예외: %s", e)
def _dispatch_ext_handlers(self, trnm: str, ws, msg: dict) -> None:
key = str(trnm or "").strip().upper()
with self._ext_handler_lock:
handlers = list(self._ext_handlers.get(key, []))
for fn in handlers:
try:
fn(ws, msg)
except Exception as e:
logger.debug("키움 WS ext handler(%s) 예외: %s", key, e)
def _reg_types(self) -> List[str]:
"""REG/REMOVE 실시간 타입 — 0B 체결 + (옵션) 0D 호가 + 0w 프로그램."""
types = [self.SUB_TYPE]
@@ -482,7 +556,8 @@ class KiwoomWebSocketPriceCache:
)
self._last_connect_time = time.time()
# blocking — 연결 종료까지 여기서 대기
self._ws.run_forever(ping_interval=30, ping_timeout=10)
# ping_interval=0 : 키움은 JSON {"trnm":"PING"} keep-alive (프로토콜 ping 비호환)
self._ws.run_forever(ping_interval=0)
def _on_open(self, token: str):
"""on_open 콜백 팩토리 — token 캡처 후 LOGIN 발송."""
@@ -533,8 +608,8 @@ class KiwoomWebSocketPriceCache:
self._reg_batch_codes.clear()
if pending:
self._send_reg_chunked(pending)
# 안정 연결 카운터 초기화 (지속 5분 이상 연결됐다면)
# 여기선 LOGIN 직후라 의미 없음, _periodic_reset_ok() 에서 처리
# 조건검색 등 공유 세션 모듈 — LOGIN 직후 CNSRLST 재등록
self._fire_login_callbacks(ws)
else:
logger.warning("❌ 키움 WS LOGIN 실패 rc=%s msg=%s", rc, rm)
try:
@@ -554,8 +629,12 @@ class KiwoomWebSocketPriceCache:
if trnm == "REAL":
self._handle_real(msg)
self._dispatch_ext_handlers("REAL", ws, msg)
return
# 조건검색 CNSRLST / CNSRREQ / CNSRCLR 등
self._dispatch_ext_handlers(trnm, ws, msg)
def _handle_real(self, msg: dict) -> None:
"""실시간 데이터 처리 — 0B 체결 + 0D 호가잔량 + 0w 프로그램매매."""
items = msg.get("data") or []
@@ -617,27 +696,30 @@ class KiwoomWebSocketPriceCache:
with self._cache_lock:
self._cache[code] = {"data": data_compat, "ts": time.time()}
# ── CandleAggregator (KIS WS 와 동일 HHMMSS → on_tick) ─────
# tick_time / tick_vol — CandleAggregator·TickRecorder 공용 (보유-only도 recorder 수집)
tt_raw = str(values.get(self.FID_TICK_TIME, "") or "").strip()
if len(tt_raw) >= 6:
tick_time = tt_raw[-6:]
else:
import datetime as _dt
tick_time = _dt.datetime.now().strftime("%H%M%S")
try:
tick_vol = int(
abs(float(str(values.get(self.FID_TICK_VOL, "0")).replace(",", "")))
)
except (ValueError, TypeError):
tick_vol = 0
# ── CandleAggregator: 후보만 (tick_to_agg = 후보−보유) ─────
if self._candle_agg is not None:
filt = self._candle_agg_codes
if filt is not None and code not in filt:
return
tt_raw = str(values.get(self.FID_TICK_TIME, "") or "").strip()
if len(tt_raw) >= 6:
tick_time = tt_raw[-6:]
else:
import datetime as _dt
tick_time = _dt.datetime.now().strftime("%H%M%S")
try:
tick_vol = int(
abs(float(str(values.get(self.FID_TICK_VOL, "0")).replace(",", "")))
)
except (ValueError, TypeError):
tick_vol = 0
try:
self._candle_agg.on_tick(code, price, tick_vol, tick_time)
except Exception as ex:
logger.debug("키움→CandleAggregator on_tick 실패 %s: %s", code, ex)
if filt is None or code in filt:
try:
self._candle_agg.on_tick(code, price, tick_vol, tick_time)
except Exception as ex:
logger.debug("키움→CandleAggregator on_tick 실패 %s: %s", code, ex)
# ── TickRecorder: 보유 포함 전 구독 종목 (백테 틱청산·B안 폴백) ─────
if self._tick_recorder is not None:
try:
self._tick_recorder.on_tick(