- DailyLedger: 당일 매수 회계 + 연속 손절 카운터 + 매수 신호 점수 한 객체로 집약 (bot.py 정리) - RiskGate: 테마당 동시 보유 + 노출 비율 상한 검증 (포트폴리오 레벨) - news_snapshot: 뉴스 SQLite 영구 저장 + 사후 감성 재검증 인프라 - backtest_runner: 전 종목 KIS 일봉 기반 백테스트 (Sharpe/MDD/Calmar) - bot.py 274 line 정리 (DailyLedger 분리) - backtest.py 173 line 재작성 (v3.2 next-bar 체결 + 거래세) - daily_launcher.py 폐기 (warmup_and_restart 통합) - .gitignore: .claude/ 제외 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
184 lines
6.3 KiB
Python
184 lines
6.3 KiB
Python
"""
|
|
프로세스 생명주기 관리
|
|
- 메모리 기반 PID 관리 (pids.txt 폐기)
|
|
- Watchdog 헬스체크
|
|
- 자동 재시작 (최대 3회)
|
|
"""
|
|
import os
|
|
import time
|
|
import threading
|
|
from multiprocessing.shared_memory import SharedMemory
|
|
|
|
from modules.config import Config
|
|
|
|
# EOD 마커 파일: 오늘 장 마감 후 봇이 기록, Watchdog가 재시작 여부 결정에 사용
|
|
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
|
|
|
|
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']
|
|
|
|
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)
|