diff --git a/app/config.py b/app/config.py index fff8329..7d399ba 100644 --- a/app/config.py +++ b/app/config.py @@ -12,7 +12,7 @@ TP1_PCT = 0.020 # 1차 익절 +2.0% → 70% 매도 TP2_PCT = 0.025 # 2차 익절 +2.5% → 전량 TP1_RATIO = 0.70 # TP1 시 매도 비율 SL_PCT = 0.020 # 손절 -2.0% -MAX_HOLD_MIN = 120 +MAX_HOLD_MIN = 90 TICKER_REENTRY_COOLDOWN_MIN = 60 # 동일 종목 재진입 금지 시간(분) # ── 리스크 ── diff --git a/app/execution/kis_client.py b/app/execution/kis_client.py index 0d6b253..327b142 100644 --- a/app/execution/kis_client.py +++ b/app/execution/kis_client.py @@ -3,7 +3,7 @@ kis_client.py KIS Open API REST + WebSocket 래퍼 - 토큰 자동 발급/갱신 - 모의투자/실거래 모드 자동 전환 -- rate limit 제어 (초당 20건) +- rate limit 제어 (모드별 요청 간격) """ import os @@ -55,8 +55,9 @@ class KISClient: ) self._load_token_from_file() - # rate limit: 모의투자 1건/초, 실거래 5건/초 + # rate limit: 모의투자 1건/초보다 보수적, 실거래 5건/초 이하 self._rate_limit = 1 if self.is_mock else 5 + self._request_spacing = 1.2 if self.is_mock else 0.22 self._semaphore = asyncio.Semaphore(1) self._req_times : list = [] @@ -158,7 +159,7 @@ class KISClient: ) -> Dict[str, Any]: """ KIS REST API 공통 호출 - - rate limit 제어 (초당 20건) + - rate limit 제어 (모드별 요청 간격) - 토큰 자동 첨부 """ token = await self.get_access_token() @@ -174,11 +175,17 @@ class KISClient: async with self._semaphore: now = time.monotonic() + if self._req_times: + wait = self._request_spacing - (now - self._req_times[-1]) + if wait > 0: + await asyncio.sleep(wait) + now = time.monotonic() self._req_times = [t for t in self._req_times if now - t < 1.0] if len(self._req_times) >= self._rate_limit: wait = 1.0 - (now - self._req_times[0]) if wait > 0: await asyncio.sleep(wait) + now = time.monotonic() self._req_times.append(time.monotonic()) _timeout = aiohttp.ClientTimeout(total=10) diff --git a/app/execution/order_executor.py b/app/execution/order_executor.py index aefc68c..7d8824d 100644 --- a/app/execution/order_executor.py +++ b/app/execution/order_executor.py @@ -31,10 +31,14 @@ class OrderExecutor: qty: int, reason: str = "", ai_boosted: bool = False, + fill_price: float | None = None, ) -> dict: """Submit a market buy and save the opened trade.""" try: - result = await self.kis.order_buy(ticker, qty) + if self.dry_run and fill_price: + result = {"entry_price": fill_price} + else: + result = await self.kis.order_buy(ticker, qty) price = result.get("entry_price", 0) if not price: price = (await self.kis.get_price(ticker))["current"] @@ -64,10 +68,14 @@ class OrderExecutor: name: str, qty: int, reason: str = "", + fill_price: float | None = None, ) -> dict: """Submit a market sell and save full or partial exit results.""" try: - result = await self.kis.order_sell(ticker, qty) + if self.dry_run and fill_price: + result = {"exit_price": fill_price} + else: + result = await self.kis.order_sell(ticker, qty) price = result.get("exit_price", 0) if not price: price = (await self.kis.get_price(ticker))["current"] diff --git a/app/main.py b/app/main.py index 91d4f41..5b786c8 100644 --- a/app/main.py +++ b/app/main.py @@ -165,6 +165,7 @@ class StockBot: self._midday_pos_mult : float = 1.0 # midday position_size_multiplier self._midday_loaded : bool = False self._last_diag : float = 0.0 # 신호 진단 로그 마지막 시각 + self._daily_summary_dates = set() mode = "모의투자" if self.kis.is_mock else "실거래" dry = " [DRY_RUN]" if os.getenv("DRY_RUN","true")=="true" else "" @@ -659,17 +660,22 @@ class StockBot: 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) + @staticmethod + def _is_retryable_price_error(err) -> bool: + msg = str(err).lower() + return StockBot._is_rate_limit_error(err) or "타임아웃" in msg or "timeout" in msg + + async def _get_price_with_retry(self, ticker: str, purpose: str, attempts: int = 4): + delays = (1.3, 2.8, 5.0, 8.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): + if attempt >= attempts or not self._is_retryable_price_error(e): raise wait = delays[min(attempt - 1, len(delays) - 1)] logger.warning( - "%s price retry %s/%s for %s after rate limit: %s", + "%s price retry %s/%s for %s after transient KIS error: %s", purpose, attempt, attempts, @@ -684,11 +690,12 @@ class StockBot: name: str, qty: int, reason: str, + fill_price: float | None = None, 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) + result = await self.executor.sell(ticker, name, qty, reason, fill_price=fill_price) if result.get("success"): return result error = result.get("error", "") @@ -832,7 +839,7 @@ class StockBot: valid_count = 0 for ticker in self.universe: try: - price_info = await self.kis.get_price(ticker) + price_info = await self._get_price_with_retry(ticker, "TARGET") self.strategy.set_today_open(ticker, price_info["open"]) target = self.strategy.get_target(ticker) name = self.ticker_names.get(ticker, ticker) @@ -1025,6 +1032,7 @@ class StockBot: ticker=ticker, name=name, qty=qty, reason=signal["reason"], ai_boosted=signal.get("boosted", False), + fill_price=current, ) if result["success"]: @@ -1148,7 +1156,7 @@ class StockBot: current: float, qty: int, reason: str): """실제 청산 실행""" name = pos["name"] - result = await self._sell_with_retry(ticker, name, qty, reason) + result = await self._sell_with_retry(ticker, name, qty, reason, fill_price=current) if not result["success"]: return @@ -1224,6 +1232,10 @@ class StockBot: async def daily_summary(self): """당일 결산 로그 및 디스코드 알림 + DB 저장""" today = datetime.now().strftime("%Y-%m-%d") + if today in self._daily_summary_dates: + logger.info("결산 이미 처리됨: %s", today) + return + with get_conn() as conn: rows = conn.execute(""" SELECT pnl, fee, exit_reason FROM trades @@ -1254,11 +1266,18 @@ class StockBot: VALUES (?,?,?,?,?,?,?,?,?) """, (today, total, wins, losses, gross_pnl, total_fee, net, mdd, stopped)) - await notify_daily_summary(total, wins, losses, net) + self._daily_summary_dates.add(today) + try: + await notify_daily_summary(total, wins, losses, net) + except Exception as e: + logger.error("결산 Discord 요약 전송 실패: %s", e) if exit_counts: dist = " / ".join(f"{k}:{v}" for k, v in sorted(exit_counts.items())) logger.info("Exit distribution: %s", dist) - await send(f"[청산분포] {dist}") + try: + await send(f"[청산분포] {dist}") + except Exception as e: + logger.error("청산분포 Discord 전송 실패: %s", e) self.risk.reset_daily() logger.info(f"결산: {total}회 / 승{wins} 패{losses} / {net:+,.0f}원 (fee {total_fee:,.0f}원)")