Harden scheduler and stale breakout reentry

This commit is contained in:
whdwo
2026-06-15 18:52:42 +09:00
parent eac4ece01e
commit 901243348e
16 changed files with 181 additions and 61 deletions
+16 -3
View File
@@ -55,9 +55,17 @@ class KISClient:
)
self._load_token_from_file()
# 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
# rate limit: KIS occasionally rejects even nominally safe bursts.
# Keep defaults conservative and allow local override from .env.
self._rate_limit = int(os.getenv(
"KIS_MOCK_RATE_LIMIT" if self.is_mock else "KIS_REAL_RATE_LIMIT",
"1" if self.is_mock else "3",
))
self._request_spacing = float(os.getenv(
"KIS_MOCK_REQUEST_SPACING" if self.is_mock else "KIS_REAL_REQUEST_SPACING",
"1.7" if self.is_mock else "0.35",
))
self._cooldown_until = 0.0
self._semaphore = asyncio.Semaphore(1)
self._req_times : list = []
@@ -175,6 +183,9 @@ class KISClient:
async with self._semaphore:
now = time.monotonic()
if now < self._cooldown_until:
await asyncio.sleep(self._cooldown_until - now)
now = time.monotonic()
if self._req_times:
wait = self._request_spacing - (now - self._req_times[-1])
if wait > 0:
@@ -204,6 +215,8 @@ class KISClient:
rt_cd = data.get("rt_cd", "")
if rt_cd != "0":
msg = data.get("msg1", "알 수 없는 오류")
if "초당" in msg or "거래건수" in msg or "rate" in msg.lower():
self._cooldown_until = time.monotonic() + max(2.5, self._request_spacing * 2)
logger.error(f"KIS API 오류 [{tr_id}]: {rt_cd} - {msg}")
raise RuntimeError(f"KIS API 오류: {msg}")
+20 -3
View File
@@ -844,15 +844,28 @@ class StockBot:
async def calc_targets(self):
"""당일 시가 기반 목표가 계산"""
logger.info("목표가 계산 시작")
self.strategy.targets.clear()
self.strategy.today_open.clear()
now_str = datetime.now().strftime("%H:%M")
valid_count = 0
for ticker in self.universe:
try:
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)
open_price = price_info.get("open") or 0
name = self.ticker_names.get(ticker, ticker)
if open_price <= 0:
current = price_info.get("current") or 0
if now_str >= "09:00" and current > 0:
open_price = current
logger.warning(f"시가 0 감지({name}/{ticker}) → 현재가 {current:,}를 임시 시가로 사용")
else:
logger.info(f"목표가 제외({name}/{ticker}): 시가 미확정(open=0)")
await asyncio.sleep(1.1)
continue
self.strategy.set_today_open(ticker, open_price)
target = self.strategy.get_target(ticker)
if target > 0:
logger.info(f"목표가: {name}({ticker}) {target:,.0f}원 [시가 {price_info['open']:,}]")
logger.info(f"목표가: {name}({ticker}) {target:,.0f}원 [시가 {open_price:,}]")
valid_count += 1
await asyncio.sleep(1.1)
except Exception as e:
@@ -1329,6 +1342,9 @@ async def run():
ctx = bot.strategy.load_ai_context()
bot.risk.set_risk_level(ctx.get("risk_level", "보통"))
await bot.update_universe()
if now >= "08:50":
logger.info("08:50 이후 장 전 재시작 감지 → 목표가 즉시 계산")
await bot.calc_targets()
while True:
now = datetime.now().strftime("%H:%M")
@@ -1354,6 +1370,7 @@ async def run():
# 09:00 매매 루프 시작
elif now == "09:00":
await bot.calc_targets()
await bot.trading_loop()
# 15:10 결산
+20 -4
View File
@@ -47,6 +47,7 @@ class VolatilityBreakout:
self._entry_times: dict = {} # ticker → 마지막 진입 datetime (쿨다운 추적)
self._exit_times: dict = {} # ticker -> 마지막 최종 청산 datetime (쿨다운 추적)
self._tp_closed_tickers: set[str] = set() # TP로 전량 청산된 당일 재진입 차단
self._rebreak_required_tickers: set[str] = set() # TIME/FORCE 후 목표가 재돌파 대기
# ── AI 컨텍스트 로드 ──
@@ -103,6 +104,9 @@ class VolatilityBreakout:
if not prev:
logger.info(f"목표가 제외({ticker}): 전일 데이터 없음")
return
if open_price <= 0:
logger.info(f"목표가 제외({ticker}): 당일 시가 미확정({open_price})")
return
if prev["amount"] < MIN_TRADE_AMOUNT:
logger.info(
f"목표가 제외({ticker}): 전일 거래대금 {prev['amount']/1e8:.0f}"
@@ -126,8 +130,10 @@ class VolatilityBreakout:
exit_time = exit_time or datetime.now()
if reason in ("TIME", "FORCE"):
self._exit_times[ticker] = exit_time
self._rebreak_required_tickers.add(ticker)
elif reason in ("TP1", "TP2"):
self._tp_closed_tickers.add(ticker)
self._rebreak_required_tickers.discard(ticker)
# ── 진입 신호 판단 ──
@@ -151,6 +157,16 @@ class VolatilityBreakout:
result["reason"] = "TP 당일 재진입 차단"
return result
# 목표가 확인
target = self.targets.get(ticker, 0)
if target <= 0:
result["reason"] = "목표가 없음"
return result
# TIME/FORCE 이후 쿨다운 중이라도 목표가 아래로 내려온 사실은 기록한다.
if ticker in self._rebreak_required_tickers and current_price < target:
self._rebreak_required_tickers.discard(ticker)
# TIME/FORCE 청산 후 쿨다운은 진입 시각이 아니라 청산 시각 기준이다.
last_exit = self._exit_times.get(ticker)
if last_exit is not None:
@@ -159,10 +175,10 @@ class VolatilityBreakout:
result["reason"] = f"재진입 쿨다운 ({elapsed:.0f}분 / {TICKER_REENTRY_COOLDOWN_MIN}분)"
return result
# 목표가 확인
target = self.targets.get(ticker, 0)
if target <= 0:
result["reason"] = "목표가 없음"
# TIME/FORCE 청산 뒤에는 남아 있는 당일 돌파 신호를 그대로 재사용하지 않는다.
# 목표가 아래로 식은 뒤 다시 돌파해야 새로운 진입 신호로 인정한다.
if ticker in self._rebreak_required_tickers:
result["reason"] = f"재돌파 대기 ({current_price:,} >= {target:,.0f})"
return result
# 기술적 조건: 현재가 >= 목표가