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>
This commit is contained in:
320
kis_trader/backtest/range_break_portfolio_backtest.py
Normal file
320
kis_trader/backtest/range_break_portfolio_backtest.py
Normal file
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
박스권 돌파(RANGE_BREAK) 시각순 포트폴리오 백테스트.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from kis_trader.backtest.backtest_portfolio_common import (
|
||||
min_invest_ratio_of_slot,
|
||||
portfolio_exposure_krw,
|
||||
target_qty_and_cost,
|
||||
)
|
||||
from kis_trader.engine.range_break_engine import (
|
||||
check_sell_signal_range_break_live,
|
||||
range_break_min_bars_required,
|
||||
range_break_scan_buy_at_bar,
|
||||
)
|
||||
from kis_trader.engine.scalping_engine import _t2dt, _to_bool
|
||||
from kis_trader.share.stock_share import share_denom_for_code
|
||||
from kis_trader.strategies.breakout import (
|
||||
_bt_slot_key,
|
||||
breakout_invest_amount_krw,
|
||||
normalize_breakout_max_loss_krw,
|
||||
)
|
||||
|
||||
|
||||
def _buy_priority_key(
|
||||
code: str,
|
||||
slot_key: str,
|
||||
universe_by_slot: Optional[Dict[str, List[str]]],
|
||||
) -> Tuple[int, str]:
|
||||
if universe_by_slot is None:
|
||||
return (0, code)
|
||||
lst = universe_by_slot.get(slot_key) or []
|
||||
try:
|
||||
return (lst.index(code), code)
|
||||
except ValueError:
|
||||
return (999999, code)
|
||||
|
||||
|
||||
def _max_stocks_from_params(params: Dict[str, Any]) -> int:
|
||||
for key in ("max_stocks", "range_break_max_stocks"):
|
||||
v = params.get(key)
|
||||
if v not in (None, "", 0):
|
||||
return max(1, int(v))
|
||||
try:
|
||||
from kis_trader.utils.env import get_env_int
|
||||
n = get_env_int("RANGE_BREAK_MAX_STOCKS", 0) or get_env_int("MAX_STOCKS", 3)
|
||||
return max(1, int(n))
|
||||
except Exception:
|
||||
return 3
|
||||
|
||||
|
||||
def _total_budget_from_params(params: Dict[str, Any]) -> float:
|
||||
for key in ("total_budget_krw", "range_break_total_budget_krw"):
|
||||
v = params.get(key)
|
||||
if v not in (None, ""):
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
from kis_trader.utils.env import get_env_int
|
||||
cap = get_env_int("RANGE_BREAK_TOTAL_BUDGET_KRW", 0)
|
||||
if cap > 0:
|
||||
return float(cap)
|
||||
except Exception:
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def _resolve_invest_cap(params: Dict[str, Any]) -> float:
|
||||
slot_money = float(params.get("slot_money", 200_000))
|
||||
sl_pct = abs(float(params.get("stop_loss_pct", params.get("sl_pct", -0.03))))
|
||||
max_loss_krw = normalize_breakout_max_loss_krw(params.get("max_loss_krw", 200_000))
|
||||
return breakout_invest_amount_krw(max_loss_krw, sl_pct * 100.0, slot_money)
|
||||
|
||||
|
||||
def run_range_break_backtest_portfolio(
|
||||
codes_candles: Dict[str, List[Dict]],
|
||||
params: Dict[str, Any],
|
||||
universe_by_slot: Optional[Dict[str, List[str]]] = None,
|
||||
) -> List[Dict]:
|
||||
"""시각순 포트폴리오 박스권 돌파 백테스트."""
|
||||
min_bars = range_break_min_bars_required(params)
|
||||
force_eod_exit = _to_bool(params.get("force_eod_exit"), False)
|
||||
cooldown_min = float(params.get("cooldown_min", 30))
|
||||
max_daily = int(params.get("max_daily", 1))
|
||||
max_stocks = _max_stocks_from_params(params)
|
||||
slot_money = float(params.get("slot_money", 200_000))
|
||||
total_budget = _total_budget_from_params(params)
|
||||
if total_budget <= 0:
|
||||
total_budget = float(max_stocks * slot_money)
|
||||
min_invest_ratio = min_invest_ratio_of_slot(params, strategy="RANGE_BREAK")
|
||||
invest_cap = _resolve_invest_cap(params)
|
||||
|
||||
buy_params = dict(params)
|
||||
skipped_micro_buys = 0
|
||||
|
||||
ctx_by_code: Dict[str, Dict[str, Any]] = {}
|
||||
all_times_set = set()
|
||||
for code, raw_rows in codes_candles.items():
|
||||
if len(raw_rows) < min_bars:
|
||||
continue
|
||||
candles = [dict(r) for r in raw_rows]
|
||||
day_open_arr: List[float] = [0.0] * len(candles)
|
||||
_cur_day = None
|
||||
_cur_open = 0.0
|
||||
_first_open = float(candles[0].get("open") or 0) if candles else 0.0
|
||||
for _idx, _c in enumerate(candles):
|
||||
_d = str(_c.get("candle_time") or "")[:8]
|
||||
if _d != _cur_day:
|
||||
_cur_day = _d
|
||||
_cur_open = float(_c.get("open") or 0)
|
||||
day_open_arr[_idx] = _cur_open if _cur_open > 0 else _first_open
|
||||
ctx_by_code[code] = {
|
||||
"code": code,
|
||||
"candles": candles,
|
||||
"time_index": {c["candle_time"]: idx for idx, c in enumerate(candles)},
|
||||
"day_open_arr": day_open_arr,
|
||||
"last_exit_dt": {},
|
||||
"daily_cnt": {},
|
||||
"pending_entry": None,
|
||||
}
|
||||
for c in candles:
|
||||
all_times_set.add(c["candle_time"])
|
||||
|
||||
all_times = sorted(all_times_set)
|
||||
portfolio: Dict[str, Dict[str, Any]] = {}
|
||||
all_trades: List[Dict] = []
|
||||
|
||||
from kis_trader.engine.scalping_engine import check_sell_signal_backtest_bar
|
||||
|
||||
for t in all_times:
|
||||
slot_key = _bt_slot_key(t, int(params.get("scan_interval_min", 1)))
|
||||
|
||||
pending_codes = [
|
||||
code for code, ctx in ctx_by_code.items()
|
||||
if ctx.get("pending_entry") and ctx["pending_entry"].get("entry_time") == t
|
||||
]
|
||||
pending_codes.sort(key=lambda c: _buy_priority_key(c, slot_key, universe_by_slot))
|
||||
for code in pending_codes:
|
||||
ctx = ctx_by_code[code]
|
||||
pe = ctx.pop("pending_entry", None)
|
||||
if not pe or code in portfolio:
|
||||
continue
|
||||
if len(portfolio) >= max_stocks:
|
||||
break
|
||||
entry_price = float(pe["entry_price"])
|
||||
box_stop = float(pe.get("box_stop_line", 0) or 0)
|
||||
if entry_price <= 0:
|
||||
continue
|
||||
exposure = portfolio_exposure_krw(portfolio)
|
||||
remaining = max(0.0, total_budget - exposure)
|
||||
target_qty, target_cost = target_qty_and_cost(entry_price, invest_cap)
|
||||
min_required = target_cost * min_invest_ratio
|
||||
if target_qty < 1 or remaining < min_required:
|
||||
skipped_micro_buys += 1
|
||||
continue
|
||||
invest = min(invest_cap, remaining, target_cost)
|
||||
qty = int(invest / entry_price)
|
||||
if qty < 1:
|
||||
skipped_micro_buys += 1
|
||||
continue
|
||||
cost = qty * entry_price
|
||||
if cost < min_required:
|
||||
skipped_micro_buys += 1
|
||||
continue
|
||||
if exposure + cost > total_budget + 1e-6:
|
||||
skipped_micro_buys += 1
|
||||
continue
|
||||
portfolio[code] = {
|
||||
"entry_price": entry_price,
|
||||
"entry_time": t,
|
||||
"qty": qty,
|
||||
"max_price": entry_price,
|
||||
"box_stop_line": box_stop,
|
||||
}
|
||||
ctx["daily_cnt"][t[:8]] = ctx["daily_cnt"].get(t[:8], 0) + 1
|
||||
break
|
||||
|
||||
for code in list(portfolio.keys()):
|
||||
ctx = ctx_by_code.get(code)
|
||||
if ctx is None:
|
||||
continue
|
||||
idx = ctx["time_index"].get(t)
|
||||
if idx is None:
|
||||
continue
|
||||
candles = ctx["candles"]
|
||||
c = candles[idx]
|
||||
day = t[:8]
|
||||
cl = float(c["close"])
|
||||
|
||||
is_eod_raw = (idx == len(candles) - 1) or (candles[idx + 1]["candle_time"][:8] != day)
|
||||
is_eod = is_eod_raw and force_eod_exit
|
||||
|
||||
pos = portfolio[code]
|
||||
if t == pos["entry_time"]:
|
||||
continue
|
||||
|
||||
bar = dict(c)
|
||||
if "open" not in bar or bar.get("open") in (None, ""):
|
||||
bar["open"] = float(c.get("open") or cl)
|
||||
res = check_sell_signal_backtest_bar(
|
||||
pos,
|
||||
bar,
|
||||
params,
|
||||
is_eod=is_eod,
|
||||
sell_fn=check_sell_signal_range_break_live,
|
||||
low_mode="current",
|
||||
)
|
||||
if not res:
|
||||
continue
|
||||
reason, exit_price = res
|
||||
all_trades.append({
|
||||
"code": code,
|
||||
"buy_time": pos["entry_time"],
|
||||
"sell_time": t,
|
||||
"buy_price": pos["entry_price"],
|
||||
"sell_price": round(exit_price, 2),
|
||||
"qty": pos.get("qty", 1),
|
||||
"pnl": 0,
|
||||
"sell_reason": reason,
|
||||
"hold_min": 0,
|
||||
})
|
||||
ctx["last_exit_dt"][day] = _t2dt(t)
|
||||
del portfolio[code]
|
||||
|
||||
if len(portfolio) >= max_stocks:
|
||||
continue
|
||||
exposure = portfolio_exposure_krw(portfolio)
|
||||
if exposure >= total_budget - 1e-6:
|
||||
continue
|
||||
|
||||
candidates: List[Tuple[Tuple[int, str], str, Dict[str, Any]]] = []
|
||||
for code, ctx in ctx_by_code.items():
|
||||
if code in portfolio or ctx.get("pending_entry"):
|
||||
continue
|
||||
idx = ctx["time_index"].get(t)
|
||||
if idx is None:
|
||||
continue
|
||||
candles = ctx["candles"]
|
||||
c = candles[idx]
|
||||
day = t[:8]
|
||||
cl = float(c["close"])
|
||||
if cl <= 0:
|
||||
continue
|
||||
|
||||
if universe_by_slot is not None:
|
||||
if code not in universe_by_slot.get(slot_key, []):
|
||||
continue
|
||||
|
||||
if day in ctx["last_exit_dt"]:
|
||||
elapsed = (_t2dt(t) - ctx["last_exit_dt"][day]).total_seconds() / 60
|
||||
if elapsed < cooldown_min:
|
||||
continue
|
||||
if ctx["daily_cnt"].get(day, 0) >= max_daily:
|
||||
continue
|
||||
|
||||
state = {
|
||||
"last_exit_dt": ctx["last_exit_dt"].get(day),
|
||||
"daily_cnt": ctx["daily_cnt"].get(day, 0),
|
||||
}
|
||||
code_buy = dict(buy_params)
|
||||
code_buy["share_denom"] = share_denom_for_code(buy_params, code)
|
||||
_reason, _msg, signal, entry_price, entry_time = range_break_scan_buy_at_bar(
|
||||
candles, idx, code_buy, state=state,
|
||||
)
|
||||
if not signal or entry_price <= 0 or not entry_time:
|
||||
continue
|
||||
if entry_time[:8] != day:
|
||||
continue
|
||||
|
||||
pri = _buy_priority_key(code, slot_key, universe_by_slot)
|
||||
pe = {
|
||||
"entry_time": entry_time,
|
||||
"entry_price": entry_price,
|
||||
"box_stop_line": float(signal.get("box_stop_line", signal.get("box_high", 0)) or 0),
|
||||
}
|
||||
|
||||
if entry_time == t and code not in portfolio:
|
||||
exposure = portfolio_exposure_krw(portfolio)
|
||||
remaining = max(0.0, total_budget - exposure)
|
||||
target_qty, target_cost = target_qty_and_cost(entry_price, invest_cap)
|
||||
min_required = target_cost * min_invest_ratio
|
||||
if (
|
||||
len(portfolio) < max_stocks
|
||||
and target_qty >= 1
|
||||
and remaining >= min_required
|
||||
and exposure + target_cost <= total_budget + 1e-6
|
||||
):
|
||||
invest = min(invest_cap, remaining, target_cost)
|
||||
qty = int(invest / entry_price)
|
||||
if qty < 1:
|
||||
continue
|
||||
cost = qty * entry_price
|
||||
if cost >= min_required:
|
||||
portfolio[code] = {
|
||||
"entry_price": entry_price,
|
||||
"entry_time": t,
|
||||
"qty": qty,
|
||||
"max_price": entry_price,
|
||||
"box_stop_line": pe["box_stop_line"],
|
||||
}
|
||||
ctx["daily_cnt"][day] = ctx["daily_cnt"].get(day, 0) + 1
|
||||
continue
|
||||
|
||||
candidates.append((pri, code, pe))
|
||||
|
||||
if not candidates:
|
||||
continue
|
||||
candidates.sort(key=lambda x: x[0])
|
||||
_pri, pick_code, pe = candidates[0]
|
||||
ctx_by_code[pick_code]["pending_entry"] = pe
|
||||
|
||||
if skipped_micro_buys:
|
||||
params["_portfolio_skip_stats"] = {"skipped_micro_buys": skipped_micro_buys}
|
||||
all_trades.sort(key=lambda x: x["sell_time"])
|
||||
return all_trades
|
||||
Reference in New Issue
Block a user