Files
kis_bot/kis_trader/backtest/scalping_portfolio_backtest.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

339 lines
12 KiB
Python

#!/usr/bin/env python3
"""
SCALP reversal 시각순 포트폴리오 백테스트 — tail_engine.run_tail_backtest_portfolio 와 동일 구조.
모멘텀(MOMENTUM)은 ``momentum_portfolio_backtest.run_momentum_backtest_portfolio`` 로 위임.
"""
from __future__ import annotations
from datetime import datetime
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.scalping_engine import (
_apply_buy_state_filters,
_eval_momentum_buy_at_index,
_eval_scalp_buy_at_index,
_macd_lines_from_params,
_slot_key,
_t2dt,
_to_bool,
check_sell_signal_backtest_bar,
compute_rsi_series,
effective_tp_pct_from_params,
)
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", "scalp_max_stocks", "short_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("SCALP_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", "scalp_total_budget_krw", "short_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("SCALP_TOTAL_BUDGET_KRW", 0)
if cap > 0:
return float(cap)
except Exception:
pass
return 0.0
def _resolve_invest_cap_krw(params: Dict[str, Any], slot_money: float) -> float:
"""legacy loop 와 동일 — max_loss/sl_pct 로 1회 투입 상한."""
sl_pct = abs(float(params.get("sl_pct", 0.015)))
max_loss_krw = float(params.get("max_loss_krw", 200000.0))
invest_amount = float(slot_money)
if max_loss_krw > 0 and sl_pct > 0:
invest_limit = max_loss_krw / sl_pct
invest_amount = min(invest_limit, float(slot_money))
return invest_amount
def run_scalping_backtest_portfolio(
codes_candles: Dict[str, List[Dict]],
params: Dict[str, Any],
universe_by_slot: Optional[Dict[str, List[str]]] = None,
mode: str = "reversal",
) -> List[Dict]:
"""
시각순 포트폴리오 백테스트 — 실매 BaseStrategy 제약 근사.
- ``mode='reversal'``: ``_eval_scalp_buy_at_index``
- ``mode='momentum'``: ``_eval_momentum_buy_at_index``
"""
mode = str(mode or "reversal").strip().lower()
if mode == "momentum":
from kis_trader.backtest.momentum_portfolio_backtest import run_momentum_backtest_portfolio
return run_momentum_backtest_portfolio(
codes_candles, params, universe_by_slot=universe_by_slot,
)
strategy = "SCALP"
rsi_period = int(params.get("rsi_period", 3))
min_bars = max(rsi_period + 5, 6) if mode == "momentum" else rsi_period + 5
force_eod_exit = _to_bool(params.get("force_eod_exit"), False)
sl_pct = abs(float(params.get("sl_pct", 0.015)))
tp_pct = effective_tp_pct_from_params(params)
max_stocks = _max_stocks_from_params(params)
slot_money = float(params.get("slot_money", 300_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=strategy)
invest_cap = _resolve_invest_cap_krw(params, slot_money)
use_macd_cross = _to_bool(params.get("use_macd_cross", False), False)
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]
macd_combined = (
_macd_lines_from_params(candles, params) if use_macd_cross else None
)
ctx_by_code[code] = {
"code": code,
"candles": candles,
"macd_combined": macd_combined,
"time_index": {c["candle_time"]: idx for idx, c in enumerate(candles)},
"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] = []
for t in all_times:
slot_key = _slot_key(t, params.get("scan_interval_min", 1))
# ── Phase 0: 예약 진입 (직전 봉 신호 → 이번 봉 시가) ──
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"])
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,
"stop": pe["stop"],
"target": pe["target"],
"max_price": entry_price,
"rsi": pe.get("rsi"),
}
break # 1시각 1매수
# ── Phase 1: 보유 종목 청산 ──
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]
hi = float(c["high"])
lo = float(c["low"])
cl = float(c["close"])
op = float(c["open"])
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
max_p = max(float(pos.get("max_price", 0) or 0), hi)
pos["max_price"] = max_p
cur_c_info = {
"open": op,
"high": hi,
"low": lo,
"close": cl,
"candle_time": t,
}
res = check_sell_signal_backtest_bar(pos, cur_c_info, params, is_eod=is_eod)
if not res:
continue
reason, exit_price = res
trade: Dict[str, Any] = {
"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,
}
if pos.get("rsi") is not None:
trade["rsi_entry"] = round(float(pos["rsi"]), 1)
all_trades.append(trade)
ctx["last_exit_dt"][day] = _t2dt(t)
ctx["daily_cnt"][day] = ctx["daily_cnt"].get(day, 0) + 1
del portfolio[code]
# ── Phase 2: 신규 매수 신호 (다음 봉 시가 진입 예약) ──
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 universe_by_slot is not None:
if code not in universe_by_slot.get(slot_key, []):
continue
if cl <= 0:
continue
eval_params = dict(params)
if universe_by_slot is not None:
eval_params.setdefault("skip_hts_scan_dupes", True)
else:
eval_params.setdefault("skip_hts_scan_dupes", False)
state = {
"daily_cnt": ctx["daily_cnt"].get(day, 0),
"last_exit_dt": ctx["last_exit_dt"].get(day),
}
if mode == "momentum":
if idx < 5:
continue
reject, _msg, sig = _eval_momentum_buy_at_index(
candles, idx, eval_params, state,
)
else:
st = _apply_buy_state_filters(candles, idx, eval_params, state)
if st[2] is None:
continue
reject, _msg, sig = _eval_scalp_buy_at_index(
candles, idx, eval_params, macd_combined=ctx.get("macd_combined"),
)
if reject or not sig:
continue
rsi = sig.get("rsi")
if rsi is None and mode == "reversal":
continue
if idx + 1 >= len(candles):
continue
next_c = candles[idx + 1]
if next_c["candle_time"][:8] != day:
continue
entry_price = float(next_c["open"])
if entry_price <= 0:
continue
stop = entry_price * (1 - sl_pct)
target = entry_price * (1 + tp_pct)
pri = _buy_priority_key(code, slot_key, universe_by_slot)
pe_data: Dict[str, Any] = {
"entry_time": next_c["candle_time"],
"entry_price": entry_price,
"stop": stop,
"target": target,
}
if rsi is not None:
pe_data["rsi"] = rsi
candidates.append((pri, code, pe_data))
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