Files
kis_bot/kis_trader/backtest/range_break_backtest_common.py
Hwang 61c72a8a4c feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항
----
- _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>
2026-07-06 01:27:00 +09:00

185 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""
박스권 돌파(RANGE_BREAK) 백테스트 공통 로더 — backtest_web / param_search 단일 진입점.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from kis_trader.backtest.backtest_portfolio_common import (
attach_scalp_trade_pnl,
backtest_slip_pct,
build_budget_warning,
fee_and_slot_from_env_row,
merge_portfolio_into_params,
min_invest_ratio_of_slot,
resolve_portfolio_params,
summarize_trades,
)
from kis_trader.engine.range_break_engine import (
RANGE_BREAK_STRATEGY_ID,
range_break_min_bars_required,
run_range_break_backtest,
)
RANGE_BREAK_STRATEGY_ID = RANGE_BREAK_STRATEGY_ID # noqa: F811 — re-export
def date_keys(start: str, end: str) -> Tuple[str, str, str, str]:
start_key = start.replace("-", "") + "0000"
end_key = end.replace("-", "") + "2359"
return start_key, end_key, start_key[:8], end_key[:8]
def resolve_range_break_universe(
start_ymd: str,
end_ymd: str,
*,
use_saved_history: bool,
strategy_id: str = RANGE_BREAK_STRATEGY_ID,
) -> Tuple[Optional[Dict[str, List[str]]], str, int, int]:
if use_saved_history and strategy_id:
try:
from kis_trader.database.db_manager import get_db as _get_ext_db
history = _get_ext_db().get_universe_by_candle_time(
strategy_id=strategy_id,
start_ymd=start_ymd,
end_ymd=end_ymd,
)
if history:
return history, "history", len(history), 1
except Exception:
pass
return None, "all", 0, 1
def load_range_break_candles_by_code(
db,
start_key: str,
end_key: str,
params: Optional[Dict[str, Any]] = None,
) -> Tuple[Dict[str, List[Dict]], int]:
"""ws_candles 1분봉 전 종목 로드."""
p = dict(params or {})
min_bars = range_break_min_bars_required(p)
codes_raw = db.conn.execute(
"SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 "
"AND candle_time >= %s AND candle_time <= %s ORDER BY code",
[start_key, end_key],
).fetchall()
codes = [r["code"] for r in codes_raw]
candles_by_code: Dict[str, List[Dict]] = {}
total_candles = 0
for code in codes:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
"FROM ws_candles WHERE timeframe=1 AND code=%s "
"AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1 "
"ORDER BY candle_time ASC",
[code, start_key, end_key],
).fetchall()
if len(rows) < min_bars:
continue
candles_by_code[code] = [dict(r) for r in rows]
total_candles += len(rows)
return candles_by_code, total_candles
def run_range_break_backtest_web_aligned(
candles_by_code: Dict[str, List[Dict]],
params: Dict[str, Any],
universe_by_slot: Optional[Dict[str, List[str]]],
*,
slot_money: float,
fee_rate: float,
sell_tax: float,
max_stocks: Optional[int] = None,
total_budget_krw: Optional[float] = None,
meta_out: Optional[Dict[str, Any]] = None,
) -> List[Dict]:
engine_params = dict(params)
engine_params["slot_money"] = float(slot_money)
if max_stocks is not None:
engine_params["max_stocks"] = int(max_stocks)
if total_budget_krw is not None:
tb = float(total_budget_krw)
engine_params["total_budget_krw"] = tb if tb > 0 else float(
int(engine_params.get("max_stocks") or 3) * slot_money
)
if universe_by_slot is not None:
engine_params.setdefault("scan_interval_min", 1)
engine_params.setdefault("portfolio_mode", True)
trades = run_range_break_backtest(
candles_by_code,
engine_params,
universe_by_slot=universe_by_slot,
)
attach_scalp_trade_pnl(
trades, fee_rate=fee_rate, sell_tax=sell_tax,
slip_pct=backtest_slip_pct(engine_params),
)
if meta_out is not None:
skip_stats = engine_params.get("_portfolio_skip_stats") or {}
meta_out["skip_stats"] = dict(skip_stats)
meta_out["engine_params"] = engine_params
meta_out["backtest_buy_source"] = "align"
return trades
def resolve_range_break_portfolio_params(
env_row: Optional[Dict[str, Any]],
base_defaults: Optional[Dict[str, Any]] = None,
*,
slot_money: Optional[float] = None,
max_stocks: Optional[int] = None,
total_budget_krw: Optional[float] = None,
) -> Dict[str, Any]:
return resolve_portfolio_params(
env_row,
base_defaults,
strategy="RANGE_BREAK",
slot_money=slot_money,
max_stocks=max_stocks,
total_budget_krw=total_budget_krw,
)
def merge_range_break_portfolio_into_params(
params: Dict[str, Any],
portfolio: Dict[str, Any],
) -> Dict[str, Any]:
return merge_portfolio_into_params(params, portfolio)
def build_range_break_budget_warning(
portfolio: Dict[str, Any],
skip_stats: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
ratio = min_invest_ratio_of_slot({}, strategy="RANGE_BREAK")
return build_budget_warning(portfolio, skip_stats, min_invest_ratio=ratio)
def summarize_range_break_trades(
trades: List[Dict],
*,
total_budget_krw: float,
period_days: int = 1,
) -> Dict[str, Any]:
return summarize_trades(
trades,
total_budget_krw=total_budget_krw,
period_days=period_days,
)
def fee_and_slot_from_env(
row: Optional[Dict[str, Any]],
) -> Tuple[float, float, float]:
return fee_and_slot_from_env_row(row, strategy="RANGE_BREAK")