OrderWorker·수집통계·영구구독 알람 env 등록과 TradeDB/db_manager 조회·마이그레이션을 보강한다. kr_trading_day 헬퍼와 ERD 문서를 함께 갱신한다. Co-authored-by: Cursor <cursoragent@cursor.com>
268 lines
8.4 KiB
Python
268 lines
8.4 KiB
Python
#!/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),
|
||
}
|