[2026-06-01] Fix duplicate bot startup guards
This commit is contained in:
+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:
|
||||
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 '실거래'}")
|
||||
|
||||
Reference in New Issue
Block a user