Files
ai-trade/modules/utils/process_tracker.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

205 lines
7.1 KiB
Python

"""
프로세스 생명주기 관리
- 메모리 기반 PID 관리 (pids.txt 폐기)
- Watchdog 헬스체크
- 자동 재시작 (최대 3회)
"""
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:
"""메모리 기반 프로세스 추적기"""
# 클래스 변수: 등록된 프로세스 정보
_processes = {} # {name: pid}
_lock = threading.Lock()
# 하위 호환: 기존 pids.txt 정리용
FILE_PATH = "pids.txt"
@staticmethod
def register(name):
"""현재 프로세스 등록 (메모리 기반)"""
pid = os.getpid()
with ProcessTracker._lock:
ProcessTracker._processes[name] = pid
print(f"[Process] Registered: {name} (PID: {pid})")
@staticmethod
def unregister(name):
"""프로세스 등록 해제"""
with ProcessTracker._lock:
ProcessTracker._processes.pop(name, None)
@staticmethod
def get_all():
"""등록된 모든 프로세스 반환"""
with ProcessTracker._lock:
return dict(ProcessTracker._processes)
@staticmethod
def check_and_kill_zombies():
"""이전 실행의 좀비 프로세스 정리 + stale SharedMemory 정리"""
# 1. pids.txt 기반 좀비 정리 (하위 호환)
if os.path.exists(ProcessTracker.FILE_PATH):
try:
import psutil
current_pid = os.getpid()
with open(ProcessTracker.FILE_PATH, "r", encoding="utf-8") as f:
lines = f.readlines()
killed_count = 0
for line in lines:
if ":" not in line or "Running Processes" in line:
continue
try:
pid = int(line.split(":")[0].strip())
if pid == current_pid:
continue
if psutil.pid_exists(pid):
proc = psutil.Process(pid)
if "python" in proc.name().lower():
print(f"[Process] Killing zombie: PID {pid} ({line.strip()})")
proc.kill()
killed_count += 1
except (ValueError, psutil.NoSuchProcess, psutil.AccessDenied):
continue
if killed_count > 0:
print(f"[Process] Cleaned up {killed_count} zombie processes.")
except Exception as e:
print(f"[Process] Zombie cleanup failed: {e}")
# pids.txt 삭제 (더 이상 사용하지 않음)
try:
os.remove(ProcessTracker.FILE_PATH)
except Exception:
pass
# 2. Stale SharedMemory 정리
try:
shm = SharedMemory(name=Config.SHM_NAME, create=False)
shm.close()
shm.unlink()
print(f"[Process] Cleaned stale SharedMemory: {Config.SHM_NAME}")
except FileNotFoundError:
pass
except Exception:
pass
@staticmethod
def clear():
"""등록 정보 초기화"""
with ProcessTracker._lock:
ProcessTracker._processes.clear()
class ProcessWatchdog:
"""자식 프로세스 감시 및 자동 재시작"""
def __init__(self, shutdown_event=None):
self.shutdown_event = shutdown_event
self._watched = {} # {name: {process, target, args, restart_count}}
self._thread = None
self._running = False
def watch(self, name, process, target, args=()):
"""프로세스를 감시 대상에 등록"""
self._watched[name] = {
'process': process,
'target': target,
'args': args,
'restart_count': 0
}
def start(self):
"""Watchdog 스레드 시작"""
self._running = True
self._thread = threading.Thread(target=self._watchdog_loop, daemon=True)
self._thread.start()
print(f"[Watchdog] Started (interval: {Config.WATCHDOG_INTERVAL}s)")
def stop(self):
"""Watchdog 중지"""
self._running = False
if self._thread:
self._thread.join(timeout=5)
def get_process(self, name):
"""감시 중인 프로세스 반환"""
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
while self._running:
if self.shutdown_event and self.shutdown_event.is_set():
break
for name, entry in list(self._watched.items()):
proc = entry['process']
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.")
continue
print(f"[Watchdog] {name} crashed (exit={exit_code}). "
f"Restarting... ({restart_count + 1}/{Config.MAX_RESTART_COUNT})")
try:
new_proc = multiprocessing.Process(
target=entry['target'],
args=entry['args']
)
new_proc.start()
entry['process'] = new_proc
entry['restart_count'] = restart_count + 1
print(f"[Watchdog] {name} restarted (new PID: {new_proc.pid})")
except Exception as e:
print(f"[Watchdog] Failed to restart {name}: {e}")
# 인터벌 대기 (shutdown_event 체크하면서)
for _ in range(Config.WATCHDOG_INTERVAL):
if not self._running:
break
if self.shutdown_event and self.shutdown_event.is_set():
break
time.sleep(1)