#!/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:]))