#!/usr/bin/env python3 """ lib_trading_calendar.py ─────────────────────────────────────────────────────────────────────────────── KRX 거래일 기반 데이터 신선도 판정 모듈 (결정론) 배경: 한국 주식시장은 주말·공휴일 휴장한다. 금요일 종가 데이터는 토·일 내내 변하지 않으므로, 단순 "24시간 경과" SLA는 토요일 오후만 돼도 거짓 신선도 위반을 일으킨다. 신선도는 "캡처한 종가가 여전히 최신 종가인가"로 판정해야 한다. 핵심 규칙: 데이터가 STALE인 시점 = 캡처 시각 이후 도래하는 첫 거래일 개장(09:00 KST). → 금요일 15:35 캡처 → 다음 개장 = 월요일 09:00. 그 전까지 FRESH(페널티 0). KST(UTC+9), KRX 정규장: 09:00 개장 / 15:30 마감. 공휴일: spec/krx_holidays.yaml 에서 로드. 없으면 주말만 비거래일. """ from __future__ import annotations from datetime import datetime, timedelta, timezone, date, time from pathlib import Path import yaml ROOT = Path(__file__).resolve().parents[2] HOLIDAYS_PATH = ROOT / "spec" / "krx_holidays.yaml" KST = timezone(timedelta(hours=9)) MARKET_OPEN_HOUR = 9 # 09:00 KST 개장 MARKET_OPEN_MINUTE = 0 MARKET_CLOSE_HOUR = 15 # 15:30 KST 마감 MARKET_CLOSE_MINUTE = 30 def _load_holidays() -> set[str]: """공휴일 YYYY-MM-DD 문자열 집합. 파일 없으면 빈 집합(주말만 적용).""" if not HOLIDAYS_PATH.exists(): return set() try: data = yaml.safe_load(HOLIDAYS_PATH.read_text(encoding="utf-8")) or {} hols = data.get("krx_market_holidays") or data.get("holidays") or [] result: set[str] = set() for h in hols: d = str(h.get("date") if isinstance(h, dict) else h or "").strip() if d: result.add(d[:10]) return result except Exception: return set() _HOLIDAYS_CACHE: set[str] | None = None def _holidays() -> set[str]: global _HOLIDAYS_CACHE if _HOLIDAYS_CACHE is None: _HOLIDAYS_CACHE = _load_holidays() return _HOLIDAYS_CACHE def is_trading_day(d: date) -> bool: """거래일 여부 — 주말(토5/일6) 및 공휴일 제외.""" if d.weekday() >= 5: # 5=토, 6=일 return False if d.isoformat() in _holidays(): return False return True def next_trading_day(d: date) -> date: """d 다음(d 제외) 첫 거래일.""" nxt = d + timedelta(days=1) for _ in range(30): # 연속 휴장 한계 방어 if is_trading_day(nxt): return nxt nxt += timedelta(days=1) return nxt def market_open_dt(d: date) -> datetime: """거래일 d 의 개장 시각(KST aware).""" return datetime.combine(d, time(MARKET_OPEN_HOUR, MARKET_OPEN_MINUTE), tzinfo=KST) def next_market_open_after(dt_utc: datetime) -> datetime: """주어진 시각(UTC aware) 이후 도래하는 첫 거래일 개장 시각(KST aware). 예: 금요일 15:35 KST → 월요일 09:00 KST (토·일 건너뜀) 금요일 08:00 KST → 금요일 09:00 KST 월요일 10:00 KST → 화요일 09:00 KST """ dt_kst = dt_utc.astimezone(KST) cur_date = dt_kst.date() # 오늘이 거래일이고 아직 개장(09:00) 전이면 → 오늘 개장 if is_trading_day(cur_date): today_open = market_open_dt(cur_date) if dt_kst < today_open: return today_open nd = next_trading_day(cur_date) return market_open_dt(nd) def is_data_stale(captured_at_iso: str, now_utc: datetime | None = None) -> dict: """거래일 기반 데이터 신선도 판정. Returns dict: stale(bool), captured_at_kst, stale_deadline_kst, now_kst, hours_until_stale(양수=FRESH, 음수=STALE), reason. """ if now_utc is None: now_utc = datetime.now(timezone.utc) try: dt = datetime.fromisoformat(captured_at_iso.replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) except Exception: return { "stale": True, "captured_at_kst": None, "stale_deadline_kst": None, "now_kst": now_utc.astimezone(KST).isoformat(), "hours_until_stale": None, "reason": "INVALID_CAPTURED_AT", } deadline = next_market_open_after(dt) now_kst = now_utc.astimezone(KST) stale = now_kst >= deadline hours_until = (deadline - now_kst).total_seconds() / 3600.0 return { "stale": stale, "captured_at_kst": dt.astimezone(KST).isoformat(), "stale_deadline_kst": deadline.isoformat(), "now_kst": now_kst.isoformat(), "hours_until_stale": round(hours_until, 2), "reason": "STALE_NEW_SESSION_OPENED" if stale else "FRESH_WITHIN_TRADING_SESSION", } if __name__ == "__main__": import sys if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") # 셀프 테스트 (KST 기준: 금 15:35 = UTC 06:35) cases = [ ("2026-05-29T06:35:00+00:00", "2026-05-30T05:00:00+00:00", "금15:35캡처 → 토14:00 KST: FRESH 기대"), ("2026-05-29T06:35:00+00:00", "2026-05-31T23:30:00+00:00", "금15:35캡처 → 월08:30 KST: FRESH 기대"), ("2026-05-29T06:35:00+00:00", "2026-06-01T00:30:00+00:00", "금15:35캡처 → 월09:30 KST: STALE 기대"), ] for cap, now, desc in cases: r = is_data_stale(cap, datetime.fromisoformat(now)) print(f"{desc}\n stale={r['stale']} deadline={r['stale_deadline_kst']} hours_until={r['hours_until_stale']}\n")