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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user