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

440 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
꼬리잡기 백테스트 공통 로더 — backtest_web(api/backtest/tail) 과 tail_param_search 가
동일한 캔들·유니버스·손익 계산을 쓰도록 단일 진입점.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
import kis_trader.engine.tail_engine as te
from kis_trader.backtest.backtest_portfolio_common import resolve_trigger_snapshots_for_backtest
from kis_trader.engine.indicator_cache import (
materialize_ws_candles_batch,
ws_candles_select_indicator_cols,
)
TAIL_STRATEGY_ID = "SHORT"
VALID_TIMEFRAMES = (3, 5, 15, 60)
def tail_backtest_universe_scan_at_enabled(params: Optional[Dict[str, Any]] = None) -> bool:
"""백테 유니버스: 1분 슬롯 대신 초단위 스캔시각 타임라인 (기본 ON, 실매 정합).
실매 꼬리잡기는 봉 마감 시점의 조건검색 유니버스를 본다. 1분 슬롯(strict lag)은
편입을 최대 1분 늦춰 실매와 어긋난다. 초단위 타임라인은 그 봉 마감(HH:MM:59)
직전 최신 스냅샷을 그대로 써 실매 ``get_universe_at`` 와 정합.
끄려면 env TAIL_BACKTEST_UNIVERSE_SCAN_AT=0.
"""
if params is not None and params.get("backtest_universe_scan_at") is not None:
s = str(params.get("backtest_universe_scan_at")).strip().lower()
if s in ("1", "true", "t", "y", "yes", "on"):
return True
if s in ("0", "false", "f", "n", "no", "off", ""):
return False
from kis_trader.utils.env import get_env_bool
return get_env_bool("TAIL_BACKTEST_UNIVERSE_SCAN_AT", True)
def date_keys(start: str, end: str) -> Tuple[str, str, str, str]:
"""YYYY-MM-DD → candle_time 키 및 ymd."""
start_key = start.replace("-", "") + "0000"
end_key = end.replace("-", "") + "2359"
return start_key, end_key, start_key[:8], end_key[:8]
def resolve_tail_universe(
start_ymd: str,
end_ymd: str,
*,
use_saved_history: bool,
strategy_id: str = TAIL_STRATEGY_ID,
) -> Tuple[Optional[Dict[str, List[str]]], str, int, int]:
"""
backtest_web._resolve_backtest_universe(꼬리) 와 동일.
Returns:
(universe_by_slot, source_label, history_slot_count, scan_interval_min)
"""
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_tail_candles_by_code(
db,
start_key: str,
end_key: str,
timeframe: int,
rsi_period: int = 14,
) -> Tuple[Dict[str, List[Dict]], int, bool]:
"""
ws_candles 전 종목 로드 (backtest_web api/backtest/tail 과 동일 쿼리).
유니버스 필터는 엔진 run_tail_backtest 에서 슬롯별 적용.
"""
tail_tf = int(timeframe)
if tail_tf not in VALID_TIMEFRAMES:
raise ValueError(f"timeframe 은 {VALID_TIMEFRAMES} 중 하나여야 합니다")
has_holding_peak = False
try:
wc_cols = db.conn.get_columns("ws_candles")
has_holding_peak = "holding_peak" in wc_cols
except Exception:
has_holding_peak = False
peak_sel = ", holding_peak" if has_holding_peak else ""
ind_cols = ws_candles_select_indicator_cols(db)
codes_raw = db.conn.execute(
"SELECT DISTINCT code FROM ws_candles WHERE timeframe=%s "
"AND candle_time >= %s AND candle_time <= %s ORDER BY code",
[tail_tf, start_key, end_key],
).fetchall()
codes = [r["code"] for r in codes_raw]
candles_by_code: Dict[str, List[Dict]] = {}
total_candles = 0
min_bars = int(rsi_period) + 5
for code in codes:
rows = db.conn.execute(
f"SELECT candle_time, open, high, low, close, volume{peak_sel}{ind_cols} "
"FROM ws_candles WHERE timeframe=%s AND code=%s "
"AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1 "
"ORDER BY candle_time ASC",
[tail_tf, 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)
materialize_ws_candles_batch(db, candles_by_code, tail_tf)
return candles_by_code, total_candles, has_holding_peak
def _t2dt(candle_time: str) -> datetime:
s = str(candle_time)
return datetime.strptime(s[:12], "%Y%m%d%H%M")
def attach_tail_trade_pnl(
trades: List[Dict],
*,
slot_money: float,
fee_rate: float,
sell_tax: float,
slip_pct: float = 0.0,
entry_already_slipped: bool = False,
) -> None:
"""backtest_web api/backtest/tail 손익·보유시간 계산과 동일.
slip_pct: 백테 체결 슬리피지(편도 %, 실매 호가 밀림 근사). 표시용 entry/exit 은
그대로 두고 손익(pnl)에만 반영한다. 청산가는 항상 불리(-) 적용, 진입가는
``entry_already_slipped=False`` (align — 다음봉 시가 시장가)일 때만 불리(+).
limit_atr 진입은 체결 단계(limit_entry_common.try_limit_fill_on_bar)에서
이미 슬립이 반영되므로 ``entry_already_slipped=True`` 로 이중 적용을 막는다.
"""
slip = max(0.0, float(slip_pct or 0.0)) / 100.0
for t in trades:
qty = t.get("qty")
if qty is None:
qty = max(1, int(slot_money / max(1, t["entry"])))
ep = float(t["entry"])
xp = float(t["exit"])
if slip > 0:
if not entry_already_slipped:
ep = ep * (1.0 + slip) # 매수 체결 불리 (실매 호가 밀림)
xp = xp * (1.0 - slip) # 매도 체결 불리
fee = (ep + xp) * qty * fee_rate
tax = xp * qty * sell_tax
t["pnl"] = round((xp - ep) * qty - fee - tax)
t["hold_min"] = round(
(_t2dt(t["exit_time"]) - _t2dt(t["entry_time"])).total_seconds() / 60, 1,
)
def fee_and_slot_from_env_row(row: Optional[Dict[str, Any]]) -> Tuple[float, float, float]:
"""env_config 1행 → (fee_rate, sell_tax, slot_money) — backtest_web 과 동일."""
if not row:
return 0.015 / 100, 0.18 / 100, 3_000_000.0
r = dict(row)
fee_rate = float(r.get("FEE_RATE_PCT") or 0.015) / 100
sell_tax = float(r.get("SELL_TAX_RATE_PCT") or 0.18) / 100
slot_money = float(
r.get("SLOT_MONEY_DEFAULT") or r.get("MAX_BUY_AMOUNT_PER_STOCK") or 3_000_000
)
return fee_rate, sell_tax, slot_money
def resolve_tail_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]:
"""
웹 api/backtest/tail 과 동일 — 1회투자·동시보유·총한도 해석.
total_budget_krw ≤ 0 이면 max_stocks × slot_money.
"""
r = dict(env_row) if env_row else {}
d = dict(base_defaults) if base_defaults else {}
slot = float(slot_money) if slot_money is not None else float(
r.get("TAIL_SLOT_MONEY") or d.get("slot_money") or 3_000_000
)
mxs = int(max_stocks) if max_stocks is not None else int(
r.get("TAIL_MAX_STOCKS") or d.get("max_stocks") or 3
)
tb_raw = total_budget_krw
if tb_raw is None:
tb_raw = float(r.get("TAIL_TOTAL_BUDGET_KRW") or d.get("total_budget_krw") or 0)
total_budget = float(tb_raw)
if total_budget <= 0:
total_budget = float(mxs * slot)
budget_warning = None
if total_budget < mxs * slot * 0.95:
budget_warning = (
f"총한도 {total_budget:,.0f}원 < 동시{mxs}×1회투자 "
f"{mxs * slot:,.0f}원 — 잔여금 소액매수·과다 회전 위험. "
"실매 정렬: 총한도↑ 또는 동시보유↓"
)
short_max_buy = int(float(
r.get("TAIL_MAX_BUY_AMOUNT") or d.get("short_max_buy_amount") or 0
))
return {
"slot_money": slot,
"max_stocks": mxs,
"total_budget_krw": total_budget,
"short_max_buy_amount": short_max_buy,
"portfolio_mode": True,
"budget_warning": budget_warning,
}
def merge_tail_portfolio_into_params(
params: Dict[str, Any],
portfolio: Dict[str, Any],
) -> Dict[str, Any]:
"""엔진 params에 포트폴리오 필드 병합 (in-place + 반환)."""
params["slot_money"] = float(portfolio["slot_money"])
params["max_stocks"] = int(portfolio["max_stocks"])
params["total_budget_krw"] = float(portfolio["total_budget_krw"])
params.setdefault("portfolio_mode", True)
sb = int(portfolio.get("short_max_buy_amount") or 0)
if sb > 0:
params["short_max_buy_amount"] = sb
return params
def build_tail_budget_warning(
portfolio: Dict[str, Any],
skip_stats: Optional[Dict[str, Any]] = None,
*,
min_invest_ratio: float = 0.9,
) -> Optional[str]:
"""웹 summary.budget_warning 과 동일 조립."""
msg = portfolio.get("budget_warning")
skip_stats = skip_stats or {}
skipped_micro = int(skip_stats.get("skipped_micro_buys") or 0)
if skipped_micro > 0:
micro_note = f"소액매수 스킵 {skipped_micro}건 (slot {min_invest_ratio * 100:.0f}% 미만)"
msg = f"{msg} | {micro_note}" if msg else micro_note
return msg
def summarize_tail_trades(
trades: List[Dict],
*,
total_budget_krw: float,
period_days: int = 1,
) -> Dict[str, Any]:
"""웹 꼬리 summary 핵심 지표 — 파라서치 결과 JSON용."""
total = len(trades)
wins = [t for t in trades if t.get("pnl", 0) > 0]
losses = [t for t in trades if t.get("pnl", 0) <= 0]
total_pnl = sum(t.get("pnl", 0) for t in trades)
win_pnl = sum(t["pnl"] for t in wins)
loss_pnl = sum(t["pnl"] for t in losses)
win_rate = round(len(wins) / total * 100, 2) if total else 0.0
pf = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0
bot_pct = round(total_pnl / total_budget_krw * 100, 2) if total_budget_krw > 0 else 0.0
days = max(1, int(period_days))
daily_avg_pct = round(bot_pct / days, 3) if days > 0 else 0.0
hold_vals = [t.get("hold_min") for t in trades if t.get("hold_min") is not None]
avg_hold = round(sum(hold_vals) / len(hold_vals), 1) if hold_vals else 0.0
return {
"total_trades": total,
"wins": len(wins),
"losses": len(losses),
"win_rate": win_rate,
"total_pnl": int(round(total_pnl)),
"pf": pf,
"bot_pct": bot_pct,
"daily_avg_pct": daily_avg_pct,
"avg_hold_min": avg_hold,
}
def run_tail_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,
ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None,
orderbook_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None,
program_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None,
meta_out: Optional[Dict[str, Any]] = None,
) -> List[Dict]:
"""엔진 1회 + 웹과 동일 손익 부착 (ws_ticks 리플레이 옵션)."""
from kis_trader.engine.tail_tick_replay import tail_backtest_wants_tick_replay
from kis_trader.backtest.tail_tick_loader import load_tail_ticks_by_code, tick_coverage_stats
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)
# ── 초단위 유니버스 타임라인 (실매 get_universe_at 정합, 돌파·모멘텀 공통) ──────
# 1분 슬롯(strict lag)의 "편입 +최대 1분 지연" 을 제거. 봉 마감(HH:MM:59) 직전
# 최신 조건검색 스냅샷을 그대로 조회해 실매와 동일 시점 유니버스로 매수 판정.
if universe_by_slot is not None and tail_backtest_universe_scan_at_enabled(engine_params):
_sk = str((meta_out or {}).get("start_key") or "")
_ek = str((meta_out or {}).get("end_key") or "")
if len(_sk) < 8 or len(_ek) < 8:
# meta_out 키 없으면 캔들 시각 min/max 일자로 폴백
_days = [
str(c.get("candle_time") or "")[:8]
for rows in candles_by_code.values() for c in rows
if c.get("candle_time")
]
if _days:
_sk, _ek = min(_days), max(_days)
if len(_sk) >= 8 and len(_ek) >= 8:
from kis_trader.backtest.universe_timeline import build_universe_timeline
_tl = build_universe_timeline(
strategy_id=TAIL_STRATEGY_ID,
start_ymd=_sk[:8], end_ymd=_ek[:8],
debounce_sec=0, strict=False, strict_lag_minutes=0,
)
if _tl is not None:
engine_params["_universe_timeline"] = _tl
if meta_out is not None:
meta_out["universe_timing"] = "scan_at"
meta_out["universe_timeline_snapshots"] = _tl.snapshot_count
# ── 체결 검증 게이트 (STRICT_FILL_VERIFY) ──────────────────────────────
# 백테 체결모델(슬리피지·체결량 상한)은 '엄격 체결 검증'이 켜졌을 때만 적용한다.
# 실매: 실전은 항상 fill 확인 / 모의는 STRICT_FILL_VERIFY=true 시 동일하게 확인.
# 백테도 같은 토글로 묶어 → OFF=순수 이론 체결(슬립0·100%체결), ON=실매 근사.
# params 우선(웹 1회용 오버라이드) → env(DB) 폴백.
_strict = engine_params.get("strict_fill_verify")
if _strict is None:
from kis_trader.utils.env import get_env_bool
_strict = get_env_bool("STRICT_FILL_VERIFY", False)
if not bool(_strict):
engine_params["limit_fill_slip_pct"] = 0.0
engine_params["backtest_vol_fill_cap_pct"] = 0.0
elif not float(engine_params.get("backtest_vol_fill_cap_pct") or 0):
# vol_cap(체결량 상한)은 공통키 우선 — 전 전략 동일 소스. TAIL_ 폴백값이
# 비어/0 이면 글로벌 BACKTEST_VOL_FILL_CAP_PCT 를 사용(웹 운영설정에서 조절).
from kis_trader.utils.env import get_env_float
engine_params["backtest_vol_fill_cap_pct"] = get_env_float(
"BACKTEST_VOL_FILL_CAP_PCT", 0.0,
)
loaded_ticks: Dict[str, Dict[str, List[Dict]]] = dict(ticks_by_code or {})
tick_meta: Dict[str, Any] = {}
if tail_backtest_wants_tick_replay(engine_params):
if not loaded_ticks and meta_out is not None:
start_key = str(meta_out.get("start_key") or "")
end_key = str(meta_out.get("end_key") or "")
db = meta_out.get("db")
if db and start_key and end_key:
loaded_ticks, tick_rows = load_tail_ticks_by_code(
db, start_key, end_key, set(candles_by_code.keys()),
)
tick_meta = tick_coverage_stats(candles_by_code, loaded_ticks)
tick_meta["ws_tick_rows_loaded"] = tick_rows
elif loaded_ticks:
tick_meta = tick_coverage_stats(candles_by_code, loaded_ticks)
tick_meta["ws_tick_rows_loaded"] = sum(
len(lst) for cm in loaded_ticks.values() for lst in cm.values()
)
ob_loaded, pg_loaded, snap_meta = resolve_trigger_snapshots_for_backtest(
candles_by_code, engine_params, strategy="TAIL", meta_out=meta_out,
orderbook_by_code=orderbook_by_code, program_by_code=program_by_code,
)
if snap_meta.get("log_verdict_by_code"):
engine_params["_backtest_log_verdict_by_code"] = snap_meta["log_verdict_by_code"]
trades = te.run_tail_backtest(
candles_by_code,
engine_params,
universe_by_slot=universe_by_slot,
ticks_by_code=loaded_ticks or None,
orderbook_by_code=ob_loaded,
program_by_code=pg_loaded,
)
# 백테 슬리피지(편도 %) — BACKTEST_SLIP_PCT 공통키(전 전략 동일, STRICT 게이트는
# 헬퍼가 처리). align 은 진입+청산 양측 반영, limit_atr 진입은 try_limit_fill_on_bar
# 의 limit_fill_slip_pct 로 이미 반영돼 청산만 추가(entry_already_slipped 로 중복 차단).
from kis_trader.backtest.backtest_portfolio_common import backtest_slip_pct
_slip_pct = backtest_slip_pct(engine_params)
_entry_mode = str(engine_params.get("entry_mode") or "align").strip().lower()
attach_tail_trade_pnl(
trades, slot_money=slot_money, fee_rate=fee_rate, sell_tax=sell_tax,
slip_pct=_slip_pct,
entry_already_slipped=(_entry_mode == "limit_atr"),
)
# 당일 실현손익 고정/트레일 익절 시뮬 (실매 daily_profit_halt 동일 판정).
# 게이트 OFF 기본 → 기존 동작 불변. 파람서치 trail 축 있으면 자동 ON.
from kis_trader.backtest.backtest_portfolio_common import apply_daily_profit_halt_sim
trades = apply_daily_profit_halt_sim(
trades, engine_params, budget_krw=float(total_budget_krw or 0),
)
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
if tick_meta:
meta_out["tick_backtest"] = tick_meta
if int(tick_meta.get("ws_tick_rows_loaded") or 0) > 0:
meta_out["backtest_buy_source"] = "ws_ticks"
elif tail_backtest_wants_tick_replay(engine_params):
meta_out["backtest_buy_source"] = "ohlc_fallback"
if snap_meta:
meta_out["trigger_snapshot_backtest"] = snap_meta
return trades