[2026-06-02] 결산 중복과 모의투자 호출 안정화

This commit is contained in:
2026-06-02 18:26:12 +09:00
parent b71e08b498
commit 77ddf6760d
4 changed files with 49 additions and 15 deletions
+1 -1
View File
@@ -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 # 동일 종목 재진입 금지 시간(분)
# ── 리스크 ──
+10 -3
View File
@@ -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)
+10 -2
View File
@@ -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"]
+28 -9
View File
@@ -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}원)")