#!/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())
