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

568 lines
21 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
"""
모멘텀 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())