refactor: web-ai V1 assets → signal_v1/ (graduation prep)
Atomic mv of root V1 assets (main_server.py + modules/ + data/ + tests/ + entry scripts + docs + logs) into signal_v1/ subdirectory. load_dotenv() updated to load web-ai/.env explicitly via Path. Adds web-ai/CLAUDE.md (workspace guide) and web-ai/start.bat (signal_v1 entry wrapper). Prepares for signal_v2/ Phase 2. Tests: signal_v1/tests/unit baseline preserved (no regression). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
208
signal_v1/modules/utils/ipc.py
Normal file
208
signal_v1/modules/utils/ipc.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
프로세스 간 통신 (IPC) - Shared Memory 기반
|
||||
텔레그램 봇과 메인 봇 간 데이터 공유 + 양방향 명령 채널
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import struct
|
||||
from multiprocessing.shared_memory import SharedMemory
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
|
||||
class SharedIPC:
|
||||
"""Shared Memory + Command Queue 기반 IPC"""
|
||||
|
||||
def __init__(self, lock=None, command_queue=None):
|
||||
self.lock = lock
|
||||
self.command_queue = command_queue
|
||||
self._shm = None
|
||||
self._is_creator = False
|
||||
|
||||
def _ensure_shm(self):
|
||||
"""SharedMemory 블록에 연결 (없으면 생성)"""
|
||||
if self._shm is not None:
|
||||
return self._shm
|
||||
try:
|
||||
self._shm = SharedMemory(name=Config.SHM_NAME, create=False)
|
||||
except FileNotFoundError:
|
||||
self._shm = SharedMemory(name=Config.SHM_NAME, create=True, size=Config.SHM_SIZE)
|
||||
self._is_creator = True
|
||||
# 초기화: 길이 필드를 0으로 설정
|
||||
struct.pack_into('I', self._shm.buf, 0, 0)
|
||||
return self._shm
|
||||
|
||||
def write_status(self, data):
|
||||
"""메인 봇이 상태를 shared memory에 기록"""
|
||||
try:
|
||||
shm = self._ensure_shm()
|
||||
payload = json.dumps({
|
||||
'timestamp': time.time(),
|
||||
'data': data
|
||||
}, ensure_ascii=False).encode('utf-8')
|
||||
|
||||
if len(payload) + 4 > Config.SHM_SIZE:
|
||||
print(f"[IPC] Data too large: {len(payload)} bytes")
|
||||
return
|
||||
|
||||
if self.lock:
|
||||
self.lock.acquire()
|
||||
try:
|
||||
# [4바이트 길이][JSON 페이로드]
|
||||
struct.pack_into('I', shm.buf, 0, len(payload))
|
||||
shm.buf[4:4 + len(payload)] = payload
|
||||
finally:
|
||||
if self.lock:
|
||||
self.lock.release()
|
||||
except Exception as e:
|
||||
print(f"[IPC] Write failed: {e}")
|
||||
|
||||
def read_status(self):
|
||||
"""텔레그램 봇이 상태를 shared memory에서 읽기"""
|
||||
raw = None
|
||||
try:
|
||||
shm = self._ensure_shm()
|
||||
|
||||
if self.lock:
|
||||
self.lock.acquire()
|
||||
try:
|
||||
length = struct.unpack_from('I', shm.buf, 0)[0]
|
||||
if length > 0 and length <= Config.SHM_SIZE - 4:
|
||||
raw = bytes(shm.buf[4:4 + length])
|
||||
finally:
|
||||
if self.lock:
|
||||
self.lock.release()
|
||||
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
ipc_data = json.loads(raw.decode('utf-8'))
|
||||
age = time.time() - ipc_data.get('timestamp', 0)
|
||||
|
||||
if age > Config.IPC_STALENESS:
|
||||
print(f"[IPC] Data too old: {age:.1f}s")
|
||||
return None
|
||||
|
||||
return ipc_data.get('data')
|
||||
except Exception as e:
|
||||
print(f"[IPC] Read failed: {e}")
|
||||
return None
|
||||
|
||||
# --- 명령 채널 (텔레그램 → 메인 봇) ---
|
||||
|
||||
def send_command(self, command, **kwargs):
|
||||
"""텔레그램 → 메인 봇 명령 전송"""
|
||||
if self.command_queue:
|
||||
try:
|
||||
self.command_queue.put_nowait({
|
||||
'command': command,
|
||||
'timestamp': time.time(),
|
||||
**kwargs
|
||||
})
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[IPC] Command send failed: {e}")
|
||||
return False
|
||||
|
||||
def poll_commands(self):
|
||||
"""메인 봇이 명령 큐를 폴링"""
|
||||
commands = []
|
||||
if self.command_queue:
|
||||
try:
|
||||
while not self.command_queue.empty():
|
||||
cmd = self.command_queue.get_nowait()
|
||||
commands.append(cmd)
|
||||
except Exception:
|
||||
pass
|
||||
return commands
|
||||
|
||||
# --- FakeBot 인스턴스 (호환성 유지) ---
|
||||
|
||||
def get_bot_instance_data(self):
|
||||
"""봇 인스턴스 데이터 가져오기 (텔레그램 봇용)"""
|
||||
status = self.read_status()
|
||||
if not status:
|
||||
return None
|
||||
|
||||
class FakeBotInstance:
|
||||
def __init__(self, data):
|
||||
self.kis = FakeKIS(data.get('balance', {}), data.get('macro_indices', {}))
|
||||
self.ollama_monitor = FakeOllama(data.get('gpu', {}))
|
||||
self.theme_manager = FakeThemeManager(data.get('themes', {}))
|
||||
self.discovered_stocks = set(data.get('discovered_stocks', []))
|
||||
self.is_macro_warning_sent = data.get('is_macro_warning', False)
|
||||
self.watchlist_manager = FakeWatchlistManager(data.get('watchlist', {}))
|
||||
self.load_watchlist = lambda: data.get('watchlist', {})
|
||||
|
||||
class FakeKIS:
|
||||
def __init__(self, balance_data, macro_indices):
|
||||
self._balance = balance_data if balance_data else {
|
||||
'total_eval': 0, 'deposit': 0, 'holdings': []
|
||||
}
|
||||
self._macro_indices = macro_indices if macro_indices else {}
|
||||
|
||||
def get_balance(self):
|
||||
return self._balance
|
||||
|
||||
def get_current_index(self, ticker):
|
||||
if ticker in self._macro_indices:
|
||||
return self._macro_indices[ticker]
|
||||
return {'price': 2500.0, 'change': 0.0}
|
||||
|
||||
def get_daily_index_price(self, ticker, period="D"):
|
||||
base_price = 2500.0
|
||||
if ticker in self._macro_indices:
|
||||
base_price = self._macro_indices[ticker].get('price', 2500.0)
|
||||
import random
|
||||
return [base_price * (1 + random.uniform(-0.02, 0.02)) for _ in range(20)]
|
||||
|
||||
def get_current_price(self, ticker):
|
||||
return None
|
||||
|
||||
def get_daily_price(self, ticker, period="D"):
|
||||
return []
|
||||
|
||||
def get_volume_rank(self, market="0"):
|
||||
return []
|
||||
|
||||
def buy_stock(self, ticker, qty):
|
||||
return {"success": False, "msg": "IPC mode"}
|
||||
|
||||
def sell_stock(self, ticker, qty):
|
||||
return {"success": False, "msg": "IPC mode"}
|
||||
|
||||
class FakeOllama:
|
||||
def __init__(self, gpu_data):
|
||||
self._gpu = gpu_data if gpu_data else {
|
||||
'name': 'N/A', 'temp': 0, 'vram_used': 0, 'vram_total': 0, 'load': 0
|
||||
}
|
||||
|
||||
def get_gpu_status(self):
|
||||
return self._gpu
|
||||
|
||||
class FakeThemeManager:
|
||||
def __init__(self, themes_data):
|
||||
self._themes = themes_data if themes_data else {}
|
||||
|
||||
def get_themes(self, ticker):
|
||||
return self._themes.get(ticker, [])
|
||||
|
||||
class FakeWatchlistManager:
|
||||
def __init__(self, watchlist_data):
|
||||
self._watchlist = watchlist_data if watchlist_data else {}
|
||||
|
||||
def update_watchlist_daily(self):
|
||||
return "Watchlist update not available in IPC mode"
|
||||
|
||||
return FakeBotInstance(status)
|
||||
|
||||
def cleanup(self):
|
||||
"""리소스 정리"""
|
||||
if self._shm:
|
||||
try:
|
||||
self._shm.close()
|
||||
if self._is_creator:
|
||||
self._shm.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
self._shm = None
|
||||
213
signal_v1/modules/utils/market_calendar.py
Normal file
213
signal_v1/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
|
||||
110
signal_v1/modules/utils/monitor.py
Normal file
110
signal_v1/modules/utils/monitor.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import psutil
|
||||
from datetime import datetime
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
|
||||
class SystemMonitor:
|
||||
def __init__(self, messenger, ollama_manager):
|
||||
self.messenger = messenger
|
||||
self.ollama_monitor = ollama_manager
|
||||
self.last_health_check = datetime.now()
|
||||
|
||||
# CPU 서킷 브레이커 상태
|
||||
self._cpu_overload_count = 0 # 연속 과부하 횟수
|
||||
self._circuit_open = False # 서킷 브레이커 발동 여부
|
||||
self._circuit_open_since = None
|
||||
|
||||
def is_cpu_critical(self):
|
||||
"""서킷 브레이커가 발동 상태인지 반환 (True이면 분석 사이클 스킵)"""
|
||||
return self._circuit_open
|
||||
|
||||
def reset_circuit(self):
|
||||
"""서킷 브레이커 수동 리셋"""
|
||||
if self._circuit_open:
|
||||
print("[Monitor] CPU Circuit Breaker RESET")
|
||||
self._circuit_open = False
|
||||
self._cpu_overload_count = 0
|
||||
self._circuit_open_since = None
|
||||
|
||||
def check_health(self):
|
||||
"""시스템 상태 점검 및 알림 (CPU, RAM, GPU) - 3분마다 실행"""
|
||||
now = datetime.now()
|
||||
if (now - self.last_health_check).total_seconds() < 180:
|
||||
return
|
||||
|
||||
self.last_health_check = now
|
||||
alerts = []
|
||||
|
||||
# 1. CPU Check
|
||||
cpu_usage = psutil.cpu_percent(interval=1) # 1초 측정 (더 정확)
|
||||
|
||||
if cpu_usage > Config.CPU_CIRCUIT_BREAKER_THRESHOLD:
|
||||
self._cpu_overload_count += 1
|
||||
|
||||
# 상위 프로세스 조회
|
||||
top_processes = []
|
||||
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent']):
|
||||
try:
|
||||
if proc.info['name'] in ('System Idle Process', 'Idle'):
|
||||
continue
|
||||
top_processes.append(proc.info)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
|
||||
top_processes.sort(key=lambda x: x['cpu_percent'], reverse=True)
|
||||
top_3_str = ""
|
||||
for p in top_processes[:3]:
|
||||
top_3_str += f"\n- {p['name']} ({p['cpu_percent']}%)"
|
||||
|
||||
# 서킷 브레이커 발동 조건
|
||||
if self._cpu_overload_count >= Config.CPU_CIRCUIT_BREAKER_CONSECUTIVE:
|
||||
if not self._circuit_open:
|
||||
self._circuit_open = True
|
||||
self._circuit_open_since = now
|
||||
alerts.append(
|
||||
f"🔴 [CPU Circuit Breaker OPEN] {cpu_usage}% × {self._cpu_overload_count}회 연속\n"
|
||||
f"⛔ 분석 사이클 일시 중단 (5분 후 자동 복구)\nTop Processes:{top_3_str}"
|
||||
)
|
||||
print(f"[Monitor] CPU Circuit Breaker OPEN! CPU={cpu_usage}%")
|
||||
else:
|
||||
alerts.append(
|
||||
f"⚠️ [CPU Overload] Usage: {cpu_usage}% ({self._cpu_overload_count}회)\nTop Processes:{top_3_str}"
|
||||
)
|
||||
else:
|
||||
# CPU 정상 → 카운터 리셋
|
||||
if self._cpu_overload_count > 0:
|
||||
print(f"[Monitor] CPU 정상화 ({cpu_usage}%). 카운터 리셋.")
|
||||
self._cpu_overload_count = 0
|
||||
|
||||
# 서킷 브레이커가 열린 후 5분 경과 시 자동 복구
|
||||
if self._circuit_open and self._circuit_open_since:
|
||||
elapsed = (now - self._circuit_open_since).total_seconds()
|
||||
if elapsed >= 300: # 5분
|
||||
self._circuit_open = False
|
||||
self._circuit_open_since = None
|
||||
alerts.append("✅ [CPU Circuit Breaker CLOSED] 시스템 안정화. 분석 재개.")
|
||||
print("[Monitor] CPU Circuit Breaker CLOSED. 분석 재개.")
|
||||
|
||||
# 2. RAM Check
|
||||
ram = psutil.virtual_memory()
|
||||
if ram.percent > 90:
|
||||
alerts.append(f"⚠️ [RAM High] Usage: {ram.percent}% (Free: {ram.available / 1024**3:.1f}GB)")
|
||||
|
||||
# 3. GPU Check
|
||||
if self.ollama_monitor:
|
||||
gpu_status = self.ollama_monitor.get_gpu_status()
|
||||
temp = gpu_status.get('temp', 0)
|
||||
if temp > 80:
|
||||
alerts.append(f"🔥 [GPU Overheat] Temp: {temp}°C")
|
||||
|
||||
# 알림 전송 (텔레그램 비활성화 - 콘솔 로그만 사용)
|
||||
if alerts:
|
||||
# 콘솔에만 출력
|
||||
for alert in alerts:
|
||||
print(f"[Monitor] {alert}")
|
||||
|
||||
# [비활성화] 텔레그램 알림 - 필요시 재활성화
|
||||
# msg = "🔔 <b>[System Health Alert]</b>\n" + "\n".join(alerts)
|
||||
# if self.messenger:
|
||||
# self.messenger.send_message(msg)
|
||||
211
signal_v1/modules/utils/performance_db.py
Normal file
211
signal_v1/modules/utils/performance_db.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
성과 데이터 영구 저장 - PerformanceDB
|
||||
|
||||
데이터 파일:
|
||||
data/performance/daily_snapshots.json - 일별 자산 스냅샷
|
||||
data/performance/trade_records.json - 강화 매매 기록 (영구 보관)
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
PERF_DIR = os.path.join(Config.DATA_DIR, "performance")
|
||||
SNAPSHOTS_FILE = os.path.join(PERF_DIR, "daily_snapshots.json")
|
||||
TRADES_FILE = os.path.join(PERF_DIR, "trade_records.json")
|
||||
|
||||
|
||||
class PerformanceDB:
|
||||
def __init__(self):
|
||||
os.makedirs(PERF_DIR, exist_ok=True)
|
||||
self._snapshots = self._load_json(SNAPSHOTS_FILE, [])
|
||||
self._trades = self._load_json(TRADES_FILE, [])
|
||||
|
||||
def _load_json(self, path, default):
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[PerformanceDB] Load failed {path}: {e}")
|
||||
return default
|
||||
return default
|
||||
|
||||
def _save_json(self, path, data):
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[PerformanceDB] Save failed {path}: {e}")
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# 일별 스냅샷
|
||||
# ─────────────────────────────────────────
|
||||
|
||||
def save_daily_snapshot(self, total_eval, deposit, holdings_count, benchmark_close=None):
|
||||
"""일별 자산 스냅샷 저장 (하루 1회 호출 권장).
|
||||
|
||||
Args:
|
||||
total_eval (int): 총 평가액 (원)
|
||||
deposit (int): 예수금 (원)
|
||||
holdings_count (int): 보유 종목 수
|
||||
benchmark_close (float|None): KOSPI 현재가 (벤치마크 비교용)
|
||||
"""
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# 오늘 이미 저장된 스냅샷이 있으면 업데이트
|
||||
for snap in self._snapshots:
|
||||
if snap.get("date") == today:
|
||||
snap["total_eval"] = total_eval
|
||||
snap["deposit"] = deposit
|
||||
snap["holdings_count"] = holdings_count
|
||||
if benchmark_close is not None:
|
||||
snap["benchmark_kospi_close"] = benchmark_close
|
||||
self._save_json(SNAPSHOTS_FILE, self._snapshots)
|
||||
return
|
||||
|
||||
# 일별/누적 수익률 계산
|
||||
daily_return_pct = 0.0
|
||||
cumulative_return_pct = 0.0
|
||||
|
||||
if self._snapshots:
|
||||
prev_eval = self._snapshots[-1].get("total_eval", 0)
|
||||
if prev_eval > 0:
|
||||
daily_return_pct = (total_eval - prev_eval) / prev_eval * 100
|
||||
|
||||
initial_capital = self.get_initial_capital()
|
||||
if initial_capital and initial_capital > 0:
|
||||
cumulative_return_pct = (total_eval - initial_capital) / initial_capital * 100
|
||||
|
||||
snap = {
|
||||
"date": today,
|
||||
"total_eval": total_eval,
|
||||
"deposit": deposit,
|
||||
"holdings_count": holdings_count,
|
||||
"benchmark_kospi_close": benchmark_close,
|
||||
"daily_return_pct": round(daily_return_pct, 4),
|
||||
"cumulative_return_pct": round(cumulative_return_pct, 4)
|
||||
}
|
||||
self._snapshots.append(snap)
|
||||
self._save_json(SNAPSHOTS_FILE, self._snapshots)
|
||||
print(f"[PerformanceDB] Snapshot saved: {today} "
|
||||
f"total={total_eval:,}원 daily={daily_return_pct:+.2f}%")
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# 매매 기록
|
||||
# ─────────────────────────────────────────
|
||||
|
||||
def save_trade_record(self, action, ticker, name, qty, price,
|
||||
scores_dict=None, reason="", macro_state="SAFE"):
|
||||
"""매수/매도 기록 저장.
|
||||
|
||||
Args:
|
||||
action (str): "BUY" | "SELL"
|
||||
ticker (str): 종목 코드
|
||||
name (str): 종목명
|
||||
qty (int): 수량
|
||||
price (float): 체결가
|
||||
scores_dict (dict|None): 분석 점수 딕셔너리
|
||||
{tech, sentiment, lstm_score, score, ai_confidence, prediction_change}
|
||||
reason (str): 매매 사유
|
||||
macro_state (str): 매크로 상태 ("SAFE"/"CAUTION"/"DANGER")
|
||||
"""
|
||||
sd = scores_dict or {}
|
||||
now_iso = datetime.now().isoformat()
|
||||
|
||||
trade = {
|
||||
"id": f"{ticker}_{now_iso}",
|
||||
"action": action,
|
||||
"ticker": ticker,
|
||||
"name": name,
|
||||
"qty": qty,
|
||||
"price": price,
|
||||
"timestamp": now_iso,
|
||||
"reason": reason,
|
||||
"macro_state": macro_state,
|
||||
# 점수 (BUY 시에만 의미 있음)
|
||||
"tech_score": float(sd.get("tech", 0.0)),
|
||||
"sentiment_score": float(sd.get("sentiment", 0.0)),
|
||||
"lstm_score": float(sd.get("lstm_score", 0.0)),
|
||||
"total_score": float(sd.get("score", 0.0)),
|
||||
"ai_confidence": float(sd.get("ai_confidence", 0.5)),
|
||||
"ai_prediction_change": float(sd.get("prediction_change", 0.0)),
|
||||
# 매도 후 채워지는 결과 필드
|
||||
"outcome_return_pct": None,
|
||||
"holding_days": None,
|
||||
"closed_at": None
|
||||
}
|
||||
self._trades.append(trade)
|
||||
self._save_json(TRADES_FILE, self._trades)
|
||||
|
||||
def close_trade(self, ticker, sell_price, sell_yield_pct=None):
|
||||
"""가장 최근 미체결 BUY를 찾아 매도 결과를 기록.
|
||||
|
||||
Args:
|
||||
ticker (str): 종목 코드
|
||||
sell_price (float): 매도 체결가
|
||||
sell_yield_pct (float|None): KIS에서 받은 수익률 (보조용)
|
||||
"""
|
||||
for trade in reversed(self._trades):
|
||||
if (trade.get("ticker") == ticker
|
||||
and trade.get("action") == "BUY"
|
||||
and trade.get("outcome_return_pct") is None):
|
||||
|
||||
buy_price = trade.get("price", 0)
|
||||
if buy_price and buy_price > 0:
|
||||
outcome_return_pct = (sell_price - buy_price) / buy_price * 100
|
||||
elif sell_yield_pct is not None:
|
||||
outcome_return_pct = sell_yield_pct
|
||||
else:
|
||||
outcome_return_pct = 0.0
|
||||
|
||||
# 보유일 계산
|
||||
holding_days = 0
|
||||
buy_ts = trade.get("timestamp", "")
|
||||
if buy_ts:
|
||||
try:
|
||||
buy_dt = datetime.fromisoformat(buy_ts)
|
||||
holding_days = (datetime.now() - buy_dt).days
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
trade["outcome_return_pct"] = round(outcome_return_pct, 4)
|
||||
trade["holding_days"] = holding_days
|
||||
trade["closed_at"] = datetime.now().isoformat()
|
||||
self._save_json(TRADES_FILE, self._trades)
|
||||
print(f"[PerformanceDB] Trade closed: {ticker} "
|
||||
f"return={outcome_return_pct:.2f}% holding={holding_days}d")
|
||||
return
|
||||
|
||||
print(f"[PerformanceDB] No open BUY found for {ticker}")
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# 조회
|
||||
# ─────────────────────────────────────────
|
||||
|
||||
def load_snapshots(self, days=90):
|
||||
"""최근 N일 스냅샷 반환."""
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
return [s for s in self._snapshots if s.get("date", "") >= cutoff]
|
||||
|
||||
def load_trades(self, days=90):
|
||||
"""최근 N일 매매 기록 반환."""
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
return [t for t in self._trades if t.get("timestamp", "")[:10] >= cutoff]
|
||||
|
||||
def get_initial_capital(self):
|
||||
"""첫 스냅샷 기준 초기 자본 반환."""
|
||||
if self._snapshots:
|
||||
return self._snapshots[0].get("total_eval", 0)
|
||||
return 0
|
||||
|
||||
def get_summary(self):
|
||||
"""간단한 현황 딕셔너리 반환 (디버깅용)."""
|
||||
return {
|
||||
"total_snapshots": len(self._snapshots),
|
||||
"total_trades": len(self._trades),
|
||||
"closed_trades": sum(1 for t in self._trades
|
||||
if t.get("outcome_return_pct") is not None),
|
||||
"initial_capital": self.get_initial_capital()
|
||||
}
|
||||
183
signal_v1/modules/utils/process_tracker.py
Normal file
183
signal_v1/modules/utils/process_tracker.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
프로세스 생명주기 관리
|
||||
- 메모리 기반 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)
|
||||
Reference in New Issue
Block a user