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:
2026-05-16 03:00:11 +09:00
parent 42b91d03cf
commit 7ea1a21487
39 changed files with 722 additions and 691 deletions

View 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

View 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

View 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)

View 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()
}

View 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)