[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
+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 '실거래'}")