Harden scheduler and stale breakout reentry
This commit is contained in:
@@ -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
@@ -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 결산
|
||||
|
||||
@@ -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
|
||||
|
||||
# 기술적 조건: 현재가 >= 목표가
|
||||
|
||||
Reference in New Issue
Block a user