""" KRX (한국거래소) 시장 캘린더 장 운영: 평일 09:00~15:30 KST (공휴일 제외) 우선순위: 1. exchange_calendars 라이브러리 (pip install exchange-calendars) → 음력 자동 계산 2. 하드코딩 폴백 (2024~2026 공휴일 내장) """ import datetime from zoneinfo import ZoneInfo KST = ZoneInfo("Asia/Seoul") MARKET_OPEN = datetime.time(9, 0) MARKET_CLOSE = datetime.time(15, 30) # ── KRX 공휴일 하드코딩 (exchange_calendars 미설치 시 폴백) ────────────────── # 출처: KRX 공식 휴장일 공고 (2024~2026) STATIC_HOLIDAYS: frozenset[datetime.date] = frozenset({ # 2024 datetime.date(2024, 1, 1), # 신정 datetime.date(2024, 2, 9), # 설날 연휴 datetime.date(2024, 2, 12), # 대체공휴일 datetime.date(2024, 3, 1), # 삼일절 datetime.date(2024, 4, 10), # 국회의원선거 datetime.date(2024, 5, 5), # 어린이날 datetime.date(2024, 5, 6), # 대체공휴일 datetime.date(2024, 5, 15), # 부처님오신날 datetime.date(2024, 6, 6), # 현충일 datetime.date(2024, 8, 15), # 광복절 datetime.date(2024, 9, 16), # 추석 연휴 datetime.date(2024, 9, 17), # 추석 datetime.date(2024, 9, 18), # 추석 연휴 datetime.date(2024, 10, 3), # 개천절 datetime.date(2024, 10, 9), # 한글날 datetime.date(2024, 12, 25), # 성탄절 datetime.date(2024, 12, 31), # 연말 휴장 # 2025 datetime.date(2025, 1, 1), # 신정 datetime.date(2025, 1, 28), # 설날 연휴 datetime.date(2025, 1, 29), # 설날 datetime.date(2025, 1, 30), # 설날 연휴 datetime.date(2025, 3, 1), # 삼일절 datetime.date(2025, 3, 3), # 대체공휴일 datetime.date(2025, 5, 5), # 어린이날 datetime.date(2025, 5, 6), # 대체공휴일 datetime.date(2025, 6, 6), # 현충일 datetime.date(2025, 8, 15), # 광복절 datetime.date(2025, 10, 2), # 대체공휴일 datetime.date(2025, 10, 3), # 개천절 datetime.date(2025, 10, 6), # 추석 연휴 datetime.date(2025, 10, 7), # 추석 datetime.date(2025, 10, 8), # 추석 연휴 datetime.date(2025, 10, 9), # 한글날 datetime.date(2025, 12, 25), # 성탄절 datetime.date(2025, 12, 31), # 연말 휴장 # 2026 datetime.date(2026, 1, 1), # 신정 datetime.date(2026, 2, 16), # 설날 연휴 datetime.date(2026, 2, 17), # 설날 datetime.date(2026, 2, 18), # 설날 연휴 datetime.date(2026, 3, 1), # 삼일절 datetime.date(2026, 3, 2), # 대체공휴일 datetime.date(2026, 5, 5), # 어린이날 datetime.date(2026, 5, 24), # 부처님오신날 datetime.date(2026, 6, 6), # 현충일 datetime.date(2026, 8, 14), # 대체공휴일 datetime.date(2026, 8, 15), # 광복절 datetime.date(2026, 9, 24), # 추석 연휴 datetime.date(2026, 9, 25), # 추석 datetime.date(2026, 10, 3), # 개천절 datetime.date(2026, 10, 9), # 한글날 datetime.date(2026, 12, 25), # 성탄절 datetime.date(2026, 12, 31), # 연말 휴장 }) class KRXCalendar: """ KRX 시장 캘린더 >>> cal = KRXCalendar() >>> cal.is_trading_day(datetime.date(2026, 1, 1)) # 신정 False >>> cal.is_trading_day(datetime.date(2026, 1, 2)) # 평일 True """ def __init__(self): self._ec_cal = None try: import exchange_calendars as ec self._ec_cal = ec.get_calendar("XKRX") print("[KRXCalendar] exchange_calendars 로드 성공 (정확한 음력 공휴일 사용)") except ImportError: print("[KRXCalendar] exchange_calendars 미설치 → 하드코딩 폴백 (pip install exchange-calendars 권장)") except Exception as e: print(f"[KRXCalendar] exchange_calendars 로드 실패: {e} → 폴백 사용") # ── 날짜 판별 ────────────────────────────────────────────────────────────── def is_trading_day(self, date: datetime.date | None = None) -> bool: """주어진 날짜가 KRX 거래일인지 확인 (기본: 오늘 KST)""" if date is None: date = datetime.datetime.now(KST).date() if date.weekday() >= 5: # 토(5), 일(6) return False if self._ec_cal: try: return self._ec_cal.is_session(date.isoformat()) except Exception: pass return date not in STATIC_HOLIDAYS def now_kst(self) -> datetime.datetime: """현재 KST 시각""" return datetime.datetime.now(KST) def is_market_open(self) -> bool: """현재 KST 기준 장 중 여부 (09:00 ≤ time < 15:30)""" now = self.now_kst() if not self.is_trading_day(now.date()): return False return MARKET_OPEN <= now.time() < MARKET_CLOSE def is_pre_market(self) -> bool: """장 시작 전 (당일 거래일이고 09:00 이전)""" now = self.now_kst() return self.is_trading_day(now.date()) and now.time() < MARKET_OPEN def is_post_market(self) -> bool: """장 마감 후 (당일 거래일이고 15:30 이후)""" now = self.now_kst() return self.is_trading_day(now.date()) and now.time() >= MARKET_CLOSE # ── 다음 장 시각 계산 ────────────────────────────────────────────────────── def next_trading_open(self) -> datetime.datetime: """ 다음 장 시작 시각 (KST) - 오늘이 거래일이고 아직 09:00 이전 → 오늘 09:00 반환 - 그 외 → 다음 거래일 09:00 반환 """ now = self.now_kst() date = now.date() if self.is_trading_day(date) and now.time() < MARKET_OPEN: return datetime.datetime.combine(date, MARKET_OPEN, tzinfo=KST) # 다음 거래일 탐색 (최대 14일) next_date = date + datetime.timedelta(days=1) for _ in range(14): if self.is_trading_day(next_date): return datetime.datetime.combine(next_date, MARKET_OPEN, tzinfo=KST) next_date += datetime.timedelta(days=1) raise RuntimeError("14일 이내에 거래일을 찾지 못했습니다.") def today_close(self) -> datetime.datetime | None: """오늘 장 종료 시각. 오늘이 거래일이 아니면 None.""" now = self.now_kst() if not self.is_trading_day(now.date()): return None return datetime.datetime.combine(now.date(), MARKET_CLOSE, tzinfo=KST) # ── 잔여 시간 계산 ────────────────────────────────────────────────────────── def seconds_to_open(self) -> float: """장 시작까지 남은 초 (이미 장 중이거나 장 마감 후면 0)""" if self.is_market_open(): return 0.0 try: return max(0.0, (self.next_trading_open() - self.now_kst()).total_seconds()) except RuntimeError: return 0.0 def seconds_to_close(self) -> float: """장 종료까지 남은 초 (장 외 시간이면 0)""" now = self.now_kst() if not self.is_trading_day(now.date()): return 0.0 close_dt = datetime.datetime.combine(now.date(), MARKET_CLOSE, tzinfo=KST) return max(0.0, (close_dt - now).total_seconds()) def minutes_to_close(self) -> float: return self.seconds_to_close() / 60 # ── 상태 요약 ────────────────────────────────────────────────────────────── def status_summary(self) -> str: """현재 시장 상태 요약 문자열 (로그/알림용)""" now = self.now_kst() today = now.date() if not self.is_trading_day(today): try: nxt = self.next_trading_open() return f"휴장 | 다음 거래일: {nxt.strftime('%m/%d(%a) %H:%M')}" except Exception: return "휴장" if self.is_market_open(): mins = int(self.minutes_to_close()) return f"장 중 | 마감까지 {mins}분" if now.time() < MARKET_OPEN: secs = self.seconds_to_open() return f"장 시작 전 | 개장까지 {int(secs / 60)}분" return "장 마감" # 싱글톤 (프로세스 내 공유) _calendar: KRXCalendar | None = None def get_calendar() -> KRXCalendar: global _calendar if _calendar is None: _calendar = KRXCalendar() return _calendar