[2026-06-01] Fix duplicate bot startup guards
This commit is contained in:
@@ -1,71 +1,17 @@
|
|||||||
# start-bot — 매매 봇 백그라운드 시작
|
# start-bot - deterministic bot startup
|
||||||
|
|
||||||
`app/main.py`를 독립 백그라운드 프로세스로 실행한다.
|
Start the trading bot with the project-owned startup script. Do not inline or rewrite
|
||||||
Claude Code가 종료된 뒤에도 봇은 계속 실행된다.
|
process-management code in this command.
|
||||||
|
|
||||||
## 실행 순서
|
## Steps
|
||||||
|
|
||||||
### 1. 기존 봇 전부 종료 (PID 파일 + 프로세스 스캔 병행)
|
1. Run:
|
||||||
```python
|
```powershell
|
||||||
import subprocess, os
|
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 파일로 종료
|
The script stops existing `app/main.py` processes, starts exactly one new bot,
|
||||||
if os.path.exists(pid_file):
|
waits briefly to verify it is still alive, then writes `logs/bot.pid`.
|
||||||
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. 완료
|
|
||||||
"봇 시작 완료" 메시지를 출력하고 종료한다.
|
|
||||||
|
|||||||
+188
-4
@@ -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:
|
class StockBot:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.kis = KISClient()
|
self.kis = KISClient()
|
||||||
@@ -550,6 +611,99 @@ class StockBot:
|
|||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute("DELETE FROM positions WHERE ticker=?", (ticker,))
|
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)
|
# 유니버스 갱신 (08:30)
|
||||||
# ─────────────────────────────────────────
|
# ─────────────────────────────────────────
|
||||||
@@ -792,6 +946,10 @@ class StockBot:
|
|||||||
if _do_diag:
|
if _do_diag:
|
||||||
_diag.append(f"{ticker}:보유중")
|
_diag.append(f"{ticker}:보유중")
|
||||||
continue
|
continue
|
||||||
|
if self._db_has_open_position(ticker):
|
||||||
|
if _do_diag:
|
||||||
|
_diag.append(f"{ticker}:DB보유중")
|
||||||
|
continue
|
||||||
if ticker in self.sl_tickers:
|
if ticker in self.sl_tickers:
|
||||||
if _do_diag:
|
if _do_diag:
|
||||||
_diag.append(f"{ticker}:SL차단")
|
_diag.append(f"{ticker}:SL차단")
|
||||||
@@ -806,7 +964,8 @@ class StockBot:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
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"]
|
current = price_info["current"]
|
||||||
name = self.ticker_names.get(ticker, ticker)
|
name = self.ticker_names.get(ticker, ticker)
|
||||||
sector = (
|
sector = (
|
||||||
@@ -839,6 +998,19 @@ class StockBot:
|
|||||||
)
|
)
|
||||||
invest = self.risk.get_pos_size(cash, combined_mult)
|
invest = self.risk.get_pos_size(cash, combined_mult)
|
||||||
qty = max(1, int(invest // current))
|
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(
|
self._log_entry_acceptance(
|
||||||
ticker=ticker,
|
ticker=ticker,
|
||||||
name=name,
|
name=name,
|
||||||
@@ -856,6 +1028,7 @@ class StockBot:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
|
reserved = False
|
||||||
entry_price = result["price"] or current
|
entry_price = result["price"] or current
|
||||||
sl_price = entry_price * (1 - self.risk.get_sl_pct())
|
sl_price = entry_price * (1 - self.risk.get_sl_pct())
|
||||||
model_scores = self._score_entry_candidate(
|
model_scores = self._score_entry_candidate(
|
||||||
@@ -913,7 +1086,14 @@ class StockBot:
|
|||||||
boosted=signal.get("boosted", False),
|
boosted=signal.get("boosted", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
if reserved:
|
||||||
|
self._db_delete_position(ticker)
|
||||||
|
reserved = False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if "reserved" in locals() and reserved:
|
||||||
|
self._db_delete_position(ticker)
|
||||||
logger.error(f"진입 체크 오류 {ticker}: {type(e).__name__}: {e}")
|
logger.error(f"진입 체크 오류 {ticker}: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
if _do_diag:
|
if _do_diag:
|
||||||
@@ -931,7 +1111,7 @@ class StockBot:
|
|||||||
"""보유 포지션 청산 신호 확인"""
|
"""보유 포지션 청산 신호 확인"""
|
||||||
for ticker, pos in list(self.positions.items()):
|
for ticker, pos in list(self.positions.items()):
|
||||||
try:
|
try:
|
||||||
price_info = await self.kis.get_price(ticker)
|
price_info = await self._get_price_with_retry(ticker, "EXIT")
|
||||||
current = price_info["current"]
|
current = price_info["current"]
|
||||||
name = pos["name"]
|
name = pos["name"]
|
||||||
|
|
||||||
@@ -968,7 +1148,7 @@ class StockBot:
|
|||||||
current: float, qty: int, reason: str):
|
current: float, qty: int, reason: str):
|
||||||
"""실제 청산 실행"""
|
"""실제 청산 실행"""
|
||||||
name = pos["name"]
|
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"]:
|
if not result["success"]:
|
||||||
return
|
return
|
||||||
@@ -1026,7 +1206,7 @@ class StockBot:
|
|||||||
logger.info("14:50 강제 청산 시작")
|
logger.info("14:50 강제 청산 시작")
|
||||||
for ticker, pos in list(self.positions.items()):
|
for ticker, pos in list(self.positions.items()):
|
||||||
try:
|
try:
|
||||||
price_info = await self.kis.get_price(ticker)
|
price_info = await self._get_price_with_retry(ticker, "FORCE_EXIT")
|
||||||
current = price_info["current"]
|
current = price_info["current"]
|
||||||
await self._do_exit(
|
await self._do_exit(
|
||||||
ticker, pos, current,
|
ticker, pos, current,
|
||||||
@@ -1162,6 +1342,10 @@ async def run():
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
os.makedirs("logs", exist_ok=True)
|
os.makedirs("logs", exist_ok=True)
|
||||||
os.makedirs("data", 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("=" * 50)
|
||||||
logger.info("단타 자동매매 시스템 시작")
|
logger.info("단타 자동매매 시스템 시작")
|
||||||
logger.info(f"모드: {'모의투자' if os.getenv('KIS_MOCK','true')=='true' else '실거래'}")
|
logger.info(f"모드: {'모의투자' if os.getenv('KIS_MOCK','true')=='true' else '실거래'}")
|
||||||
|
|||||||
+78
-41
@@ -1,44 +1,83 @@
|
|||||||
"""
|
import asyncio
|
||||||
watchdog.py — 봇 생존 감시 + 자동 재시작
|
import os
|
||||||
5분마다 실행 (작업 스케줄러) / 장중(09:00~15:10)에만 작동
|
import subprocess
|
||||||
"""
|
import sys
|
||||||
import asyncio, os, subprocess, sys
|
|
||||||
from datetime import datetime
|
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)
|
os.chdir(PROJECT)
|
||||||
sys.path.insert(0, '.')
|
sys.path.insert(0, str(PROJECT))
|
||||||
|
|
||||||
from app.main import load_env
|
from app.main import load_env
|
||||||
|
|
||||||
load_env()
|
load_env()
|
||||||
|
|
||||||
|
|
||||||
def is_process_alive(pid: int) -> bool:
|
def _is_process_alive(pid: int) -> bool:
|
||||||
r = subprocess.run(
|
result = subprocess.run(
|
||||||
['tasklist', '/FI', f'PID eq {pid}', '/NH'],
|
["tasklist", "/FI", f"PID eq {pid}", "/NH"],
|
||||||
capture_output=True, text=True,
|
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:
|
try:
|
||||||
return int(open(PID_FILE).read().strip())
|
return int(PID_FILE.read_text(encoding="utf-8").strip())
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def restart_bot() -> int:
|
def _write_pid(pid: int) -> None:
|
||||||
proc = subprocess.Popen(
|
PID_FILE.parent.mkdir(exist_ok=True)
|
||||||
[sys.executable, 'app/main.py'],
|
PID_FILE.write_text(str(pid), encoding="utf-8")
|
||||||
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
|
|
||||||
stdout=open('logs/bot_stderr.log', 'a', encoding='utf-8'),
|
|
||||||
stderr=subprocess.STDOUT,
|
def _restart_bot() -> int:
|
||||||
close_fds=True,
|
env = os.environ.copy()
|
||||||
)
|
env["PYTHONUNBUFFERED"] = "1"
|
||||||
with open(PID_FILE, 'w') as f:
|
creationflags = 0
|
||||||
f.write(str(proc.pid))
|
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
|
return proc.pid
|
||||||
|
|
||||||
|
|
||||||
@@ -46,35 +85,33 @@ async def main():
|
|||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
now_str = now.strftime("%H:%M")
|
now_str = now.strftime("%H:%M")
|
||||||
|
|
||||||
# 장 외 시간은 체크 안 함
|
|
||||||
if not ("09:00" <= now_str <= "15:10"):
|
if not ("09:00" <= now_str <= "15:10"):
|
||||||
print(f"[{now_str}] 장 외 시간 — 워치독 종료")
|
print(f"[{now_str}] outside trading window - watchdog skipped")
|
||||||
return
|
return
|
||||||
|
|
||||||
from app.monitor.notifier import send
|
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:
|
live_pids = _find_bot_pids()
|
||||||
msg = f"[경고] 봇 PID 파일 없음 — 봇이 실행되지 않은 상태입니다 ({now_str})"
|
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)
|
print(msg)
|
||||||
await send(msg)
|
await send(msg)
|
||||||
new_pid = restart_bot()
|
|
||||||
await send(f"[복구] 봇 자동 재시작 완료 PID={new_pid}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if is_process_alive(pid):
|
msg = f"[긴급] bot process not found (pid={pid}) - restarting"
|
||||||
print(f"[{now_str}] 봇 정상 실행 중 PID={pid}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 봇이 죽어있음
|
|
||||||
msg = f"[긴급] 봇 프로세스 종료 감지 (PID={pid}) — 자동 재시작 시도"
|
|
||||||
print(msg)
|
print(msg)
|
||||||
await send(msg)
|
await send(msg)
|
||||||
|
|
||||||
new_pid = restart_bot()
|
new_pid = _restart_bot()
|
||||||
await send(f"[복구] 봇 자동 재시작 완료 PID={new_pid} ({now_str})")
|
await send(f"[복구] bot restarted PID={new_pid} ({now_str})")
|
||||||
print(f"봇 재시작 완료 PID={new_pid}")
|
print(f"bot restarted PID={new_pid}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+1
-2
@@ -8,7 +8,6 @@ $env:PYTHONIOENCODING = "utf-8"
|
|||||||
$PROJECT = Split-Path -Parent $PSScriptRoot
|
$PROJECT = Split-Path -Parent $PSScriptRoot
|
||||||
$LOG = "$PROJECT\logs\bot_start.log"
|
$LOG = "$PROJECT\logs\bot_start.log"
|
||||||
. "$PROJECT\scripts\stockbot_env.ps1"
|
. "$PROJECT\scripts\stockbot_env.ps1"
|
||||||
$CLAUDE = Resolve-StockBotClaude
|
|
||||||
$PYTHON = Resolve-StockBotPython -Project $PROJECT
|
$PYTHON = Resolve-StockBotPython -Project $PROJECT
|
||||||
$utf8 = New-Object System.Text.UTF8Encoding $false
|
$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"
|
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||||
[System.IO.File]::AppendAllText($LOG, "[$timestamp] /start-bot 실행`n", $utf8)
|
[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) }
|
ForEach-Object { [System.IO.File]::AppendAllText($LOG, "$_`n", $utf8) }
|
||||||
|
|
||||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
|||||||
$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)
|
[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) }
|
ForEach-Object { [System.IO.File]::AppendAllText($LOG, "$_`n", $utf8) }
|
||||||
|
|
||||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user