You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

391 lines
15 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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