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:
2026-07-06 01:27:00 +09:00
parent d8ba01afa4
commit 61c72a8a4c
171 changed files with 176914 additions and 7329 deletions

View File

@@ -0,0 +1,439 @@
#!/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