#!/usr/bin/env python3 """한국 주식시장(KRX/KOSDAQ) 휴장일을 investing.com에서 받아 state/market_holidays.json에 저장. - 올해 + 내년 데이터를 함께 수집해 12월 → 1월 갭 방지. - launchd `ai.openclaw.stock.holiday-sync`가 주 1회 호출 (LLM 미경유). - 실패 시 stderr만 남기고 기존 state 파일은 보존 (web view는 옛 데이터로 계속 동작). CLI: python3 holiday_sync.py # 올해+내년 fetch 후 저장 python3 holiday_sync.py --years 1 # 올해만 python3 holiday_sync.py --show # 저장된 휴장일 출력 (network 호출 없음) Source: https://kr.investing.com/holiday-calendar/service/getCalendarFilteredData 필터: country[]=11(한국), 거래소 = "서울 증권 거래소" (KOSDAQ는 같은 날 동시 휴장이라 첫 매칭만) """ from __future__ import annotations import argparse import json import re import sys import urllib.parse import urllib.request from datetime import date, datetime from pathlib import Path from zoneinfo import ZoneInfo WORKSPACE = Path(__file__).resolve().parent.parent STATE_FILE = WORKSPACE / 'state' / 'market_holidays.json' HEALTH_FILE = WORKSPACE / 'state' / 'holiday_sync_health.json' CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json') KST = ZoneInfo('Asia/Seoul') URL = 'https://kr.investing.com/holiday-calendar/service/getCalendarFilteredData' NAGER_URL = 'https://date.nager.at/api/v3/PublicHolidays/{year}/KR' KOREA_COUNTRY_ID = 11 USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15' ROW_RE = re.compile( r'\s*([^<]*).*?([^<]+)\s*([^<]+)', re.DOTALL, ) DATE_RE = re.compile(r'(\d{4})년\s*(\d{1,2})월\s*(\d{1,2})일') def fetch_year(year: int) -> dict[str, str]: """{date(YYYY-MM-DD) → 휴장명} for the given year. 빈 dict 반환 가능.""" body = urllib.parse.urlencode([ ('country[]', str(KOREA_COUNTRY_ID)), ('dateFrom', f'{year}-01-01'), ('dateTo', f'{year}-12-31'), ]).encode() req = urllib.request.Request(URL, data=body, method='POST', headers={ 'User-Agent': USER_AGENT, 'X-Requested-With': 'XMLHttpRequest', 'Referer': 'https://kr.investing.com/holiday-calendar/', 'Origin': 'https://kr.investing.com', 'Accept': 'application/json, text/javascript, */*; q=0.01', 'Accept-Language': 'ko-KR,ko;q=0.9', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', }) with urllib.request.urlopen(req, timeout=20) as r: payload = json.loads(r.read().decode('utf-8')) html_str = payload.get('data', '') last_date: str | None = None out: dict[str, str] = {} for date_raw, exch, name in ROW_RE.findall(html_str): date_raw = date_raw.strip() exch = exch.strip() name = name.strip() if date_raw: m = DATE_RE.match(date_raw) if m: last_date = f'{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}' # 한국 KRX 본장 기준 — 서울/코스닥 공동 휴장이라 서울만 보면 충분. if last_date and last_date.startswith(str(year)) and '서울' in exch and last_date not in out: out[last_date] = name return out def fetch_nager(year: int) -> dict[str, str]: """date.nager.at에서 한국 공휴일 fetch (평일만). investing.com이 임시공휴일(선거일·정부 지정 대체공휴일)을 누락하는 패턴 보강용. 토·일은 KRX 자체 휴장이라 제외 — KRX 휴장 의미가 없음.""" req = urllib.request.Request(NAGER_URL.format(year=year), headers={ 'User-Agent': USER_AGENT, 'Accept': 'application/json', }) with urllib.request.urlopen(req, timeout=20) as r: data = json.loads(r.read().decode('utf-8')) out: dict[str, str] = {} for item in data: d = (item.get('date') or '').strip() name = (item.get('localName') or item.get('name') or '').strip() if not d or not name: continue try: y, m, dd = map(int, d.split('-')) if date(y, m, dd).weekday() >= 5: continue except Exception: continue out[d] = name return out def is_holiday(date_str: str) -> bool: """date_str(YYYY-MM-DD)이 KRX 휴장일이면 True. 데이터 파일 없거나 깨지면 False(안전 fallback). 다른 스크립트가 self-skip 게이트로 import해서 사용한다.""" if not STATE_FILE.exists(): return False try: data = json.loads(STATE_FILE.read_text()) return date_str in (data.get('holidays') or {}) except Exception: return False def is_holiday_today() -> bool: return is_holiday(datetime.now(KST).strftime('%Y-%m-%d')) def is_market_day_today() -> bool: """오늘이 KRX 정규장 영업일이면 True (평일 + 휴장일 아님). 데이터 파일 누락 시 평일이면 True로 fall through — 검증 신호 가리지 않음.""" now = datetime.now(KST) if now.weekday() >= 5: # 토(5)·일(6) return False return not is_holiday_today() def _send_telegram(text: str) -> bool: """레이 텔레그램으로 자가 알림. 자격증명·발송 실패 시 stderr만 남기고 False.""" try: cfg = json.loads(CONFIG_PATH.read_text()) acct = cfg['channels']['telegram']['accounts']['stock'] token = acct['botToken'] chat_ids = acct.get('allowFrom') or [] except Exception as e: sys.stderr.write(f'telegram cfg load failed: {e}\n') return False if not chat_ids: return False url = f'https://api.telegram.org/bot{token}/sendMessage' ok = True for chat_id in chat_ids: body = urllib.parse.urlencode({ 'chat_id': chat_id, 'text': text[:4000], 'disable_web_page_preview': 'true', }).encode() try: req = urllib.request.Request(url, data=body, method='POST') with urllib.request.urlopen(req, timeout=15) as r: if r.status != 200: ok = False except Exception as e: sys.stderr.write(f'telegram send failed: {e}\n') ok = False return ok def _load_health() -> dict: if not HEALTH_FILE.exists(): return {'consecutive_both_failures': 0} try: return json.loads(HEALTH_FILE.read_text()) except Exception: return {'consecutive_both_failures': 0} def _save_health(h: dict) -> None: HEALTH_FILE.parent.mkdir(parents=True, exist_ok=True) HEALTH_FILE.write_text(json.dumps(h, ensure_ascii=False, indent=2)) def _update_health(both_failed: bool, error_lines: list[str]) -> None: """sync 시도 결과로 health 상태 갱신 + 2회 연속 진입 시점에만 알림 1회.""" now_iso = datetime.now(KST).isoformat() h = _load_health() prev = int(h.get('consecutive_both_failures') or 0) h['last_attempt_at'] = now_iso if both_failed: h['consecutive_both_failures'] = prev + 1 h['last_failure_reason'] = '\n'.join(error_lines)[:500] if h['consecutive_both_failures'] == 2 and prev < 2: last_ok = h.get('last_success_at') or '기록 없음' text = ( '🚨 holiday-sync 2회 연속 실패\n' 'investing.com · nager.at 양쪽 모두 데이터 0건\n' f'마지막 성공: {last_ok}\n' f'오류:\n{h["last_failure_reason"]}\n' 'state file은 옛 데이터 유지 — 휴장 가드는 정상 동작' ) sent = _send_telegram(text) h['alert_sent_at'] = now_iso if sent else None else: h['consecutive_both_failures'] = 0 h['last_success_at'] = now_iso h.pop('last_failure_reason', None) h.pop('alert_sent_at', None) _save_health(h) def cmd_sync(years_forward: int) -> int: this_year = datetime.now(KST).year years = list(range(this_year, this_year + max(1, years_forward))) investing_data: dict[str, str] = {} errors: list[str] = [] for y in years: try: investing_data.update(fetch_year(y)) except Exception as e: errors.append(f'investing fetch_year({y}): {e}') sys.stderr.write(errors[-1] + '\n') # nager는 healthcheck용으로 항상 시도 — 휴리스틱(investing 0건 연도 skip)은 사용 시점에만 적용. nager_data_raw: dict[str, str] = {} for y in years: try: nager_data_raw.update(fetch_nager(y)) except Exception as e: errors.append(f'nager fetch_nager({y}): {e}') sys.stderr.write(errors[-1] + '\n') # 양쪽 다 0건이면 영구 변경/네트워크 차단 의심 — health 카운트 + 2회 연속 시 알림. both_failed = (not investing_data) and (not nager_data_raw) _update_health(both_failed, errors) if not investing_data: sys.stderr.write('no holidays parsed — investing.com 미반영. state file은 옛 데이터 유지.\n') return 3 # KRX는 다음 연도 휴장 캘린더를 보통 11~12월에 공식 발표 → 그 전엔 investing.com도 비어 있음. # investing이 한 건이라도 잡은 연도에만 nager 보강 — 미발표 연도의 일반 공휴일이 KRX 휴장으로 잘못 등록되는 것 차단. investing_years = {int(d[:4]) for d in investing_data} nager_data = {d: n for d, n in nager_data_raw.items() if int(d[:4]) in investing_years} # investing.com(거래소 캘린더) 우선 — KRX 휴장 권위. nager는 누락분만 채움. holidays = {**nager_data, **investing_data} nager_extra = sorted(d for d in nager_data if d not in investing_data) STATE_FILE.parent.mkdir(parents=True, exist_ok=True) payload = { 'fetched_at': datetime.now(KST).isoformat(), 'sources': {'primary': URL, 'fallback': NAGER_URL.format(year='YYYY')}, 'years': years, 'holidays': dict(sorted(holidays.items())), } tmp = STATE_FILE.with_suffix('.json.tmp') tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2)) tmp.replace(STATE_FILE) extra_msg = f' (nager 보강 {len(nager_extra)}일: {nager_extra})' if nager_extra else '' print(f'wrote {STATE_FILE} — {len(holidays)} holidays for {years}{extra_msg}', flush=True) return 0 def cmd_show() -> int: if not STATE_FILE.exists(): sys.stderr.write(f'no state file: {STATE_FILE}\n') return 1 data = json.loads(STATE_FILE.read_text()) print(f'fetched_at: {data.get("fetched_at")}') print(f'years: {data.get("years")}') for d, n in (data.get('holidays') or {}).items(): print(f' {d} {n}') return 0 def main(argv: list[str]) -> int: p = argparse.ArgumentParser(prog='holiday_sync') p.add_argument('--years', type=int, default=2, help='올해 + N년 (default 2: 올해와 내년)') p.add_argument('--show', action='store_true', help='저장된 휴장일 출력 후 종료 (네트워크 호출 없음)') args = p.parse_args(argv) if args.show: return cmd_show() return cmd_sync(args.years) if __name__ == '__main__': sys.exit(main(sys.argv[1:]))