#!/usr/bin/env python3
"""
모멘텀 백테 ws_ticks 리플레이 — 실매 MomentumStrategy 진입·청산 정렬.

- 진입: live_align 신호봉(T-1) → 진입봉(T) 첫 틱/시가
- 청산: 1~2초 폴링 근사로 ``check_sell_signal_momentum_live`` → **틱 체결가**
"""
from __future__ import annotations

from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple

from kis_trader.engine.momentum_engine import check_sell_signal_momentum_live
from kis_trader.utils.env import get_env_bool, get_env_float, get_env_int


def _param_bool(
    params: Optional[Dict[str, Any]],
    param_key: str,
    env_key: str,
    default: bool,
) -> bool:
    if params is not None and params.get(param_key) is not None:
        s = str(params.get(param_key)).strip().lower()
        if s in ("1", "true", "t", "y", "yes", "on"):
            return True
        if s in ("0", "false", "f", "n", "no", "off", ""):
            return False
    return get_env_bool(env_key, default)


def momentum_backtest_use_tick_exit(params: Optional[Dict[str, Any]] = None) -> bool:
    """백테 청산에 ws_ticks 사용 (기본 ON — 실매 체결 정합).

    실매 청산은 초단위 실제 틱(ws_ticks)으로 체결된다. 1분봉 OHLC 경로
    (open→high→low→close)는 "고가 먼저" 낙관 가정이라 어깨컷·트레일·익절을
    실제보다 유리하게 체결해 백테 손익을 부풀린다(검증: 2026-07-03 OHLC 순위
    전 조합 흑자 → 틱 순위 전 조합 적자, 실매 -48,742 정합). 그래서 파람서치·웹
    백테 모두 기본 틱재생으로 실매 정합 순위를 낸다.
    끄려면 params 또는 env MOMENTUM_BACKTEST_USE_TICK_EXIT=0.
    """
    return _param_bool(
        params, "backtest_use_tick_exit", "MOMENTUM_BACKTEST_USE_TICK_EXIT", True,
    )


def momentum_backtest_tick_fallback_ohlc(params: Optional[Dict[str, Any]] = None) -> bool:
    """해당 분 틱 없을 때 1분봉 OHLC intrabar 폴백."""
    return _param_bool(
        params, "backtest_tick_fallback_ohlc", "MOMENTUM_BACKTEST_TICK_FALLBACK_OHLC", True,
    )


def momentum_backtest_tick_only_codes(params: Optional[Dict[str, Any]] = None) -> bool:
    """틱재생 시 **틱 데이터가 전혀 없는 종목을 백테/파람서치에서 제외** (기본 ON).

    틱 전무 종목은 대부분 개장 직후(09:00~09:02) 조건검색에 잠깐 떴다 빠진
    순간 후보로, 유니버스 존속이 2분 남짓이라 실제 매매로 이어질 수 없다.
    이런 종목을 남겨두면 OHLC 폴백으로 유령 거래를 만들어 손익을 왜곡한다.
    → 틱 있는 종목만 신뢰. 끄려면 env MOMENTUM_BACKTEST_TICK_ONLY_CODES=0.
    """
    return _param_bool(
        params, "backtest_tick_only_codes", "MOMENTUM_BACKTEST_TICK_ONLY_CODES", True,
    )


def momentum_backtest_wants_tick_replay(
    params: Optional[Dict[str, Any]] = None,
    ticks_by_code: Optional[Dict[str, Any]] = None,
) -> bool:
    if not ticks_by_code:
        return False
    return momentum_backtest_use_tick_exit(params) or momentum_backtest_use_tick_entry(params)


def momentum_backtest_use_tick_entry(params: Optional[Dict[str, Any]] = None) -> bool:
    """백테 진입에 ws_ticks 첫 체결가 사용 (기본 ON — 실매 체결 정합).

    위 ``momentum_backtest_use_tick_exit`` 과 동일 취지 — 실매는 진입봉(T) 첫 틱
    체결가로 매수하므로 백테도 틱 첫 체결가로 진입해 정합을 맞춘다.
    끄려면 params 또는 env MOMENTUM_BACKTEST_USE_TICK_ENTRY=0.
    """
    return _param_bool(
        params, "backtest_use_tick_entry", "MOMENTUM_BACKTEST_USE_TICK_ENTRY", True,
    )


def momentum_live_align_enabled(params: Optional[Dict[str, Any]] = None) -> bool:
    """실매·백테 동일: 신호봉(T-1) → 진입봉(T) 시가."""
    return _param_bool(
        params, "live_backtest_align", "MOMENTUM_LIVE_BACKTEST_ALIGN", True,
    )


def momentum_backtest_live_scan_queue_enabled(params: Optional[Dict[str, Any]] = None) -> bool:
    """백테 매수: 실매처럼 N초마다 후보 순회·1건 매수 (기본 ON)."""
    return _param_bool(
        params, "backtest_live_scan_queue", "MOMENTUM_BACKTEST_LIVE_SCAN_QUEUE", True,
    )


def momentum_backtest_scan_sec(params: Optional[Dict[str, Any]] = None) -> int:
    """실매 루프·조건검색 주기 근사(초). 기본 10초."""
    if params is not None and params.get("backtest_scan_sec") is not None:
        try:
            return max(1, int(float(params["backtest_scan_sec"])))
        except (TypeError, ValueError):
            pass
    return max(1, int(get_env_int("MOMENTUM_BACKTEST_SCAN_SEC", 10)))


def align_momentum_entry_from_ticks(
    ticks_by_code: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]],
    code: str,
    entry_bar_time: str,
    fallback_open: float,
    params: Optional[Dict[str, Any]] = None,
    *,
    min_tick_time: str = "",
) -> Tuple[float, str, str]:
    """
    진입봉 첫 유효 틱 체결가 (없으면 분봉 시가).

    Returns:
        (fill_price, entry_time_key, source) — source: ws_ticks | ohlc_open
    """
    p = params or {}
    fo = float(fallback_open or 0)
    bar_key = str(entry_bar_time or "")[:12]
    if not momentum_backtest_use_tick_entry(p) or not ticks_by_code:
        return fo, bar_key, "ohlc_open"
    ticks = collect_minute_ticks(ticks_by_code, code, bar_key)
    slip_pct = abs(float(get_env_float("MOMENTUM_BACKTEST_BUY_SLIP_PCT", 0.0)))
    min_tt = str(min_tick_time or "").strip()
    # 백테 틱재생: 컬럼 뷰면 dict 재구성 없이 배열 직접 읽기(첫 유효틱 즉시 반환 → 동일).
    try:
        from kis_trader.backtest.shared_ticks import TickColumnView
        _is_view = isinstance(ticks, TickColumnView)
    except Exception:
        _is_view = False
    if _is_view:
        owner = ticks.owner
        _price = owner._price
        _tick_time = owner._tick_time
        for i in ticks.iter_idx():
            tt = _tick_time[i].decode("utf-8")
            if min_tt and len(tt) >= 14 and tt[:14] < min_tt[:14]:
                continue
            px = float(_price[i])
            if px <= 0:
                continue
            fill_px = px * (1.0 + slip_pct / 100.0) if slip_pct > 0 else px
            et = tt[:14] if len(tt) >= 14 else bar_key
            return fill_px, et, "ws_ticks"
        return fo, bar_key, "ohlc_open"
    for tick in ticks:
        tt = str(tick.get("tick_time") or "")
        if min_tt and len(tt) >= 14 and tt[:14] < min_tt[:14]:
            continue
        px = float(tick.get("price") or 0)
        if px <= 0:
            continue
        fill_px = px * (1.0 + slip_pct / 100.0) if slip_pct > 0 else px
        tt = str(tick.get("tick_time") or "")
        et = tt[:14] if len(tt) >= 14 else bar_key
        return fill_px, et, "ws_ticks"
    return fo, bar_key, "ohlc_open"


def parse_backtest_time(t: str) -> datetime:
    """YYYYMMDDHHMM 또는 YYYYMMDDHHMMSS → datetime."""
    s = str(t or "").strip().replace("-", "").replace(":", "").replace(" ", "")
    if len(s) >= 14:
        return datetime.strptime(s[:14], "%Y%m%d%H%M%S")
    if len(s) >= 12:
        return datetime.strptime(s[:12], "%Y%m%d%H%M")
    raise ValueError("invalid backtest time: %r" % (t,))


def _tick_time_to_ms(tick_time: str) -> int:
    dt = parse_backtest_time(tick_time)
    return int(dt.timestamp() * 1000)


def collect_minute_ticks(
    ticks_by_code: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]],
    code: str,
    minute_key: str,
) -> List[Dict[str, Any]]:
    if not ticks_by_code:
        return []
    bucket = ticks_by_code.get(code) or {}
    # 백테 틱재생: 공유메모리 버킷이면 dict 리스트 대신 컬럼 뷰 반환.
    # 로더가 tick_time 오름차순으로 담아둔 저장순서 == 아래 정렬 결과라 순서 동일.
    try:
        from kis_trader.backtest.shared_ticks import SharedBucketMapping
        if isinstance(bucket, SharedBucketMapping):
            return bucket.column_view_minute(minute_key)
    except Exception:
        pass
    ticks = list(bucket.get(str(minute_key)[:12]) or [])
    ticks.sort(key=lambda x: str(x.get("tick_time") or ""))
    return ticks


def _try_momentum_sell_on_ticks_columnar(
    position: Dict[str, Any],
    view: Any,
    params: Dict[str, Any],
    *,
    is_eod: bool = False,
    entry_time: str = "",
) -> Optional[Tuple[str, float, str, float]]:
    """try_momentum_sell_on_ticks 의 컬럼 직접접근 판(백테 틱재생 전용).

    dict 재구성 없이 공유 컬럼 배열(epoch/price/tick_time)을 직접 읽는다. 로직·산출물은
    dict 판과 100% 동일하다:
      - dict판 ``len(tt)<12 or 파싱실패`` skip ↔ epoch(=사전계산)<=0 skip
      - dict판 ``tt[:12] < entry_key`` (분 문자열 비교) ↔ epoch < entry_key_epoch
        (entry_key 는 12자리 분→_tick_epoch_sec 가 :00 경계로 패딩하므로 동치)
      - dict판 ``_tick_time_to_ms(tt)`` ↔ epoch*1000 (12/14자리 모두 동일 값)
    max_price 갱신은 dict판과 동일하게 '유효 틱마다' 수행한다(폴링 게이트 이전).
    """
    if len(view) == 0:
        return None
    from kis_trader.engine.whipsaw_filter import _tick_epoch_sec

    owner = view.owner
    _epoch = owner._epoch
    _price = owner._price
    _tick_time = owner._tick_time

    poll_ms = max(200, int(get_env_int("MOMENTUM_BACKTEST_POLL_MS", 1500)))
    slip_pct = abs(float(get_env_float("MOMENTUM_BACKTEST_SELL_SLIP_PCT", 0.0)))
    entry_key = str(entry_time or "")[:12]
    entry_key_epoch = _tick_epoch_sec(entry_key) if entry_key else 0

    # entry_dt — dict판과 동일: entry_time 우선, 없으면 첫 틱 tick_time
    if entry_time:
        _entry_src = entry_time
    else:
        _fi = view.first_idx()
        _entry_src = _tick_time[_fi].decode("utf-8") if _fi >= 0 else entry_key
    try:
        entry_dt = parse_backtest_time(_entry_src)
    except ValueError:
        entry_dt = parse_backtest_time(entry_key)

    last_check_ms = -10**15
    n = len(view)
    idx = -1
    for i in view.iter_idx():
        idx += 1
        ts = int(_epoch[i])
        if ts <= 0:
            continue
        if entry_key and ts < entry_key_epoch:
            continue
        px = float(_price[i])
        if px <= 0:
            continue

        mp = max(float(position.get("max_price", position["entry_price"])), px)
        position["max_price"] = mp

        tick_ms = ts * 1000
        if tick_ms - last_check_ms < poll_ms:
            continue
        last_check_ms = tick_ms

        tt = _tick_time[i].decode("utf-8")
        candle = {
            "high": mp,
            "low": px,
            "close": px,
            "candle_time": tt[:12],
        }
        eod_here = bool(is_eod and idx == n - 1)
        res = check_sell_signal_momentum_live(position, candle, params, is_eod=eod_here)
        if not res:
            continue

        reason, _theoretical = res
        fill_px = px
        if slip_pct > 0:
            fill_px = px * (1.0 - slip_pct / 100.0)

        try:
            sell_dt = parse_backtest_time(tt)
        except ValueError:
            sell_dt = parse_backtest_time(tt[:12])
        hold_min = round((sell_dt - entry_dt).total_seconds() / 60.0, 1)
        sell_time = tt[:14] if len(tt) >= 14 else tt[:12]
        return reason, fill_px, sell_time, hold_min

    return None


def try_momentum_sell_on_ticks(
    position: Dict[str, Any],
    ticks: List[Dict[str, Any]],
    params: Dict[str, Any],
    *,
    is_eod: bool = False,
    entry_time: str = "",
) -> Optional[Tuple[str, float, str, float]]:
    """
    틱 시간순 청산 검사 (실매 폴링 간격 근사).

    Returns:
        (reason, fill_price, sell_time, hold_min) 또는 None
        sell_time: YYYYMMDDHHMMSS (가능 시) — hold_min 은 초 단위 반영
    """
    # 백테 틱재생: 공유메모리 컬럼 뷰면 직접집계(실매 dict 경로 무변경).
    try:
        from kis_trader.backtest.shared_ticks import TickColumnView
        if isinstance(ticks, TickColumnView):
            return _try_momentum_sell_on_ticks_columnar(
                position, ticks, params, is_eod=is_eod, entry_time=entry_time,
            )
    except Exception:
        pass

    if not ticks:
        return None

    poll_ms = max(200, int(get_env_int("MOMENTUM_BACKTEST_POLL_MS", 1500)))
    slip_pct = abs(float(get_env_float("MOMENTUM_BACKTEST_SELL_SLIP_PCT", 0.0)))
    entry_key = str(entry_time or "")[:12]
    try:
        entry_dt = parse_backtest_time(entry_time or ticks[0].get("tick_time", entry_key))
    except ValueError:
        entry_dt = parse_backtest_time(entry_key)

    last_check_ms = -10**15
    n = len(ticks)

    for idx, tick in enumerate(ticks):
        tt = str(tick.get("tick_time") or "")
        if len(tt) < 12:
            continue
        if tt[:12] < entry_key:
            continue

        try:
            tick_ms = _tick_time_to_ms(tt)
        except ValueError:
            continue

        px = float(tick.get("price") or 0)
        if px <= 0:
            continue

        mp = max(float(position.get("max_price", position["entry_price"])), px)
        position["max_price"] = mp

        if tick_ms - last_check_ms < poll_ms:
            continue
        last_check_ms = tick_ms

        candle = {
            "high": mp,
            "low": px,
            "close": px,
            "candle_time": tt[:12],
        }
        eod_here = bool(is_eod and idx == n - 1)
        res = check_sell_signal_momentum_live(position, candle, params, is_eod=eod_here)
        if not res:
            continue

        reason, _theoretical = res
        fill_px = px
        if slip_pct > 0:
            fill_px = px * (1.0 - slip_pct / 100.0)

        try:
            sell_dt = parse_backtest_time(tt)
        except ValueError:
            sell_dt = parse_backtest_time(tt[:12])
        hold_min = round((sell_dt - entry_dt).total_seconds() / 60.0, 1)
        sell_time = tt[:14] if len(tt) >= 14 else tt[:12]
        return reason, fill_px, sell_time, hold_min

    return None


def resolve_momentum_sell_for_bar(
    position: Dict[str, Any],
    bar: Dict[str, Any],
    params: Dict[str, Any],
    *,
    is_eod: bool = False,
    ticks_by_code: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]] = None,
    code: str = "",
) -> Optional[Tuple[str, float, str, float, str]]:
    """
    한 분봉 청산 — 틱 우선, 없으면 OHLC intrabar 폴백.

    Returns:
        (reason, fill_price, sell_time, hold_min, exit_source)
        exit_source: ws_ticks | ohlc_bar
    """
    from kis_trader.engine.momentum_engine import check_sell_signal_momentum_backtest_bar

    ct = str(bar.get("candle_time") or "")
    entry_time = str(position.get("entry_time") or "")

    if momentum_backtest_use_tick_exit(params) and ticks_by_code and code:
        minute_ticks = collect_minute_ticks(ticks_by_code, code, ct)
        if minute_ticks:
            tick_res = try_momentum_sell_on_ticks(
                position, minute_ticks, params,
                is_eod=is_eod, entry_time=entry_time,
            )
            if tick_res:
                reason, fill_px, sell_time, hold_min = tick_res
                return reason, fill_px, sell_time, hold_min, "ws_ticks"

    if not momentum_backtest_tick_fallback_ohlc(params):
        return None

    res = check_sell_signal_momentum_backtest_bar(position, bar, params, is_eod=is_eod)
    if not res:
        return None
    reason, exit_price = res
    try:
        entry_dt = parse_backtest_time(entry_time)
        sell_dt = parse_backtest_time(ct)
        hold_min = round((sell_dt - entry_dt).total_seconds() / 60.0, 1)
    except ValueError:
        hold_min = 0.0
    return reason, float(exit_price), ct, hold_min, "ohlc_bar"
