#!/usr/bin/env python3 """ kis_trader/utils/kr_trading_day.py — 한국 거래일(주말·휴장) 보정 공통 ==================================================================== 백테 웹 / CLI / API 날짜 기본값: 주말·휴장이면 **이전 장운영일**로 맞춤. 휴장일 목록: env ``KR_MARKET_HOLIDAYS`` (콤마구분 YYYY-MM-DD). DB에 없으면 기본 CSV를 env_config_ext 에 1회 시드. """ from __future__ import annotations from datetime import date, datetime, timedelta from typing import List, Optional, Set, Tuple, Union from kis_trader.utils.env import get_env_from_db, get_env_int DateLike = Union[str, date, datetime] # 한국거래소 휴장(주말 제외). DB/ env 로 갱신. get_env 기본값용. _DEFAULT_KR_MARKET_HOLIDAYS = ( # 2025 "2025-01-01,2025-01-28,2025-01-29,2025-01-30," "2025-03-01,2025-05-05,2025-05-06,2025-06-06," "2025-08-15,2025-10-03,2025-10-06,2025-10-07,2025-10-08,2025-10-09,2025-12-25," # 2026 "2026-01-01,2026-02-16,2026-02-17,2026-02-18," "2026-03-01,2026-05-05,2026-05-24,2026-06-06," "2026-08-15,2026-09-24,2026-09-25,2026-09-26," "2026-10-03,2026-10-09,2026-12-25," # 2027 (주요) "2027-01-01,2027-02-08,2027-02-09,2027-02-10," "2027-03-01,2027-05-05,2027-05-13,2027-06-06," "2027-08-15,2027-09-14,2027-09-15,2027-09-16," "2027-10-03,2027-10-09,2027-12-25" ) _holidays_cache: Optional[Set[date]] = None _holidays_cache_raw: Optional[str] = None _seeded: bool = False def _parse_ymd(raw: DateLike) -> date: if isinstance(raw, datetime): return raw.date() if isinstance(raw, date): return raw s = str(raw or "").strip() if not s: raise ValueError("empty date") if "-" in s and len(s) >= 10: return datetime.strptime(s[:10], "%Y-%m-%d").date() digits = "".join(c for c in s if c.isdigit()) if len(digits) >= 8: return datetime.strptime(digits[:8], "%Y%m%d").date() raise ValueError(f"bad date: {raw!r}") def ymd(d: date) -> str: return d.strftime("%Y-%m-%d") def parse_holiday_csv(raw: str) -> Set[date]: out: Set[date] = set() for part in str(raw or "").replace(";", ",").split(","): p = part.strip() if not p or p.startswith("#"): continue try: out.add(_parse_ymd(p)) except ValueError: continue return out def ensure_kr_market_holidays_in_db() -> None: """KR_MARKET_HOLIDAYS / lookback 기본값을 DB/ext 에 없으면 1회 시드 (기존 값 유지).""" global _seeded if _seeded: return _seeded = True try: from kis_trader.utils.env import _get_db # noqa: PLC2701 db = _get_db() if not db: return now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") def _seed_if_missing(key: str, value: str) -> None: cur = str(get_env_from_db(key, "") or "").strip() if cur: return row = db.conn.execute( "SELECT env_value FROM env_config_ext WHERE env_key=%s LIMIT 1", (key,), ).fetchone() if row and str((row["env_value"] if isinstance(row, dict) else row[0]) or "").strip(): return db.conn.execute( "INSERT INTO env_config_ext (env_key, env_value, updated_at) " "VALUES (%s, %s, %s) " "ON DUPLICATE KEY UPDATE env_key=env_key", (key, value, now), ) _seed_if_missing("KR_MARKET_HOLIDAYS", _DEFAULT_KR_MARKET_HOLIDAYS) _seed_if_missing("PARAM_SEARCH_DEFAULT_LOOKBACK_DAYS", "7") _seed_if_missing("KR_TRADING_DAY_MAX_BACK_DAYS", "14") try: from kis_trader.utils.env import invalidate_merged_env_cache invalidate_merged_env_cache() except Exception: pass except Exception: pass def get_kr_market_holiday_set(*, refresh: bool = False) -> Set[date]: """휴장일 set. env/DB → 없으면 코드 기본 CSV.""" global _holidays_cache, _holidays_cache_raw ensure_kr_market_holidays_in_db() raw = str( get_env_from_db("KR_MARKET_HOLIDAYS", _DEFAULT_KR_MARKET_HOLIDAYS) or "" ).strip() if not raw: raw = _DEFAULT_KR_MARKET_HOLIDAYS if ( not refresh and _holidays_cache is not None and _holidays_cache_raw == raw ): return _holidays_cache _holidays_cache = parse_holiday_csv(raw) _holidays_cache_raw = raw return _holidays_cache def is_kr_trading_day(d: DateLike, *, holidays: Optional[Set[date]] = None) -> bool: """월~금 이고 휴장 목록에 없으면 True.""" day = _parse_ymd(d) if day.weekday() >= 5: return False hol = holidays if holidays is not None else get_kr_market_holiday_set() return day not in hol def iter_kr_trading_days( start: DateLike, end: DateLike, *, holidays: Optional[Set[date]] = None, ) -> List[date]: """[start, end] 구간의 한국 거래일 목록 (주말·휴장 제외, 오름차순).""" a = _parse_ymd(start) b = _parse_ymd(end) if a > b: a, b = b, a hol = holidays if holidays is not None else get_kr_market_holiday_set() out: List[date] = [] cur = a while cur <= b: if is_kr_trading_day(cur, holidays=hol): out.append(cur) cur += timedelta(days=1) return out def count_kr_trading_days( start: DateLike, end: DateLike, *, holidays: Optional[Set[date]] = None, ) -> int: """[start, end] 한국 거래일 수. 잘못된 날짜면 1(최소 표본).""" try: n = len(iter_kr_trading_days(start, end, holidays=holidays)) return max(1, int(n)) except Exception: return 1 def clamp_to_prev_kr_trading_day( d: DateLike, *, holidays: Optional[Set[date]] = None, max_back_days: Optional[int] = None, ) -> str: """ 거래일이 아니면 하루씩 과거로 이동해 **가장 가까운 이전(또는 당일) 장운영일** 반환. """ day = _parse_ymd(d) hol = holidays if holidays is not None else get_kr_market_holiday_set() limit = int( max_back_days if max_back_days is not None else get_env_int("KR_TRADING_DAY_MAX_BACK_DAYS", 14) ) limit = max(1, limit) for _ in range(limit + 1): if is_kr_trading_day(day, holidays=hol): return ymd(day) day -= timedelta(days=1) return ymd(day) def default_kr_trading_end(*, as_of: Optional[DateLike] = None) -> str: """종료일 기본값 = as_of(기본 오늘) 을 이전 거래일로 보정.""" base = _parse_ymd(as_of) if as_of is not None else date.today() return clamp_to_prev_kr_trading_day(base) def default_kr_trading_start( lookback_days: int = 7, *, as_of: Optional[DateLike] = None, end: Optional[DateLike] = None, ) -> str: """시작일 기본값 = (종료일 − lookback) 을 이전 거래일로 보정.""" end_d = _parse_ymd(end) if end is not None else _parse_ymd( default_kr_trading_end(as_of=as_of) ) lb = max(1, int(lookback_days)) start_raw = end_d - timedelta(days=lb) return clamp_to_prev_kr_trading_day(start_raw) def resolve_kr_backtest_date_defaults( lookback_days: int = 7, *, as_of: Optional[DateLike] = None, ) -> Tuple[str, str]: """(start, end) 기본 구간. end·start 모두 거래일.""" end = default_kr_trading_end(as_of=as_of) start = default_kr_trading_start(lookback_days, end=end) if start > end: start = end return start, end def clamp_to_prev_weekday(d: DateLike, *, max_back_days: int = 14) -> str: """주말만 보정 (해외장 등 공휴일 목록 없이 토·일만 스킵).""" day = _parse_ymd(d) for _ in range(max(1, int(max_back_days)) + 1): if day.weekday() < 5: return ymd(day) day -= timedelta(days=1) return ymd(day) def holiday_list_ymd() -> List[str]: return sorted(ymd(d) for d in get_kr_market_holiday_set()) def trading_dates_payload(lookback_days: int = 7) -> dict: """웹/API용 날짜 기본값 묶음.""" start, end = resolve_kr_backtest_date_defaults(lookback_days) today = ymd(date.today()) return { "today": today, "end": end, "start": start, "trading_day": end, # 단일일 조회용 (= 보정된 종료일) "holidays": holiday_list_ymd(), "lookback_days": int(lookback_days), "is_today_trading_day": is_kr_trading_day(today), }