Files
ai-trade/modules/utils/market_calendar.py
gahusb 0aebca7ff0 v3.1 과매수 방지, 앙상블 학습, KRX 캘린더 기반 장중 전용 운영 구현
[잔고 관리]
- _today_buy_total 인스턴스 변수로 당일 누적 매수 추적 (KIS T+2 미차감 보완)
- MAX_BUY_PER_CYCLE, MAX_DAILY_BUY_RATIO 설정 추가
- available_deposit = max_daily_buy - effective_today_buy 계산

[앙상블 & 포지션 사이징]
- AdaptiveEnsemble 실제 연동 (하드코딩 가중치 제거)
- Kelly Criterion Half-Kelly 포지션 비중 계산
- SignalWeights.normalize() Water-Filling 알고리즘으로 경계 위반 해결
- _accuracy_weighted() 크기 가중 정확도로 통일
- ensemble_weights.json → ensemble_history.json 통합

[LLM 클라이언트]
- GeminiLLMClient 추가 (Gemini → Ollama 폴백 체인)
- _class_last_call_ts 클래스 변수로 워커 재시작 후에도 스로틀 유지
- Ollama 미실행 조기 감지 및 명확한 오류 메시지

[KIS API]
- 모든 requests.get/post에 timeout=Config.HTTP_TIMEOUT 적용
- get_balance()에 today_buy_amt 필드 추가

[장중 전용 운영]
- KRXCalendar: exchange_calendars 기반, 2024~2026 공휴일 하드코딩 폴백
- EOD 셧다운: 15:35에 전체 상태 저장 후 서버 자동 종료
- Watchdog: .eod_date 마커로 EOD 후 재시작 차단
- daily_launcher.py: 매일 08:30 실행, 휴장일 감지 후 봇 미시작
- Windows 작업 스케줄러 WebAI_DailyLauncher 등록

[텔레그램 스킬 수정]
- PYTHONIOENCODING=utf-8 서브프로세스 환경 설정 (cp949 이모지 오류 해결)
- /regime: IPC macro_indices 파싱 구현, --json 모드 input() 블로킹 제거
- /weights: ensemble_history.json 형식 파싱 업데이트
- /model_health: glob 패턴 *_v3.pt 수정
- /postmortem: 거래 없을 때 빈 JSON 출력으로 Telegram 오류 해결
- /macro: price=0 시 prev_close 폴백 표시

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 05:21:23 +09:00

214 lines
8.8 KiB
Python

"""
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