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,567 @@
#!/usr/bin/env python3
"""
모멘텀 vs 무작위 진입 벤치마크 — param_search 와 동일 데이터·포트폴리오·청산.
청산: ``check_sell_signal_momentum_backtest_bar`` (실매 ``check_sell_signal_momentum_live`` 동일).
무작위: 유니버스(MOMENTUM history) + 매매시간 + 쿨다운·일일한도만 맞추고,
TRIGGER(RSI·vol·EMA) 없이 슬롯마다 후보 1종목 무작위 선택.
실행 (기본: 백그라운드 — nohup 불필요):
cd /home/hoon/kis_bot
python3 -m kis_trader.backtest.momentum_random_benchmark \\
--start 2026-06-01 --end 2026-06-14 --seeds 100
# 포그라운드(터미널 붙잡기)가 필요할 때만:
python3 -m kis_trader.backtest.momentum_random_benchmark --foreground --seeds 10
로그: /tmp/mom_random_bench.log (기본) · PID: /tmp/mom_random_bench.pid
"""
from __future__ import annotations
import argparse
import json
import os
import random
import subprocess
import sys
import time
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
_BG_WORKER_ENV = "MOM_RANDOM_BENCH_WORKER"
DEFAULT_LOG_PATH = "/tmp/mom_random_bench.log"
DEFAULT_PID_PATH = "/tmp/mom_random_bench.pid"
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from database import TradeDB # noqa: E402
from kis_trader.backtest import momentum_backtest_common as mbc # noqa: E402
from kis_trader.backtest import scalping_backtest_common as sbc # noqa: E402
from kis_trader.backtest.backtest_portfolio_common import ( # noqa: E402
attach_scalp_trade_pnl,
backtest_slip_pct,
min_invest_ratio_of_slot,
portfolio_exposure_krw,
target_qty_and_cost,
)
from kis_trader.backtest.momentum_portfolio_backtest import ( # noqa: E402
_buy_priority_key,
_max_stocks_from_params,
_resolve_invest_cap_krw,
_total_budget_from_params,
)
from kis_trader.backtest.param_search_momentum import ( # noqa: E402
_load_candles_for_search,
_mom_fixed_defaults,
_ui_to_engine_params,
)
from kis_trader.engine.momentum_engine import ( # noqa: E402
MOMENTUM_STRATEGY_ID,
_slot_key,
_t2dt,
_to_bool,
check_sell_signal_momentum_backtest_bar,
effective_tp_pct_from_params,
eval_momentum_buy_at_index,
)
def _hm_from_candle_time(t: str) -> int:
s = str(t)[8:12]
return int(s) if len(s) >= 4 else 0
def _session_ok(t: str, params: Dict[str, Any]) -> bool:
hm = _hm_from_candle_time(t)
ts = int(params.get("time_start_hm", 900))
te = int(params.get("time_end_hm", 1530))
return ts <= hm < te
def _cooldown_ok(t: str, day: str, last_exit_dt, cooldown_min: float) -> bool:
if not last_exit_dt:
return True
try:
from datetime import datetime as _dt
cur = _dt.strptime(t, "%Y%m%d%H%M%S")
last = last_exit_dt if hasattr(last_exit_dt, "year") else _t2dt(str(last_exit_dt))
elapsed = (cur - last).total_seconds() / 60.0
return elapsed >= float(cooldown_min)
except Exception:
return True
def run_portfolio(
codes_candles: Dict[str, List[Dict]],
params: Dict[str, Any],
universe_by_slot: Optional[Dict[str, List[str]]],
*,
random_seed: Optional[int] = None,
) -> List[Dict]:
"""시각순 포트폴리오 — random_seed 있으면 무작위 진입."""
rng = random.Random(random_seed) if random_seed is not None else None
rsi_period = int(params.get("rsi_period", 3))
min_bars = max(rsi_period + 5, 6)
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=MOMENTUM_STRATEGY_ID)
invest_cap = _resolve_invest_cap_krw(params, slot_money)
cooldown_min = float(params.get("cooldown_min", 10))
max_daily = int(params.get("max_daily", 5))
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]
ctx_by_code[code] = {
"code": code,
"candles": candles,
"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:
if not _session_ok(t, params):
continue
slot_key = _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"])
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:
continue
invest = min(invest_cap, remaining, target_cost)
qty = int(invest / entry_price)
if qty < 1:
continue
cost = qty * entry_price
if cost < min_required or exposure + cost > total_budget + 1e-6:
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
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]
if t == portfolio[code]["entry_time"]:
continue
is_eod_raw = (idx == len(candles) - 1) or (candles[idx + 1]["candle_time"][:8] != day)
is_eod = is_eod_raw and force_eod_exit
cur_c_info = {
"open": float(c["open"]),
"high": float(c["high"]),
"low": float(c["low"]),
"close": float(c["close"]),
"candle_time": t,
}
pos = portfolio[code]
res = check_sell_signal_momentum_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,
"strategy": MOMENTUM_STRATEGY_ID,
}
all_trades.append(trade)
ctx["last_exit_dt"][day] = _t2dt(t)
del portfolio[code]
if len(portfolio) >= max_stocks:
continue
if portfolio_exposure_krw(portfolio) >= 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 and code not in universe_by_slot.get(slot_key, []):
continue
if cl <= 0 or idx < 5:
continue
if ctx["daily_cnt"].get(day, 0) >= max_daily:
continue
if not _cooldown_ok(t, day, ctx["last_exit_dt"].get(day), cooldown_min):
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
if rng is None:
eval_params = dict(params)
eval_params.setdefault("skip_hts_scan_dupes", universe_by_slot is not None)
state = {
"daily_cnt": ctx["daily_cnt"].get(day, 0),
"last_exit_dt": ctx["last_exit_dt"].get(day),
}
reject, _msg, sig = eval_momentum_buy_at_index(candles, idx, eval_params, state)
if reject or not sig:
continue
pe_data: Dict[str, Any] = {
"entry_time": next_c["candle_time"],
"entry_price": entry_price,
"stop": entry_price * (1 - sl_pct),
"target": entry_price * (1 + tp_pct),
"rsi": sig.get("rsi"),
}
else:
pe_data = {
"entry_time": next_c["candle_time"],
"entry_price": entry_price,
"stop": entry_price * (1 - sl_pct),
"target": entry_price * (1 + tp_pct),
"rsi": None,
}
candidates.append((_buy_priority_key(code, slot_key, universe_by_slot), code, pe_data))
if not candidates:
continue
if rng is not None:
_pri, pick_code, pe = rng.choice(candidates)
else:
candidates.sort(key=lambda x: x[0])
_pri, pick_code, pe = candidates[0]
ctx_by_code[pick_code]["pending_entry"] = pe
ctx_by_code[pick_code]["daily_cnt"][t[:8]] = (
ctx_by_code[pick_code]["daily_cnt"].get(t[:8], 0) + 1
)
fee_rate = float(params.get("fee_rate", 0.00015))
sell_tax = float(params.get("sell_tax", 0.0018))
attach_scalp_trade_pnl(
all_trades, fee_rate=fee_rate, sell_tax=sell_tax,
slip_pct=backtest_slip_pct(params),
)
return all_trades
def _build_engine_params(ui: Dict[str, Any], fixed: Dict[str, Any]) -> Dict[str, Any]:
merged = dict(fixed)
merged.update(ui)
return _ui_to_engine_params(merged)
def _load_universe(start: str, end: str) -> Optional[Dict[str, List[str]]]:
start_ymd = start.replace("-", "")
end_ymd = end.replace("-", "")
try:
universe, _, _, _, _ = mbc.resolve_momentum_universe(
start_ymd, end_ymd, use_saved_history=True, strategy_id="MOMENTUM",
)
return universe
except Exception:
return None
def _stats(trades: List[Dict], total_budget: float, period_days: int) -> Dict[str, Any]:
return mbc.summarize_momentum_trades(
trades, total_budget_krw=total_budget, period_days=period_days,
)
def _print_row(label: str, st: Dict[str, Any]) -> None:
print(
f" {label:<22} | 손익 {st['total_pnl']:>10,.0f}원 | "
f"거래 {st['total_trades']:>3} | 승률 {st['win_rate']:>5.1f}% | "
f"PF {st['pf']:>5.2f} | MDD {st.get('mdd_krw', st.get('mdd', 0)):,.0f}",
flush=True,
)
def _read_running_pid(pid_path: str) -> Optional[int]:
try:
with open(pid_path, "r", encoding="utf-8") as f:
pid = int(f.read().strip())
os.kill(pid, 0)
return pid
except (OSError, ValueError, ProcessLookupError):
return None
def _write_pid(pid_path: str) -> None:
with open(pid_path, "w", encoding="utf-8") as f:
f.write(str(os.getpid()))
def _clear_pid(pid_path: str) -> None:
try:
if _read_running_pid(pid_path) == os.getpid():
os.remove(pid_path)
except OSError:
pass
def _spawn_background(log_path: str, pid_path: str) -> int:
"""부모는 즉시 반환 — 워커는 detached 세션에서 로그 파일로 출력."""
running = _read_running_pid(pid_path)
if running:
print(f"⛔ 이미 실행 중 (pid={running})", flush=True)
print(f" tail -f {log_path}", flush=True)
return 2
os.makedirs(os.path.dirname(log_path) or ".", exist_ok=True)
log_f = open(log_path, "a", encoding="utf-8")
stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_f.write(f"\n[{stamp}] 백그라운드 워커 시작\n")
log_f.flush()
child_argv = [sys.executable, "-u"] + sys.argv[1:]
if "--foreground" not in child_argv:
child_argv.append("--foreground")
env = os.environ.copy()
env[_BG_WORKER_ENV] = "1"
proc = subprocess.Popen(
child_argv,
stdin=subprocess.DEVNULL,
stdout=log_f,
stderr=subprocess.STDOUT,
cwd=ROOT,
env=env,
start_new_session=True,
)
log_f.close()
try:
with open(pid_path, "w", encoding="utf-8") as pf:
pf.write(str(proc.pid))
except OSError:
pass
print(f"✅ 백그라운드 시작 pid={proc.pid}", flush=True)
print(f" 로그: {log_path}", flush=True)
print(f" 확인: tail -f {log_path}", flush=True)
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="모멘텀 vs 무작위 진입 벤치마크")
parser.add_argument("--start", default="2026-06-01")
parser.add_argument("--end", default="2026-06-14")
parser.add_argument("--seeds", type=int, default=100, help="무작위 시드 반복 횟수")
parser.add_argument("--json-rank1", default="", help="search_momentum JSON (1위 params)")
parser.add_argument(
"--foreground", action="store_true",
help="포그라운드 실행 (기본: 백그라운드)",
)
parser.add_argument("--log", default=DEFAULT_LOG_PATH, help="백그라운드 로그 경로")
parser.add_argument("--pid-file", default=DEFAULT_PID_PATH, help="실행 중 PID 파일")
args = parser.parse_args()
is_worker = os.environ.get(_BG_WORKER_ENV) == "1" or args.foreground
if not is_worker:
return _spawn_background(args.log, args.pid_file)
_write_pid(args.pid_file)
try:
return _run_benchmark(args)
finally:
_clear_pid(args.pid_file)
def _run_benchmark(args: argparse.Namespace) -> int:
t0 = time.time()
fixed = _mom_fixed_defaults()
rsi_period = int(fixed.get("rsi_period", 3))
db = TradeDB()
try:
row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone()
env_row = dict(row) if row else {}
finally:
db.close()
fee_rate, sell_tax, slot_from_env = sbc.fee_and_slot_from_env(env_row, strategy="MOMENTUM")
portfolio = sbc.resolve_scalp_portfolio_params(
env_row, None, strategy="MOMENTUM", slot_money=slot_from_env,
)
slot_money = float(portfolio["slot_money"])
max_stocks = int(portfolio["max_stocks"])
total_budget = float(portfolio["total_budget_krw"])
period_days = max(
1,
(datetime.strptime(args.end, "%Y-%m-%d") - datetime.strptime(args.start, "%Y-%m-%d")).days + 1,
)
json_path = args.json_rank1
if not json_path:
json_path = os.path.join(
HERE, "results", "search_momentum_fast_20260615_012529.json",
)
with open(json_path, "r", encoding="utf-8") as f:
search_data = json.load(f)
rank1_ui = dict((search_data.get("top") or [{}])[0].get("params") or {})
rank1_engine = _build_engine_params(rank1_ui, fixed)
rank1_engine["slot_money"] = slot_money
rank1_engine["max_stocks"] = max_stocks
rank1_engine["total_budget_krw"] = total_budget
rank1_engine["fee_rate"] = fee_rate
rank1_engine["sell_tax"] = sell_tax
db_engine = _build_engine_params({}, fixed)
db_engine["slot_money"] = slot_money
db_engine["max_stocks"] = max_stocks
db_engine["total_budget_krw"] = total_budget
db_engine["fee_rate"] = fee_rate
db_engine["sell_tax"] = sell_tax
print("=" * 72, flush=True)
print(f"모멘텀 벤치마크 {args.start} ~ {args.end} ({period_days}일)", flush=True)
print(
f"포트폴리오: 슬롯 {slot_money:,.0f} | 동시 {max_stocks} | 한도 {total_budget:,.0f} | "
f"수수료 {fee_rate*100:.4f}% + 세 {sell_tax*100:.2f}%",
flush=True,
)
print("=" * 72, flush=True)
print("⏳ 캔들 로드...", flush=True)
candles = _load_candles_for_search(args.start, args.end, rsi_period)
print(f"{len(candles):,}종목", flush=True)
universe = _load_universe(args.start, args.end)
if universe:
avg = sum(len(v) for v in universe.values()) / max(1, len(universe))
print(f"✅ 유니버스: MOMENTUM history | {len(universe):,}슬롯 · 평균 {avg:.1f}", flush=True)
else:
print("⚠️ 유니버스 이력 없음 — 전종목", flush=True)
print("\n[1] 탐색 1위 로직 (fast grid rank1)", flush=True)
print(f" params: vol×{rank1_ui.get('mom_vol_mult')} RSI {rank1_ui.get('mom_rsi_min')}~{rank1_ui.get('mom_rsi_max')} "
f"EMA {'ON' if rank1_ui.get('use_ema_filter') else 'OFF'} "
f"{rank1_ui.get('ema_fast_period')}/{rank1_ui.get('ema_slow_period')}", flush=True)
t1 = run_portfolio(candles, rank1_engine, universe, random_seed=None)
st1 = _stats(t1, total_budget, period_days)
_print_row("탐색1위 로직", st1)
print("\n[2] 현재 DB 실매 설정", flush=True)
t2 = run_portfolio(candles, db_engine, universe, random_seed=None)
st2 = _stats(t2, total_budget, period_days)
_print_row("DB 실매", st2)
print(f"\n[3] 무작위 진입 × {args.seeds}회 (동일 유니버스·청산·포트폴리오)", flush=True)
pnls: List[float] = []
trades_n: List[int] = []
win_rates: List[float] = []
pfs: List[float] = []
for seed in range(1, args.seeds + 1):
tr = run_portfolio(candles, rank1_engine, universe, random_seed=seed)
st = _stats(tr, total_budget, period_days)
pnls.append(float(st["total_pnl"]))
trades_n.append(int(st["total_trades"]))
win_rates.append(float(st["win_rate"]))
pfs.append(float(st["pf"]))
if seed % 25 == 0:
print(f" ... seed {seed}/{args.seeds}", flush=True)
import statistics
avg_pnl = statistics.mean(pnls)
med_pnl = statistics.median(pnls)
avg_pf = statistics.mean(pfs)
avg_wr = statistics.mean(win_rates)
avg_tr = statistics.mean(trades_n)
beat = sum(1 for p in pnls if p > st1["total_pnl"])
beat_db = sum(1 for p in pnls if p > st2["total_pnl"])
print(f"\n{'=' * 72}", flush=True)
print("📊 요약", flush=True)
_print_row("탐색1위 로직", st1)
_print_row("DB 실매", st2)
print(
f" {'무작위(평균)':<22} | 손익 {avg_pnl:>10,.0f}원 | "
f"거래 {avg_tr:>5.0f} | 승률 {avg_wr:>5.1f}% | PF {avg_pf:>5.2f}",
flush=True,
)
print(
f" {'무작위(중앙값)':<22} | 손익 {med_pnl:>10,.0f}원 | "
f"min {min(pnls):,.0f} max {max(pnls):,.0f}",
flush=True,
)
print(
f"\n 무작위 {args.seeds}회 중 탐색1위보다 나은 비율: {beat}/{args.seeds} ({100*beat/args.seeds:.0f}%)",
flush=True,
)
print(
f" 무작위 {args.seeds}회 중 DB실매보다 나은 비율: {beat_db}/{args.seeds} ({100*beat_db/args.seeds:.0f}%)",
flush=True,
)
if st1["total_pnl"] <= avg_pnl:
print("\n ⚠️ 탐색1위 로직 ≤ 무작위 평균 → TRIGGER 엣지 없음 (운/노이즈 수준)", flush=True)
else:
print(f"\n ✅ 탐색1위가 무작위 평균 대비 {st1['total_pnl']-avg_pnl:+,.0f}", flush=True)
print(f"\n⏱ 총 {time.time()-t0:.0f}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())