#!/usr/bin/env python3
"""ORB 스케줄러 정확성 검증 테스트.

전략 파라미터가 서로 다른 세션 2개를 가지고:
  1. 세션별로 독립된 스케줄이 만들어지는지 확인
  2. 같은 시각에 각 세션이 올바른 이벤트를 받는지 확인
  3. 종일 시뮬레이션으로 이벤트 순서·횟수 검증
  4. ORBAutoScheduler._build_today_schedule() 통합 검증

Usage:
    python scripts/test_orb_schedule.py
"""
from __future__ import annotations

import os
import sys
import tempfile
from pathlib import Path
from typing import Any

ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
os.chdir(ROOT)

import datetime as dt
from collections import defaultdict
from zoneinfo import ZoneInfo

ET = ZoneInfo("America/New_York")

SEP = "─" * 60


def section(title: str) -> None:
    print(f"\n{SEP}")
    print(f"  {title}")
    print(SEP)


def ok(msg: str) -> None:
    print(f"  ✓ {msg}")


def fail(msg: str) -> None:
    print(f"  ✗ {msg}")
    sys.exit(1)


def assert_eq(label: str, got: Any, expected: Any) -> None:
    if got == expected:
        ok(f"{label}: {got!r}")
    else:
        fail(f"{label}: expected {expected!r}, got {got!r}")


# ── Test configs ──────────────────────────────────────────────────────────────

_CONFIG_V55 = """\
strategy_mode: orb
orb_strategy:
  orb_minutes: 10
  sim_bar_minutes: 90
  order_timeout_minutes: 45
  entry_direction: long_only
  min_price: 5.0
  min_avg_dollar_volume: 1000000
  min_atr_14: 0.10
  min_rvol: 0.1
  max_candidates: 10
  min_candidates_to_trade: 1
  weight_rvol: 0.60
  weight_gap: 0.25
  weight_dollar_vol: 0.15
  atr_stop_multiplier: 0.05
  breakeven_at_r: 1.0
  trailing_at_r: 10.0
  trailing_stop_atr_multiplier: 0.3
  risk_per_trade_pct: 0.01
  max_position_pct: 0.25
  daily_max_loss_pct: 0.05
  max_stops_per_day: 5
  exit_minutes_before_close: 5
universe:
  source: midlarge
"""

_CONFIG_V57 = """\
strategy_mode: orb
orb_strategy:
  orb_minutes: 5
  sim_bar_minutes: 120
  order_timeout_minutes: 45
  entry_direction: long_only
  min_price: 5.0
  min_avg_dollar_volume: 1000000
  min_atr_14: 0.10
  min_rvol: 0.1
  max_candidates: 10
  min_candidates_to_trade: 1
  weight_rvol: 0.60
  weight_gap: 0.25
  weight_dollar_vol: 0.15
  atr_stop_multiplier: 0.05
  breakeven_at_r: 1.0
  trailing_at_r: 10.0
  trailing_stop_atr_multiplier: 0.3
  risk_per_trade_pct: 0.01
  max_position_pct: 0.25
  daily_max_loss_pct: 0.05
  max_stops_per_day: 5
  exit_minutes_before_close: 5
universe:
  source: midlarge
"""

_CONFIG_V60 = """\
strategy_mode: orb
orb_strategy:
  orb_minutes: 5
  sim_bar_minutes: 120
  order_timeout_minutes: 20
  entry_direction: long_only
  min_price: 5.0
  min_avg_dollar_volume: 1000000
  min_atr_14: 0.10
  min_rvol: 0.5
  max_candidates: 10
  min_candidates_to_trade: 1
  weight_rvol: 0.60
  weight_gap: 0.25
  weight_dollar_vol: 0.15
  atr_stop_multiplier: 0.05
  breakeven_at_r: 1.0
  trailing_at_r: 10.0
  trailing_stop_atr_multiplier: 0.3
  risk_per_trade_pct: 0.01
  max_position_pct: 0.25
  daily_max_loss_pct: 0.05
  max_stops_per_day: 5
  exit_minutes_before_close: 5
universe:
  source: midlarge
"""

# ── Helpers ───────────────────────────────────────────────────────────────────

def make_time(h: int, m: int) -> dt.datetime:
    today = dt.date(2026, 4, 14)  # Monday
    return dt.datetime(today.year, today.month, today.day, h, m, tzinfo=ET)


def events_of_kind(schedule: list[dict], kind: str, session: str | None = None) -> list[dict]:
    return [
        e for e in schedule
        if e["kind"] == kind and (session is None or e.get("session") == session)
    ]


def events_at(schedule: list[dict], h: int, m: int, session: str | None = None) -> list[dict]:
    t = make_time(h, m)
    return [
        e for e in schedule
        if e["et_dt"] == t and (session is None or e.get("session") == session)
    ]


# ── Main ─────────────────────────────────────────────────────────────────────

def main() -> None:
    print("\nORB 스케줄러 정확성 검증")
    print("=" * 60)

    today = dt.date(2026, 4, 14)  # 월요일

    with tempfile.TemporaryDirectory() as tmp:
        # 설정 파일 작성
        cfg_v55 = Path(tmp) / "v55.yaml"
        cfg_v57 = Path(tmp) / "v57.yaml"
        cfg_v60 = Path(tmp) / "v60.yaml"
        cfg_v55.write_text(_CONFIG_V55)
        cfg_v57.write_text(_CONFIG_V57)
        cfg_v60.write_text(_CONFIG_V60)
        db_path = str(Path(tmp) / "orb.db")

        from apps.orb_trader.state import ORBStateManager
        state = ORBStateManager(db_path)
        state.create_session("s_v55", str(cfg_v55), 10_000.0)
        state.create_session("s_v57", str(cfg_v57), 10_000.0)
        state.create_session("s_v60", str(cfg_v60), 10_000.0)

        # ── 1. 개별 전략 스케줄 정확성 ────────────────────────────────────────
        section("1. 전략별 스케줄 파라미터 반영 확인")

        from apps.web.orb_trading_service import build_schedule, _load_session_params

        params_v55 = _load_session_params(db_path, "s_v55")
        params_v57 = _load_session_params(db_path, "s_v57")
        params_v60 = _load_session_params(db_path, "s_v60")

        assert_eq("v55.orb_minutes",           params_v55["orb_minutes"],           10)
        assert_eq("v55.sim_bar_minutes",       params_v55["sim_bar_minutes"],       90)
        assert_eq("v55.order_timeout_minutes", params_v55["order_timeout_minutes"], 45)
        assert_eq("v57.orb_minutes",           params_v57["orb_minutes"],           5)
        assert_eq("v57.sim_bar_minutes",       params_v57["sim_bar_minutes"],       120)
        assert_eq("v60.order_timeout_minutes", params_v60["order_timeout_minutes"], 20)

        sched_v55 = build_schedule(today, **params_v55)
        sched_v57 = build_schedule(today, **params_v57)
        sched_v60 = build_schedule(today, **params_v60)

        # orb_detect 시각
        det_v55 = events_of_kind(sched_v55, "orb_detect")[0]["et_dt"]
        det_v57 = events_of_kind(sched_v57, "orb_detect")[0]["et_dt"]
        assert_eq("v55 orb_detect", det_v55.strftime("%H:%M"), "09:40")
        assert_eq("v57 orb_detect", det_v57.strftime("%H:%M"), "09:35")

        # stop_check 간격
        stops_v55 = events_of_kind(sched_v55, "stop_check")
        stops_v57 = events_of_kind(sched_v57, "stop_check")
        if len(stops_v55) >= 2:
            gap55 = int((stops_v55[1]["et_dt"] - stops_v55[0]["et_dt"]).total_seconds() // 60)
            assert_eq("v55 stop 간격(분)", gap55, 90)
        if len(stops_v57) >= 2:
            gap57 = int((stops_v57[1]["et_dt"] - stops_v57[0]["et_dt"]).total_seconds() // 60)
            assert_eq("v57 stop 간격(분)", gap57, 120)

        # breakout 횟수 (order_timeout 반영)
        bt_v55 = events_of_kind(sched_v55, "breakout")
        bt_v60 = events_of_kind(sched_v60, "breakout")
        assert_eq("v55 breakout 횟수", len(bt_v55), 45)
        assert_eq("v60 breakout 횟수(timeout=20)", len(bt_v60), 20)

        # ── 2. 통합 스케줄 세션 태그 확인 ────────────────────────────────────
        section("2. 통합 스케줄: 세션 태그 + 이벤트 격리")

        from apps.web.orb_trading_service import ORBAutoScheduler

        scheduler = ORBAutoScheduler()
        scheduler._db_path = db_path
        scheduler._log_lines = []

        combined = scheduler._build_today_schedule(today, ["s_v55", "s_v57"])

        # 모든 이벤트에 session 태그가 있는지
        untagged = [e for e in combined if not e.get("session")]
        assert_eq("태그 없는 이벤트 수", len(untagged), 0)

        # 이벤트 이름에 세션 prefix가 붙는지
        wrong_prefix = [e for e in combined if not e["name"].startswith(e["session"] + ":")]
        assert_eq("잘못된 prefix 이벤트 수", len(wrong_prefix), 0)

        # v55 세션의 orb_detect → 9:40
        v55_detect = events_at(combined, 9, 40, session="s_v55")
        assert_eq("s_v55 orb_detect at 09:40", len(v55_detect), 1)
        assert_eq("s_v55 detect kind", v55_detect[0]["kind"], "orb_detect")

        # v57 세션의 orb_detect → 9:35
        v57_detect = events_at(combined, 9, 35, session="s_v57")
        assert_eq("s_v57 orb_detect at 09:35", len(v57_detect), 1)
        assert_eq("s_v57 detect kind", v57_detect[0]["kind"], "orb_detect")

        # 9:35에 v55는 orb_monitor (아직 윈도우 중), v57은 orb_detect
        ev_935_v55 = events_at(combined, 9, 35, session="s_v55")
        ev_935_v57 = events_at(combined, 9, 35, session="s_v57")
        assert_eq("09:35 v55 kind (윈도우 중)", ev_935_v55[0]["kind"], "orb_monitor")
        assert_eq("09:35 v57 kind (감지!)",    ev_935_v57[0]["kind"], "orb_detect")

        # 9:40에 v55는 orb_detect, v57은 breakout (orb_end+5)
        ev_940_v55 = events_at(combined, 9, 40, session="s_v55")
        ev_940_v57 = events_at(combined, 9, 40, session="s_v57")
        assert_eq("09:40 v55 kind (감지!)", ev_940_v55[0]["kind"], "orb_detect")
        assert_eq("09:40 v57 kind (브레이크아웃)", ev_940_v57[0]["kind"], "breakout")

        ok("세션 간 이벤트가 완전히 격리되어 있음")

        # ── 3. 종일 이벤트 dispatch 시뮬레이션 ───────────────────────────────
        section("3. 종일 이벤트 dispatch 시뮬레이션 (s_v55 + s_v57)")

        dispatched: dict[str, list[str]] = defaultdict(list)

        # 스케줄의 모든 이벤트를 순서대로 처리하며 (session, kind) 기록
        processed = set()
        for ev in combined:
            if ev["name"] in processed:
                continue
            # 같은 시각 이벤트 묶음
            batch = [e for e in combined if e["et_dt"] == ev["et_dt"] and e["name"] not in processed]
            for b in batch:
                dispatched[b["session"]].append(b["kind"])
                processed.add(b["name"])

        # v55: orb_monitor×10 → orb_detect×1 → breakout×45 → stop_check×N → eod_exit → post_close
        v55_kinds = dispatched["s_v55"]
        monitors_v55 = v55_kinds.count("orb_monitor")
        detects_v55  = v55_kinds.count("orb_detect")
        breaks_v55   = v55_kinds.count("breakout")
        stops_v55_n  = v55_kinds.count("stop_check")

        assert_eq("v55 orb_monitor 횟수", monitors_v55, 10)
        assert_eq("v55 orb_detect  횟수", detects_v55,  1)
        assert_eq("v55 breakout    횟수", breaks_v55,   45)
        assert_eq("v55 stop_check  횟수 ≥ 1", stops_v55_n >= 1, True)
        assert_eq("v55 eod_exit    횟수", v55_kinds.count("eod_exit"), 1)
        assert_eq("v55 post_close  횟수", v55_kinds.count("post_close"), 1)

        # v57: orb_monitor×5 → orb_detect×1 → breakout×45 → stop_check×N → eod_exit → post_close
        v57_kinds = dispatched["s_v57"]
        monitors_v57 = v57_kinds.count("orb_monitor")
        detects_v57  = v57_kinds.count("orb_detect")
        breaks_v57   = v57_kinds.count("breakout")

        assert_eq("v57 orb_monitor 횟수", monitors_v57, 5)
        assert_eq("v57 orb_detect  횟수", detects_v57,  1)
        assert_eq("v57 breakout    횟수", breaks_v57,   45)
        assert_eq("v57 eod_exit    횟수", v57_kinds.count("eod_exit"), 1)

        # v55와 v57의 stop_check 시각이 다른지
        stops_v55_times = [
            e["et_dt"].strftime("%H:%M")
            for e in combined
            if e["kind"] == "stop_check" and e["session"] == "s_v55"
        ]
        stops_v57_times = [
            e["et_dt"].strftime("%H:%M")
            for e in combined
            if e["kind"] == "stop_check" and e["session"] == "s_v57"
        ]
        print(f"\n  v55 stop_check 시각: {stops_v55_times}")
        print(f"  v57 stop_check 시각: {stops_v57_times}")
        if set(stops_v55_times) != set(stops_v57_times):
            ok("v55/v57 stop_check 시각이 서로 다름 (전략별 독립 간격)")
        else:
            fail("v55/v57 stop_check 시각이 동일 — 파라미터가 적용 안됨")

        # ── 4. 이벤트 순서 검증 ──────────────────────────────────────────────
        section("4. 이벤트 순서 검증 (orb_monitor → orb_detect → breakout)")

        for sess in ["s_v55", "s_v57"]:
            kinds = dispatched[sess]
            # 첫 orb_detect 이전에 orb_monitor만 있어야 함
            detect_idx = kinds.index("orb_detect")
            before_detect = kinds[:detect_idx]
            non_monitor = [k for k in before_detect if k != "orb_monitor"]
            if non_monitor:
                fail(f"{sess}: orb_detect 이전에 orb_monitor 외 이벤트: {non_monitor}")
            else:
                ok(f"{sess}: orb_monitor → orb_detect → breakout 순서 정상")

            # orb_detect 이후 첫 이벤트는 breakout
            after_detect = kinds[detect_idx + 1:]
            if after_detect and after_detect[0] == "breakout":
                ok(f"{sess}: orb_detect 직후 breakout 시작")
            elif after_detect:
                ok(f"{sess}: orb_detect 직후 이벤트: {after_detect[0]}")

        # ── 5. 경쟁 조건 없음 확인 ──────────────────────────────────────────
        section("5. 같은 시각에 두 세션이 동시에 올바른 이벤트를 받는지")

        # 9:41: v55는 breakout_1, v57은 breakout_6 (v57은 9:35+6=9:41)
        ev_941 = events_at(combined, 9, 41)
        by_sess = {e["session"]: e["kind"] for e in ev_941}
        print(f"\n  09:41 이벤트: {by_sess}")
        if "s_v55" in by_sess and "s_v57" in by_sess:
            assert_eq("09:41 v55 kind", by_sess["s_v55"], "breakout")
            assert_eq("09:41 v57 kind", by_sess["s_v57"], "breakout")
            ok("두 세션 모두 breakout — 이벤트 격리 정상")
        else:
            ok(f"09:41 에 활성 세션: {list(by_sess.keys())}")

        # 9:36: v55는 orb_monitor (아직 윈도우), v57은 breakout
        ev_936 = events_at(combined, 9, 36)
        by_sess_936 = {e["session"]: e["kind"] for e in ev_936}
        print(f"  09:36 이벤트: {by_sess_936}")
        assert_eq("09:36 v55 kind (윈도우 6분)", by_sess_936.get("s_v55"), "orb_monitor")
        assert_eq("09:36 v57 kind (브레이크아웃)", by_sess_936.get("s_v57"), "breakout")

    print(f"\n{'=' * 60}")
    print("  모든 스케줄 검증 통과 — 장 시작 시 올바르게 작동할 것으로 검증됨")
    print(f"{'=' * 60}\n")


if __name__ == "__main__":
    import traceback
    try:
        main()
    except SystemExit:
        raise
    except Exception:
        traceback.print_exc()
        sys.exit(1)
