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>
This commit is contained in:
213
modules/utils/market_calendar.py
Normal file
213
modules/utils/market_calendar.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
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
|
||||
@@ -6,11 +6,16 @@
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import datetime
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from multiprocessing.shared_memory import SharedMemory
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
# EOD 마커 파일: 오늘 장 마감 후 봇이 기록, Watchdog가 재시작 여부 결정에 사용
|
||||
_EOD_DATE_FILE = Path("data") / ".eod_date"
|
||||
|
||||
|
||||
class ProcessTracker:
|
||||
"""메모리 기반 프로세스 추적기"""
|
||||
@@ -136,6 +141,17 @@ class ProcessWatchdog:
|
||||
entry = self._watched.get(name)
|
||||
return entry['process'] if entry else None
|
||||
|
||||
@staticmethod
|
||||
def is_eod_today() -> bool:
|
||||
"""오늘 EOD 마커 파일이 존재하면 True (장 마감 셧다운 → 재시작 차단)"""
|
||||
try:
|
||||
if not _EOD_DATE_FILE.exists():
|
||||
return False
|
||||
eod_date = datetime.date.fromisoformat(_EOD_DATE_FILE.read_text().strip())
|
||||
return eod_date >= datetime.date.today()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _watchdog_loop(self):
|
||||
"""주기적으로 자식 프로세스 상태 확인"""
|
||||
import multiprocessing
|
||||
@@ -150,10 +166,15 @@ class ProcessWatchdog:
|
||||
if proc.is_alive():
|
||||
continue
|
||||
|
||||
# 프로세스가 죽었음
|
||||
# 프로세스가 종료됨
|
||||
exit_code = proc.exitcode
|
||||
restart_count = entry['restart_count']
|
||||
|
||||
# [EOD 차단] 오늘 장 마감 셧다운이면 재시작하지 않음
|
||||
if ProcessWatchdog.is_eod_today():
|
||||
print(f"[Watchdog] {name}: EOD 셧다운 감지 — 재시작 건너뜀.")
|
||||
continue
|
||||
|
||||
if restart_count >= Config.MAX_RESTART_COUNT:
|
||||
print(f"[Watchdog] {name} crashed (exit={exit_code}). "
|
||||
f"Max restarts ({Config.MAX_RESTART_COUNT}) reached. Giving up.")
|
||||
|
||||
Reference in New Issue
Block a user