[2026-06-01] Fix duplicate bot startup guards

This commit is contained in:
2026-06-01 18:54:52 +09:00
parent 57a0f686e1
commit dd789cfbda
6 changed files with 393 additions and 114 deletions
+12 -66
View File
@@ -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`.
+188 -4
View File
@@ -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 '실거래'}")
+74 -37
View File
@@ -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:
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, 'app/main.py'],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
stdout=open('logs/bot_stderr.log', 'a', encoding='utf-8'),
[sys.executable, "-u", "app/main.py"],
cwd=PROJECT,
creationflags=creationflags,
stdout=log,
stderr=subprocess.STDOUT,
close_fds=True,
env=env,
)
with open(PID_FILE, 'w') as f:
f.write(str(proc.pid))
_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__":
+1 -2
View File
@@ -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"
+1 -1
View File
@@ -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"
+113
View File
@@ -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())