From dd789cfbdaeed65e9e124fc36963e5f8c0a10bf3 Mon Sep 17 00:00:00 2001 From: jongjae Date: Mon, 1 Jun 2026 18:54:52 +0900 Subject: [PATCH] [2026-06-01] Fix duplicate bot startup guards --- .claude/commands/start-bot.md | 78 +++----------- app/main.py | 192 +++++++++++++++++++++++++++++++++- scripts/_watchdog.py | 119 +++++++++++++-------- scripts/run_bot.ps1 | 3 +- scripts/run_morning.ps1 | 2 +- scripts/start_bot.py | 113 ++++++++++++++++++++ 6 files changed, 393 insertions(+), 114 deletions(-) create mode 100644 scripts/start_bot.py diff --git a/.claude/commands/start-bot.md b/.claude/commands/start-bot.md index 7221f2f..3485abd 100644 --- a/.claude/commands/start-bot.md +++ b/.claude/commands/start-bot.md @@ -1,71 +1,17 @@ -# start-bot — 매매 봇 백그라운드 시작 +# start-bot - deterministic bot startup -`app/main.py`를 독립 백그라운드 프로세스로 실행한다. -Claude Code가 종료된 뒤에도 봇은 계속 실행된다. +Start the trading bot with the project-owned startup script. Do not inline or rewrite +process-management code in this command. -## 실행 순서 +## Steps -### 1. 기존 봇 전부 종료 (PID 파일 + 프로세스 스캔 병행) -```python -import subprocess, os +1. Run: + ```powershell + python scripts\start_bot.py + ``` -pid_file = r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3\logs\bot.pid' +2. Report the script output. A successful run prints the new PID and sends the + Discord start notification. -# 1-a) PID 파일로 종료 -if os.path.exists(pid_file): - try: - pid = int(open(pid_file).read().strip()) - subprocess.run(['taskkill', '/PID', str(pid), '/F'], capture_output=True) - print(f'PID 파일 종료: {pid}') - except Exception as e: - print(f'PID 파일 종료 실패: {e}') - os.remove(pid_file) - -# 1-b) Get-CimInstance로 잔존 프로세스 스캔해서 모두 종료 -r = subprocess.run( - ['powershell', '-Command', - 'Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like "*app/main.py*" -or $_.CommandLine -like "*app\\main.py*" } | Select-Object -ExpandProperty ProcessId'], - capture_output=True, text=True -) -pids = [p.strip() for p in r.stdout.strip().splitlines() if p.strip().isdigit()] -for pid in pids: - subprocess.run(['taskkill', '/PID', pid, '/F'], capture_output=True) - print(f'잔존 프로세스 종료: {pid}') - -if not pids: - print('실행 중인 봇 없음 — 새로 시작합니다') -``` - -### 2. 봇 시작 + PID 저장 -```python -import subprocess, sys, os - -os.chdir(r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3') -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, -) -# PID 파일 저장 (다음 재시작 때 확실히 종료하기 위해) -with open('logs/bot.pid', 'w') as f: - f.write(str(proc.pid)) -print(f'봇 시작 완료 PID={proc.pid}') -``` - -### 3. Discord 시작 알림 -```python -import asyncio, sys, os -sys.path.insert(0, '.') -from app.main import load_env; load_env() -from app.monitor.notifier import send -mode = os.getenv('KIS_MOCK', 'true') -dry = os.getenv('DRY_RUN', 'true') -label = '[모의투자]' if mode == 'true' else '[실거래]' -asyncio.run(send(f'{label} 자동매매 봇 시작 (DRY_RUN={dry})')) -print('Discord 알림 전송 완료') -``` - -### 4. 완료 -"봇 시작 완료" 메시지를 출력하고 종료한다. +The script stops existing `app/main.py` processes, starts exactly one new bot, +waits briefly to verify it is still alive, then writes `logs/bot.pid`. diff --git a/app/main.py b/app/main.py index 656a123..91d4f41 100644 --- a/app/main.py +++ b/app/main.py @@ -85,6 +85,67 @@ from app.config import ( ) +class SingleInstanceLock: + """Process-wide lock so only one StockBot can run per workspace.""" + + def __init__(self, path: str | Path): + self.path = Path(path) + self._fh = None + self._mode = None + + def acquire(self) -> bool: + self.path.parent.mkdir(parents=True, exist_ok=True) + self._fh = open(self.path, "a+", encoding="utf-8") + if os.name == "nt": + import msvcrt + + self._fh.seek(0, os.SEEK_END) + if self._fh.tell() == 0: + self._fh.write("0") + self._fh.flush() + self._fh.seek(0) + try: + msvcrt.locking(self._fh.fileno(), msvcrt.LK_NBLCK, 1) + except OSError: + self._fh.close() + self._fh = None + return False + self._mode = "msvcrt" + else: + import fcntl + + try: + fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + self._fh.close() + self._fh = None + return False + self._mode = "fcntl" + + self._fh.seek(0) + self._fh.truncate() + self._fh.write(str(os.getpid())) + self._fh.flush() + return True + + def release(self): + if self._fh is None: + return + try: + if self._mode == "msvcrt": + import msvcrt + + self._fh.seek(0) + msvcrt.locking(self._fh.fileno(), msvcrt.LK_UNLCK, 1) + elif self._mode == "fcntl": + import fcntl + + fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN) + finally: + self._fh.close() + self._fh = None + + class StockBot: def __init__(self): self.kis = KISClient() @@ -550,6 +611,99 @@ class StockBot: with get_conn() as conn: conn.execute("DELETE FROM positions WHERE ticker=?", (ticker,)) + def _db_has_open_position(self, ticker: str) -> bool: + with get_conn() as conn: + row = conn.execute( + "SELECT 1 FROM positions WHERE ticker=?", + (ticker,), + ).fetchone() + return row is not None + + def _db_reserve_position( + self, + ticker: str, + name: str, + entry_price: float, + qty: int, + target_price: float, + stop_price: float, + ai_boosted: bool, + ) -> bool: + """Atomically reserve a ticker before sending a buy order.""" + with get_conn() as conn: + cur = conn.execute(""" + INSERT OR IGNORE INTO positions + (ticker, name, entry_time, entry_price, quantity, + tp1_done, target_price, stop_price, ai_boosted) + VALUES (?,?,?,?,?,?,?,?,?) + """, ( + ticker, + name, + datetime.now().strftime("%H:%M:%S"), + entry_price, + qty, + 0, + target_price, + stop_price, + 1 if ai_boosted else 0, + )) + return cur.rowcount == 1 + + @staticmethod + def _is_rate_limit_error(err) -> bool: + msg = str(err) + return ( + "초당" in msg + or "거래건수" in msg + or "rate limit" in msg.lower() + or "too many" in msg.lower() + ) + + async def _get_price_with_retry(self, ticker: str, purpose: str, attempts: int = 3): + delays = (1.2, 2.5, 5.0) + for attempt in range(1, attempts + 1): + try: + return await self.kis.get_price(ticker) + except Exception as e: + if attempt >= attempts or not self._is_rate_limit_error(e): + raise + wait = delays[min(attempt - 1, len(delays) - 1)] + logger.warning( + "%s price retry %s/%s for %s after rate limit: %s", + purpose, + attempt, + attempts, + ticker, + e, + ) + await asyncio.sleep(wait) + + async def _sell_with_retry( + self, + ticker: str, + name: str, + qty: int, + reason: str, + attempts: int = 3, + ) -> dict: + delays = (1.2, 2.5, 5.0) + for attempt in range(1, attempts + 1): + result = await self.executor.sell(ticker, name, qty, reason) + if result.get("success"): + return result + error = result.get("error", "") + if attempt >= attempts or not self._is_rate_limit_error(error): + return result + wait = delays[min(attempt - 1, len(delays) - 1)] + logger.warning( + "SELL retry %s/%s for %s after rate limit: %s", + attempt, + attempts, + ticker, + error, + ) + await asyncio.sleep(wait) + # ───────────────────────────────────────── # 유니버스 갱신 (08:30) # ───────────────────────────────────────── @@ -792,6 +946,10 @@ class StockBot: if _do_diag: _diag.append(f"{ticker}:보유중") continue + if self._db_has_open_position(ticker): + if _do_diag: + _diag.append(f"{ticker}:DB보유중") + continue if ticker in self.sl_tickers: if _do_diag: _diag.append(f"{ticker}:SL차단") @@ -806,7 +964,8 @@ class StockBot: continue try: - price_info = await self.kis.get_price(ticker) # rate limiter가 자동 throttle + reserved = False + price_info = await self._get_price_with_retry(ticker, "ENTRY") current = price_info["current"] name = self.ticker_names.get(ticker, ticker) sector = ( @@ -839,6 +998,19 @@ class StockBot: ) invest = self.risk.get_pos_size(cash, combined_mult) qty = max(1, int(invest // current)) + reserve_stop = current * (1 - self.risk.get_sl_pct()) + if not self._db_reserve_position( + ticker=ticker, + name=name, + entry_price=current, + qty=qty, + target_price=target, + stop_price=reserve_stop, + ai_boosted=signal.get("boosted", False), + ): + logger.info("ENTRY blocked: DB active position exists for %s", ticker) + continue + reserved = True self._log_entry_acceptance( ticker=ticker, name=name, @@ -856,6 +1028,7 @@ class StockBot: ) if result["success"]: + reserved = False entry_price = result["price"] or current sl_price = entry_price * (1 - self.risk.get_sl_pct()) model_scores = self._score_entry_candidate( @@ -913,7 +1086,14 @@ class StockBot: boosted=signal.get("boosted", False), ) + else: + if reserved: + self._db_delete_position(ticker) + reserved = False + except Exception as e: + if "reserved" in locals() and reserved: + self._db_delete_position(ticker) logger.error(f"진입 체크 오류 {ticker}: {type(e).__name__}: {e}") if _do_diag: @@ -931,7 +1111,7 @@ class StockBot: """보유 포지션 청산 신호 확인""" for ticker, pos in list(self.positions.items()): try: - price_info = await self.kis.get_price(ticker) + price_info = await self._get_price_with_retry(ticker, "EXIT") current = price_info["current"] name = pos["name"] @@ -968,7 +1148,7 @@ class StockBot: current: float, qty: int, reason: str): """실제 청산 실행""" name = pos["name"] - result = await self.executor.sell(ticker, name, qty, reason) + result = await self._sell_with_retry(ticker, name, qty, reason) if not result["success"]: return @@ -1026,7 +1206,7 @@ class StockBot: logger.info("14:50 강제 청산 시작") for ticker, pos in list(self.positions.items()): try: - price_info = await self.kis.get_price(ticker) + price_info = await self._get_price_with_retry(ticker, "FORCE_EXIT") current = price_info["current"] await self._do_exit( ticker, pos, current, @@ -1162,6 +1342,10 @@ async def run(): if __name__ == "__main__": os.makedirs("logs", exist_ok=True) os.makedirs("data", exist_ok=True) + instance_lock = SingleInstanceLock("logs/stockbot.lock") + if not instance_lock.acquire(): + logger.error("StockBot already running; duplicate process exiting") + sys.exit(2) logger.info("=" * 50) logger.info("단타 자동매매 시스템 시작") logger.info(f"모드: {'모의투자' if os.getenv('KIS_MOCK','true')=='true' else '실거래'}") diff --git a/scripts/_watchdog.py b/scripts/_watchdog.py index f3607e9..75a0273 100644 --- a/scripts/_watchdog.py +++ b/scripts/_watchdog.py @@ -1,44 +1,83 @@ -""" -watchdog.py — 봇 생존 감시 + 자동 재시작 -5분마다 실행 (작업 스케줄러) / 장중(09:00~15:10)에만 작동 -""" -import asyncio, os, subprocess, sys +import asyncio +import os +import subprocess +import sys from datetime import datetime +from pathlib import Path -PROJECT = r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3' -PID_FILE = os.path.join(PROJECT, 'logs', 'bot.pid') + +PROJECT = Path(__file__).resolve().parents[1] +PID_FILE = PROJECT / "logs" / "bot.pid" os.chdir(PROJECT) -sys.path.insert(0, '.') +sys.path.insert(0, str(PROJECT)) + 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, +def _is_process_alive(pid: int) -> bool: + result = subprocess.run( + ["tasklist", "/FI", f"PID eq {pid}", "/NH"], + capture_output=True, + text=True, ) - return str(pid) in r.stdout + return str(pid) in result.stdout -def get_pid() -> int | None: +def _find_bot_pids() -> list[int]: + command = ( + "Get-CimInstance Win32_Process | " + "Where-Object { $_.Name -like 'python*' -and " + "($_.CommandLine -like '*app/main.py*' " + "-or $_.CommandLine -like '*app\\\\main.py*') } | " + "Select-Object -ExpandProperty ProcessId" + ) + result = subprocess.run( + ["powershell", "-NoProfile", "-Command", command], + capture_output=True, + text=True, + ) + pids: list[int] = [] + for line in result.stdout.splitlines(): + line = line.strip() + if line.isdigit(): + pids.append(int(line)) + return pids + + +def _get_pid() -> int | None: try: - return int(open(PID_FILE).read().strip()) + return int(PID_FILE.read_text(encoding="utf-8").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)) +def _write_pid(pid: int) -> None: + PID_FILE.parent.mkdir(exist_ok=True) + PID_FILE.write_text(str(pid), encoding="utf-8") + + +def _restart_bot() -> int: + env = os.environ.copy() + env["PYTHONUNBUFFERED"] = "1" + creationflags = 0 + if hasattr(subprocess, "DETACHED_PROCESS"): + creationflags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP + + log_path = PROJECT / "logs" / "bot_stderr.log" + with open(log_path, "a", encoding="utf-8") as log: + proc = subprocess.Popen( + [sys.executable, "-u", "app/main.py"], + cwd=PROJECT, + creationflags=creationflags, + stdout=log, + stderr=subprocess.STDOUT, + close_fds=True, + env=env, + ) + _write_pid(proc.pid) return proc.pid @@ -46,35 +85,33 @@ async def main(): now = datetime.now() now_str = now.strftime("%H:%M") - # 장 외 시간은 체크 안 함 if not ("09:00" <= now_str <= "15:10"): - print(f"[{now_str}] 장 외 시간 — 워치독 종료") + print(f"[{now_str}] outside trading window - watchdog skipped") return from app.monitor.notifier import send - pid = get_pid() + pid = _get_pid() + if pid is not None and _is_process_alive(pid): + print(f"[{now_str}] bot running PID={pid}") + return - if pid is None: - msg = f"[경고] 봇 PID 파일 없음 — 봇이 실행되지 않은 상태입니다 ({now_str})" + live_pids = _find_bot_pids() + if live_pids: + recovered_pid = live_pids[0] + _write_pid(recovered_pid) + msg = f"[복구] bot.pid corrected to running bot PID={recovered_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}) — 자동 재시작 시도" + msg = f"[긴급] bot process not found (pid={pid}) - restarting" print(msg) await send(msg) - new_pid = restart_bot() - await send(f"[복구] 봇 자동 재시작 완료 PID={new_pid} ({now_str})") - print(f"봇 재시작 완료 PID={new_pid}") + new_pid = _restart_bot() + await send(f"[복구] bot restarted PID={new_pid} ({now_str})") + print(f"bot restarted PID={new_pid}") if __name__ == "__main__": diff --git a/scripts/run_bot.ps1 b/scripts/run_bot.ps1 index 7870bc1..1f45c93 100644 --- a/scripts/run_bot.ps1 +++ b/scripts/run_bot.ps1 @@ -8,7 +8,6 @@ $env:PYTHONIOENCODING = "utf-8" $PROJECT = Split-Path -Parent $PSScriptRoot $LOG = "$PROJECT\logs\bot_start.log" . "$PROJECT\scripts\stockbot_env.ps1" -$CLAUDE = Resolve-StockBotClaude $PYTHON = Resolve-StockBotPython -Project $PROJECT $utf8 = New-Object System.Text.UTF8Encoding $false @@ -25,7 +24,7 @@ if ($LASTEXITCODE -ne 0) { $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" [System.IO.File]::AppendAllText($LOG, "[$timestamp] /start-bot 실행`n", $utf8) -& $CLAUDE -p "/start-bot" --dangerously-skip-permissions 2>&1 | +& $PYTHON "scripts\start_bot.py" 2>&1 | ForEach-Object { [System.IO.File]::AppendAllText($LOG, "$_`n", $utf8) } $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" diff --git a/scripts/run_morning.ps1 b/scripts/run_morning.ps1 index 5ce3e35..d3b52a4 100644 --- a/scripts/run_morning.ps1 +++ b/scripts/run_morning.ps1 @@ -37,7 +37,7 @@ $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" [System.IO.File]::AppendAllText($LOG, "[$timestamp] /start-bot 시작`n", $utf8) -& $CLAUDE -p "/start-bot" --dangerously-skip-permissions 2>&1 | +& $PYTHON "scripts\start_bot.py" 2>&1 | ForEach-Object { [System.IO.File]::AppendAllText($LOG, "$_`n", $utf8) } $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" diff --git a/scripts/start_bot.py b/scripts/start_bot.py new file mode 100644 index 0000000..669c2dd --- /dev/null +++ b/scripts/start_bot.py @@ -0,0 +1,113 @@ +import asyncio +import os +import subprocess +import sys +import time +from pathlib import Path + + +PROJECT = Path(__file__).resolve().parents[1] +PID_FILE = PROJECT / "logs" / "bot.pid" +LOG_FILE = PROJECT / "logs" / "bot_stderr.log" + + +def _taskkill(pid: int) -> None: + subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True, text=True) + + +def _find_bot_pids() -> list[int]: + command = ( + "Get-CimInstance Win32_Process | " + "Where-Object { $_.Name -like 'python*' -and " + "($_.CommandLine -like '*app/main.py*' " + "-or $_.CommandLine -like '*app\\\\main.py*') } | " + "Select-Object -ExpandProperty ProcessId" + ) + result = subprocess.run( + ["powershell", "-NoProfile", "-Command", command], + capture_output=True, + text=True, + ) + pids: list[int] = [] + for line in result.stdout.splitlines(): + line = line.strip() + if line.isdigit(): + pids.append(int(line)) + return pids + + +def _kill_existing_bots() -> None: + killed: set[int] = set() + if PID_FILE.exists(): + try: + pid = int(PID_FILE.read_text(encoding="utf-8").strip()) + _taskkill(pid) + killed.add(pid) + print(f"PID file process stopped: {pid}") + except Exception as exc: + print(f"PID file stop skipped: {exc}") + PID_FILE.unlink(missing_ok=True) + + for pid in _find_bot_pids(): + if pid in killed: + continue + _taskkill(pid) + killed.add(pid) + print(f"Existing bot process stopped: {pid}") + + if not killed: + print("No existing bot process found") + + +def _start_bot() -> int: + PROJECT.joinpath("logs").mkdir(exist_ok=True) + env = os.environ.copy() + env["PYTHONUNBUFFERED"] = "1" + creationflags = 0 + if hasattr(subprocess, "DETACHED_PROCESS"): + creationflags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP + + with open(LOG_FILE, "a", encoding="utf-8") as log: + proc = subprocess.Popen( + [sys.executable, "-u", "app/main.py"], + cwd=PROJECT, + creationflags=creationflags, + stdout=log, + stderr=subprocess.STDOUT, + close_fds=True, + env=env, + ) + + time.sleep(2) + if proc.poll() is not None: + raise RuntimeError(f"bot process exited during startup: returncode={proc.returncode}") + + PID_FILE.write_text(str(proc.pid), encoding="utf-8") + return proc.pid + + +async def _notify_start() -> None: + sys.path.insert(0, str(PROJECT)) + from app.main import load_env + + load_env() + from app.monitor.notifier import send + + mode = os.getenv("KIS_MOCK", "true") + dry = os.getenv("DRY_RUN", "true") + label = "[모의투자]" if mode == "true" else "[실거래]" + await send(f"{label} 자동매매 봇 시작 (DRY_RUN={dry})") + + +def main() -> int: + os.chdir(PROJECT) + _kill_existing_bots() + pid = _start_bot() + print(f"Bot started PID={pid}") + asyncio.run(_notify_start()) + print("Discord start notification sent") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())