From 6095b4c7fa8829ad8ed449bfa6b4e3226903ef4f Mon Sep 17 00:00:00 2001 From: jongjae Date: Tue, 26 May 2026 10:39:44 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B4=87=20=EB=AC=B4=EC=9D=8C=20=ED=81=AC?= =?UTF-8?q?=EB=9E=98=EC=8B=9C=20=EB=B0=A9=EC=A7=80=20=E2=80=94=20trading?= =?UTF-8?q?=5Floop=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20+=20?= =?UTF-8?q?=EC=9B=8C=EC=B9=98=EB=8F=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/main.py: trading_loop while 루프 전체를 try/except로 감싸 예외 발생 시 로그+Discord 알림 후 루프 재개 (연속 10회 오류 시에만 종료) - scripts/_watchdog.py: 봇 PID 생존 확인, 죽어있으면 Discord 알림 + 자동 재시작 - scripts/run_watchdog.ps1: 워치독 PowerShell 래퍼 - scripts/setup_scheduler.ps1: StockBot_Watchdog 태스크 등록 추가 (5분 간격) Co-Authored-By: Claude Sonnet 4.6 --- app/main.py | 81 +++++++++++++++++++++++-------------- scripts/_watchdog.py | 81 +++++++++++++++++++++++++++++++++++++ scripts/run_watchdog.ps1 | 21 ++++++++++ scripts/setup_scheduler.ps1 | 30 +++++++++++++- 4 files changed, 182 insertions(+), 31 deletions(-) create mode 100644 scripts/_watchdog.py create mode 100644 scripts/run_watchdog.ps1 diff --git a/app/main.py b/app/main.py index 97bee46..5293c3e 100644 --- a/app/main.py +++ b/app/main.py @@ -321,44 +321,65 @@ class StockBot: """1초 단위 메인 루프""" logger.info("매매 루프 시작") self.running = True + _consecutive_errors = 0 while self.running: - now = datetime.now() - now_str = now.strftime("%H:%M") + try: + now = datetime.now() + now_str = now.strftime("%H:%M") - # 14:50 강제 청산 - if now_str >= FORCE_EXIT: - await self.force_exit_all() - self.running = False - break + # 14:50 강제 청산 + if now_str >= FORCE_EXIT: + await self.force_exit_all() + self.running = False + break - # 14:00 이후 신규 진입 중단 (청산은 계속) - if now_str > "14:00": + # 14:00 이후 신규 진입 중단 (청산은 계속) + if now_str > "14:00": + await self.check_exits() + await asyncio.sleep(1) + continue + + # 09:00 이전 대기 + if now_str < "09:00": + await asyncio.sleep(1) + continue + + # midday_context.json 갱신 감지 (점심 세션 이벤트 기반 시작) + self._check_midday_context() + + # 리스크 체크 (L2/L4/L5 하드 중단) + if not self.risk.can_trade(): + await asyncio.sleep(5) + continue + + # 보유 포지션 청산 체크 await self.check_exits() + + # 신규 진입 체크 + if self.risk.can_add_position(len(self.positions)): + await self.check_entries() + + _consecutive_errors = 0 await asyncio.sleep(1) - continue - # 09:00 이전 대기 - if now_str < "09:00": - await asyncio.sleep(1) - continue - - # midday_context.json 갱신 감지 (점심 세션 이벤트 기반 시작) - self._check_midday_context() - - # 리스크 체크 (L2/L4/L5 하드 중단) - if not self.risk.can_trade(): + except asyncio.CancelledError: + raise + except Exception as e: + _consecutive_errors += 1 + logger.error( + f"매매 루프 오류 (연속 {_consecutive_errors}회): " + f"{type(e).__name__}: {e}", + exc_info=True, + ) + await notify_error(f"매매 루프 오류 {_consecutive_errors}회: {type(e).__name__}: {e}") + # 연속 10회 오류 시 루프 종료 (무한 오류 방지) + if _consecutive_errors >= 10: + logger.critical("연속 오류 10회 — 매매 루프 강제 종료") + await notify_error("연속 오류 10회 — 매매 루프 강제 종료") + self.running = False + break await asyncio.sleep(5) - continue - - # 보유 포지션 청산 체크 - await self.check_exits() - - # 신규 진입 체크 - if self.risk.can_add_position(len(self.positions)): - await self.check_entries() - - await asyncio.sleep(1) # ───────────────────────────────────────── # 진입 체크 diff --git a/scripts/_watchdog.py b/scripts/_watchdog.py new file mode 100644 index 0000000..f3607e9 --- /dev/null +++ b/scripts/_watchdog.py @@ -0,0 +1,81 @@ +""" +watchdog.py — 봇 생존 감시 + 자동 재시작 +5분마다 실행 (작업 스케줄러) / 장중(09:00~15:10)에만 작동 +""" +import asyncio, os, subprocess, sys +from datetime import datetime + +PROJECT = r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3' +PID_FILE = os.path.join(PROJECT, 'logs', 'bot.pid') + +os.chdir(PROJECT) +sys.path.insert(0, '.') +from app.main import load_env +load_env() + + +def is_process_alive(pid: int) -> bool: + r = subprocess.run( + ['tasklist', '/FI', f'PID eq {pid}', '/NH'], + capture_output=True, text=True, + ) + return str(pid) in r.stdout + + +def get_pid() -> int | None: + try: + return int(open(PID_FILE).read().strip()) + except Exception: + return None + + +def restart_bot() -> int: + proc = subprocess.Popen( + [sys.executable, 'app/main.py'], + creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP, + stdout=open('logs/bot_stderr.log', 'a', encoding='utf-8'), + stderr=subprocess.STDOUT, + close_fds=True, + ) + with open(PID_FILE, 'w') as f: + f.write(str(proc.pid)) + return proc.pid + + +async def main(): + now = datetime.now() + now_str = now.strftime("%H:%M") + + # 장 외 시간은 체크 안 함 + if not ("09:00" <= now_str <= "15:10"): + print(f"[{now_str}] 장 외 시간 — 워치독 종료") + return + + from app.monitor.notifier import send + + pid = get_pid() + + if pid is None: + msg = f"[경고] 봇 PID 파일 없음 — 봇이 실행되지 않은 상태입니다 ({now_str})" + print(msg) + await send(msg) + new_pid = restart_bot() + await send(f"[복구] 봇 자동 재시작 완료 PID={new_pid}") + return + + if is_process_alive(pid): + print(f"[{now_str}] 봇 정상 실행 중 PID={pid}") + return + + # 봇이 죽어있음 + msg = f"[긴급] 봇 프로세스 종료 감지 (PID={pid}) — 자동 재시작 시도" + print(msg) + await send(msg) + + new_pid = restart_bot() + await send(f"[복구] 봇 자동 재시작 완료 PID={new_pid} ({now_str})") + print(f"봇 재시작 완료 PID={new_pid}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/run_watchdog.ps1 b/scripts/run_watchdog.ps1 new file mode 100644 index 0000000..cd05be2 --- /dev/null +++ b/scripts/run_watchdog.ps1 @@ -0,0 +1,21 @@ +# 워치독 스크립트 — 봇 생존 감시 + 자동 재시작 +# 작업 스케줄러에서 5분마다 실행 (평일 09:00~15:10) + +$OutputEncoding = [System.Text.Encoding]::UTF8 +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$env:PYTHONIOENCODING = "utf-8" + +$PROJECT = Split-Path -Parent $PSScriptRoot +$LOG = "$PROJECT\logs\watchdog.log" +$utf8 = New-Object System.Text.UTF8Encoding $false + +Set-Location $PROJECT + +$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" +[System.IO.File]::AppendAllText($LOG, "[$timestamp] 워치독 실행`n", $utf8) + +python scripts/_watchdog.py 2>&1 | + ForEach-Object { [System.IO.File]::AppendAllText($LOG, "$_`n", $utf8) } + +$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" +[System.IO.File]::AppendAllText($LOG, "[$timestamp] 워치독 완료`n", $utf8) diff --git a/scripts/setup_scheduler.ps1 b/scripts/setup_scheduler.ps1 index 9938eac..d2c833f 100644 --- a/scripts/setup_scheduler.ps1 +++ b/scripts/setup_scheduler.ps1 @@ -42,7 +42,7 @@ function Register-StockTask($name, $time, $script, $limitMin) { Write-Host "[OK] $name 등록 완료 (평일 $time)" -ForegroundColor Green } -# ── 3개 태스크 등록 (StockBot_Bot 제거 — /morning이 /start-bot을 호출함) ──── +# ── 태스크 등록 ──────────────────────────────────────────────────────────────── # 08:15 claude /morning → 뉴스+KIS 수집 → daily_context.json → /start-bot 호출 Register-StockTask "StockBot_Morning" "08:15" "run_morning.ps1" 20 @@ -52,6 +52,34 @@ Register-StockTask "StockBot_Midday" "11:20" "run_midday.ps1" 20 # 15:30 claude /evening → 결과 분석 → 리포트 → Discord Register-StockTask "StockBot_Evening" "15:30" "run_evening.ps1" 30 +# 워치독: 5분마다 봇 생존 확인 → 죽어있으면 Discord 알림 + 자동 재시작 +function Register-WatchdogTask { + $times = @("09:00","09:05","09:10","09:15","09:20","09:25","09:30","09:35","09:40","09:45","09:50","09:55", + "10:00","10:05","10:10","10:15","10:20","10:25","10:30","10:35","10:40","10:45","10:50","10:55", + "11:00","11:05","11:10","11:15","11:20","11:25","11:30","11:35","11:40","11:45","11:50","11:55", + "12:00","12:05","12:10","12:15","12:20","12:25","12:30","12:35","12:40","12:45","12:50","12:55", + "13:00","13:05","13:10","13:15","13:20","13:25","13:30","13:35","13:40","13:45","13:50","13:55", + "14:00","14:05","14:10","14:15","14:20","14:25","14:30","14:35","14:40","14:45","14:50","14:55", + "15:00","15:05","15:10") + $triggers = $times | ForEach-Object { + New-ScheduledTaskTrigger -Weekly -DaysOfWeek $weekdays -At $_ + } + $action = New-ScheduledTaskAction ` + -Execute "powershell.exe" ` + -Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$PROJECT\scripts\run_watchdog.ps1`"" ` + -WorkingDirectory $PROJECT + $settings = New-ScheduledTaskSettingsSet ` + -ExecutionTimeLimit (New-TimeSpan -Minutes 3) ` + -StartWhenAvailable ` + -MultipleInstances IgnoreNew ` + -DontStopIfGoingOnBatteries + $settings.DisallowStartIfOnBatteries = $false + Register-ScheduledTask -TaskName "StockBot_Watchdog" -TaskPath "\StockBot\" ` + -Trigger $triggers -Action $action -Settings $settings -RunLevel Limited -Force | Out-Null + Write-Host "[OK] StockBot_Watchdog 등록 완료 (평일 09:00~15:10, 5분 간격)" -ForegroundColor Green +} +Register-WatchdogTask + # StockBot_Bot 비활성화 유지 (이미 존재할 경우) $botTask = Get-ScheduledTask -TaskName "StockBot_Bot" -TaskPath "\StockBot\" -ErrorAction SilentlyContinue if ($botTask) {