From 3f6ff387e2d80dd0738f5eb4cbe6f942643a3f1b Mon Sep 17 00:00:00 2001 From: jongjae Date: Fri, 29 May 2026 18:07:46 +0900 Subject: [PATCH] =?UTF-8?q?[2026-05-29]=20=EC=9E=AC=EC=A7=84=EC=9E=85=20?= =?UTF-8?q?=EC=BF=A8=EB=8B=A4=EC=9A=B4=20=EA=B8=B0=EC=A4=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 34 +++++++++++++++++++++++++++++ app/strategy/volatility_breakout.py | 23 +++++++++++++++---- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/app/main.py b/app/main.py index d7ca8e6..656a123 100644 --- a/app/main.py +++ b/app/main.py @@ -461,6 +461,7 @@ class StockBot: self._restore_positions_from_db() # 당일 SL 종목 복원 (재시작 후에도 재진입 차단 유지) self._restore_sl_tickers_from_db() + self._restore_reentry_controls_from_db() await send(f"[시작] 단타봇 가동 | 예수금: {cash:,}원 | " f"{'모의투자' if self.kis.is_mock else '실거래'}") @@ -498,6 +499,37 @@ class StockBot: if self.sl_tickers: logger.info(f"당일 SL 종목 복원(재진입 차단): {self.sl_tickers}") + def _restore_reentry_controls_from_db(self): + """재시작 시 오늘 청산 이력 기반 재진입 제한 상태를 복원한다.""" + today = datetime.now().strftime("%Y-%m-%d") + with get_conn() as conn: + rows = conn.execute(""" + SELECT ticker, exit_time, exit_reason + FROM trades + WHERE date=? + AND exit_time IS NOT NULL + AND exit_reason IN ('TIME', 'FORCE', 'TP1', 'TP2') + ORDER BY exit_time + """, (today,)).fetchall() + + restored = [] + for ticker, exit_time, reason in rows: + if ticker in self.positions: + continue + try: + exit_dt = datetime.strptime(exit_time, "%H:%M:%S").replace( + year=datetime.now().year, + month=datetime.now().month, + day=datetime.now().day, + ) + except (TypeError, ValueError): + continue + self.strategy.mark_final_exit(ticker, reason, exit_dt) + restored.append(f"{ticker}:{reason}") + + if restored: + logger.info("재진입 제한 상태 복원: %s", ", ".join(restored)) + def _db_save_position(self, ticker: str, pos: dict, target_price: float): with get_conn() as conn: conn.execute(""" @@ -963,6 +995,7 @@ class StockBot: if pos["qty"] <= 0: del self.positions[ticker] self._db_delete_position(ticker) + self.strategy.mark_final_exit(ticker, reason) else: self._db_save_position(ticker, pos, self.strategy.get_target(ticker)) await notify_tp1(ticker, name, pnl_pct) @@ -970,6 +1003,7 @@ class StockBot: elif reason in ("TP2", "SL", "TIME", "FORCE"): del self.positions[ticker] self._db_delete_position(ticker) + self.strategy.mark_final_exit(ticker, reason) if reason == "TP2": await notify_tp2(ticker, name, pnl_pct) elif reason == "SL": diff --git a/app/strategy/volatility_breakout.py b/app/strategy/volatility_breakout.py index 2c99ade..4100e8a 100644 --- a/app/strategy/volatility_breakout.py +++ b/app/strategy/volatility_breakout.py @@ -45,6 +45,8 @@ class VolatilityBreakout: self.today_open = {} # ticker → 당일 시가 self.targets = {} # ticker → 목표가 self._entry_times: dict = {} # ticker → 마지막 진입 datetime (쿨다운 추적) + self._exit_times: dict = {} # ticker -> 마지막 최종 청산 datetime (쿨다운 추적) + self._tp_closed_tickers: set[str] = set() # TP로 전량 청산된 당일 재진입 차단 # ── AI 컨텍스트 로드 ── @@ -119,6 +121,14 @@ class VolatilityBreakout: def get_target(self, ticker: str) -> float: return self.targets.get(ticker, 0.0) + def mark_final_exit(self, ticker: str, reason: str, exit_time: datetime | None = None): + """최종 청산 중 당일 재진입 제한에 필요한 상태를 기록한다.""" + exit_time = exit_time or datetime.now() + if reason in ("TIME", "FORCE"): + self._exit_times[ticker] = exit_time + elif reason in ("TP1", "TP2"): + self._tp_closed_tickers.add(ticker) + # ── 진입 신호 판단 ── def check_entry(self, ticker: str, name: str, @@ -136,10 +146,15 @@ class VolatilityBreakout: result["reason"] = f"진입 시간 외 ({now})" return result - # 동일 종목 재진입 쿨다운 체크 - last_entry = self._entry_times.get(ticker) - if last_entry is not None: - elapsed = (now_dt - last_entry).total_seconds() / 60 + # TP로 전량 청산된 종목은 당일 재진입하지 않는다. + if ticker in self._tp_closed_tickers: + result["reason"] = "TP 당일 재진입 차단" + return result + + # TIME/FORCE 청산 후 쿨다운은 진입 시각이 아니라 청산 시각 기준이다. + last_exit = self._exit_times.get(ticker) + if last_exit is not None: + elapsed = (now_dt - last_exit).total_seconds() / 60 if elapsed < TICKER_REENTRY_COOLDOWN_MIN: result["reason"] = f"재진입 쿨다운 ({elapsed:.0f}분 / {TICKER_REENTRY_COOLDOWN_MIN}분)" return result