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