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