#!/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, load_portfolio_env_row, 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) if "skip_hts_scan_dupes" not in eval_params: from kis_trader.engine.momentum_hts_logic import resolve_momentum_skip_hts_scan_dupes eval_params["skip_hts_scan_dupes"] = resolve_momentum_skip_hts_scan_dupes() 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)) env_row = load_portfolio_env_row() 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())