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