refactor: enhance Optuna backtesting framework, optimize orderbook filtering, and update database management utilities.
This commit is contained in:
@@ -37,13 +37,18 @@ def _env_flag(key: str, default: bool) -> bool:
|
||||
|
||||
|
||||
def live_universe_slot_align_enabled(strategy_id: str) -> bool:
|
||||
"""실매 후보 ∩ history 슬롯 필터.
|
||||
|
||||
기본 **False** — history 지연/축소 시 전량탈락(universe_wipe) 방지.
|
||||
백테 슬롯 정합이 필요하면 ``LIVE_UNIVERSE_SLOT_ALIGN=true`` 또는 전략별 키로 ON.
|
||||
"""
|
||||
sid = (strategy_id or "").upper()
|
||||
per_key = f"{sid}_LIVE_UNIVERSE_SLOT_ALIGN"
|
||||
if get_env_from_db(per_key, "") not in (None, "", "None"):
|
||||
return _env_flag(per_key, True)
|
||||
return _env_flag(per_key, False)
|
||||
if sid in _UNIVERSE_SLOT_SKIP_DEFAULT:
|
||||
return False
|
||||
return _env_flag("LIVE_UNIVERSE_SLOT_ALIGN", True)
|
||||
return _env_flag("LIVE_UNIVERSE_SLOT_ALIGN", False)
|
||||
|
||||
|
||||
def resolve_live_universe_history_source(
|
||||
@@ -144,6 +149,59 @@ def resolve_live_min_invest_ratio(strategy_id: str) -> float:
|
||||
return min_invest_ratio_of_slot({}, strategy=portfolio_strategy_key(strategy_id))
|
||||
|
||||
|
||||
def _history_snapshot_event_time(
|
||||
db: Any,
|
||||
strategy_id: str,
|
||||
at_time: str,
|
||||
history_source: str,
|
||||
) -> Optional[Any]:
|
||||
"""``at_time`` 이전 최신 ``event_time`` (없으면 None)."""
|
||||
conn = getattr(db, "conn", None)
|
||||
if conn is None:
|
||||
return None
|
||||
table_fn = getattr(db, "_universe_history_table", None)
|
||||
if callable(table_fn):
|
||||
try:
|
||||
table = table_fn(history_source)
|
||||
except Exception:
|
||||
table = None
|
||||
else:
|
||||
table = None
|
||||
if not table:
|
||||
from kis_trader.backtest.universe_history_source import history_table_for_source
|
||||
table = history_table_for_source(history_source)
|
||||
try:
|
||||
row = conn.execute(
|
||||
f"""
|
||||
SELECT MAX(event_time) AS et
|
||||
FROM {table}
|
||||
WHERE strategy_id=%s AND event_time <= %s
|
||||
""",
|
||||
(strategy_id, at_time),
|
||||
).fetchone()
|
||||
return (row or {}).get("et") if row else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _event_time_ymd(et: Any) -> str:
|
||||
if et is None:
|
||||
return ""
|
||||
if hasattr(et, "strftime"):
|
||||
try:
|
||||
return et.strftime("%Y-%m-%d")
|
||||
except Exception:
|
||||
pass
|
||||
s = str(et).strip()
|
||||
if len(s) >= 10 and s[4] == "-" and s[7] == "-":
|
||||
return s[:10]
|
||||
# YYYYMMDDHHMM / YYYYMMDD...
|
||||
digits = "".join(ch for ch in s if ch.isdigit())
|
||||
if len(digits) >= 8:
|
||||
return f"{digits[:4]}-{digits[4:6]}-{digits[6:8]}"
|
||||
return ""
|
||||
|
||||
|
||||
def history_universe_codes_at(
|
||||
db: Any,
|
||||
strategy_id: str,
|
||||
@@ -157,6 +215,9 @@ def history_universe_codes_at(
|
||||
- kiwoom → ``target_candidates_history``
|
||||
- ls → ``ls_candidates_history``
|
||||
스냅샷 없으면 ``None`` (필터 생략 = 실시간 후보 유지).
|
||||
|
||||
**당일 스냅샷만** 사용한다. 주말 재시작·키움 매니저 다운 뒤 남은
|
||||
며칠 전 history 와 sticky 후보를 교집합하면 전원 탈락(11→0) 한다.
|
||||
"""
|
||||
when = when or dt.now()
|
||||
at_time = when.strftime("%Y-%m-%d %H:%M:%S")
|
||||
@@ -170,6 +231,14 @@ def history_universe_codes_at(
|
||||
src = resolve_live_universe_history_source(sid, universe_source=None)
|
||||
if src not in ("ls", "kiwoom"):
|
||||
src = "kiwoom"
|
||||
|
||||
# 당일(캘린더) 스냅샷만 슬롯정합에 쓴다 — 낡은 history 전멸 방지
|
||||
et = _history_snapshot_event_time(db, sid, at_time, src)
|
||||
if not et:
|
||||
return None
|
||||
if _event_time_ymd(et) != when.strftime("%Y-%m-%d"):
|
||||
return None
|
||||
|
||||
getter = getattr(db, "get_universe_at", None)
|
||||
if getter is None:
|
||||
return None
|
||||
|
||||
170
kis_trader/utils/ls_ws_session_windows.py
Normal file
170
kis_trader/utils/ls_ws_session_windows.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
LS WebSocket 세션 시간 분할 (국내 ↔ 해외) — 벽시계 기준
|
||||
======================================================
|
||||
LS 는 KIS approval 과 다름:
|
||||
- 접근토큰 1개 + WS URL 1개로 국내(US3) · 해외(GSC) 동시 구독 가능
|
||||
- 세션 전환 시 **토큰 재발급 금지** (재발급하면 조건검색 REST·WS 공용 토큰이 무효화됨)
|
||||
- 이 모듈은 **소켓 hold / 대기 / 워치독 게이트** 만 담당 (oauth 호출 없음)
|
||||
|
||||
운용 (KIS ``kis_ws_session_windows`` 와 동일 시각 철학):
|
||||
- 국내 hold: 기본 07:00~20:00 (장 전후 여유) — 소켓 유지·국장 워치독은 별도 정규창
|
||||
- 해외 hold: 기본 21:00~06:00 — 해외 구독이 있을 때만 소켓 유지 사유
|
||||
- 갭(20:00~21:00, 06:00~07:00): 소켓 close 후 대기 (**재발급 없음**)
|
||||
|
||||
env (DB 등록):
|
||||
LS_WS_KR_HOLD_START_HM / LS_WS_KR_HOLD_END_HM (기본 700 / 2000)
|
||||
LS_WS_US_HOLD_START_HM / LS_WS_US_HOLD_END_HM (기본 2100 / 600)
|
||||
LS_WS_SESSION_GUARD_SEC
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from .env import get_env_float, get_env_int
|
||||
from .session_hm import hm_in_trading_window
|
||||
|
||||
|
||||
def kr_ws_hold_bounds() -> Tuple[int, int]:
|
||||
"""국내 LS WS 세션 점유 HHMM (당일 구간). 기본 0700~2000."""
|
||||
start = int(get_env_int("LS_WS_KR_HOLD_START_HM", 700) or 700)
|
||||
end = int(get_env_int("LS_WS_KR_HOLD_END_HM", 2000) or 2000)
|
||||
if start <= 0:
|
||||
start = 700
|
||||
if end <= 0:
|
||||
end = 2000
|
||||
return start, end
|
||||
|
||||
|
||||
def us_ws_hold_bounds() -> Tuple[int, int]:
|
||||
"""해외 LS WS 세션 점유 HHMM (자정 넘김). 기본 2100~0600."""
|
||||
start = int(get_env_int("LS_WS_US_HOLD_START_HM", 2100) or 2100)
|
||||
end = int(get_env_int("LS_WS_US_HOLD_END_HM", 600) or 600)
|
||||
if start <= 0:
|
||||
start = 2100
|
||||
if end <= 0:
|
||||
end = 600
|
||||
return start, end
|
||||
|
||||
|
||||
def session_guard_interval_sec() -> float:
|
||||
"""연결 유지 중 hold 창 이탈 감시 주기(초)."""
|
||||
return max(5.0, float(get_env_float("LS_WS_SESSION_GUARD_SEC", 15.0) or 15.0))
|
||||
|
||||
|
||||
def _hm_now(now: Optional[_dt.datetime] = None) -> int:
|
||||
n = now or _dt.datetime.now()
|
||||
return int(n.hour * 100 + n.minute)
|
||||
|
||||
|
||||
def in_kr_ws_hold_window(now: Optional[_dt.datetime] = None) -> bool:
|
||||
"""True = 국내 LS WS 소켓을 유지해도 되는 시간 (월~금)."""
|
||||
n = now or _dt.datetime.now()
|
||||
if n.weekday() >= 5:
|
||||
return False
|
||||
start, end = kr_ws_hold_bounds()
|
||||
return hm_in_trading_window(_hm_now(n), start, end, wrap_midnight=False)
|
||||
|
||||
|
||||
def in_us_ws_hold_window(now: Optional[_dt.datetime] = None) -> bool:
|
||||
"""
|
||||
True = 해외 LS WS 소켓 유지 사유가 되는 시간.
|
||||
- start~23:59: 월~금
|
||||
- 00:00~end: 화~토 (미국장 새벽 마감)
|
||||
"""
|
||||
n = now or _dt.datetime.now()
|
||||
wd = n.weekday()
|
||||
hm = _hm_now(n)
|
||||
start, end = us_ws_hold_bounds()
|
||||
if start > end:
|
||||
if hm >= start:
|
||||
return 0 <= wd <= 4
|
||||
if hm < end:
|
||||
return 1 <= wd <= 5
|
||||
return False
|
||||
if n.weekday() >= 5:
|
||||
return False
|
||||
return hm_in_trading_window(hm, start, end, wrap_midnight=False)
|
||||
|
||||
|
||||
def should_hold_ls_socket(
|
||||
*,
|
||||
n_us_subscribed: int = 0,
|
||||
now: Optional[_dt.datetime] = None,
|
||||
) -> bool:
|
||||
"""소켓을 열어 둘지. 해외 hold 는 해외 구독이 있을 때만."""
|
||||
n = now or _dt.datetime.now()
|
||||
if in_kr_ws_hold_window(n):
|
||||
return True
|
||||
if int(n_us_subscribed or 0) > 0 and in_us_ws_hold_window(n):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def seconds_until_kr_ws_open(now: Optional[_dt.datetime] = None) -> float:
|
||||
"""다음 국내 hold 시작까지 초 (최소 60). hold 중이면 짧은 쿨다운."""
|
||||
n = now or _dt.datetime.now()
|
||||
if in_kr_ws_hold_window(n):
|
||||
return max(30.0, float(get_env_float("LS_WS_HOLD_INNER_COOLDOWN_SEC", 90.0) or 90.0))
|
||||
start, _end = kr_ws_hold_bounds()
|
||||
sh, sm = divmod(int(start), 100)
|
||||
hm = _hm_now(n)
|
||||
target = n.replace(hour=sh, minute=sm, second=0, microsecond=0)
|
||||
if n.weekday() >= 5:
|
||||
days = (0 - n.weekday()) % 7
|
||||
if days == 0:
|
||||
days = 7
|
||||
target += _dt.timedelta(days=days)
|
||||
elif hm >= start:
|
||||
target += _dt.timedelta(days=1)
|
||||
while target.weekday() >= 5:
|
||||
target += _dt.timedelta(days=1)
|
||||
return max(60.0, (target - n).total_seconds())
|
||||
|
||||
|
||||
def seconds_until_us_ws_open(now: Optional[_dt.datetime] = None) -> float:
|
||||
"""다음 해외 hold 시작까지 초 (최소 60). hold 중이면 짧은 쿨다운."""
|
||||
n = now or _dt.datetime.now()
|
||||
if in_us_ws_hold_window(n):
|
||||
return max(30.0, float(get_env_float("LS_WS_HOLD_INNER_COOLDOWN_SEC", 90.0) or 90.0))
|
||||
start, end = us_ws_hold_bounds()
|
||||
sh, sm = divmod(int(start), 100)
|
||||
hm = _hm_now(n)
|
||||
target = n.replace(hour=sh, minute=sm, second=0, microsecond=0)
|
||||
if start > end:
|
||||
if end <= hm < start:
|
||||
if n.weekday() >= 5:
|
||||
days = (0 - n.weekday()) % 7
|
||||
if days == 0:
|
||||
days = 7
|
||||
target += _dt.timedelta(days=days)
|
||||
elif hm >= start:
|
||||
target += _dt.timedelta(days=1)
|
||||
while target.weekday() >= 5:
|
||||
target += _dt.timedelta(days=1)
|
||||
else:
|
||||
if n.weekday() == 6:
|
||||
target += _dt.timedelta(days=1)
|
||||
while target.weekday() >= 5:
|
||||
target += _dt.timedelta(days=1)
|
||||
else:
|
||||
if hm >= start or n.weekday() >= 5:
|
||||
target += _dt.timedelta(days=1)
|
||||
while target.weekday() >= 5:
|
||||
target += _dt.timedelta(days=1)
|
||||
return max(60.0, (target - n).total_seconds())
|
||||
|
||||
|
||||
def seconds_until_ls_socket_open(
|
||||
*,
|
||||
n_us_subscribed: int = 0,
|
||||
now: Optional[_dt.datetime] = None,
|
||||
) -> float:
|
||||
"""다음 소켓 hold 시작까지 초. 해외 구독 없으면 국내만."""
|
||||
n = now or _dt.datetime.now()
|
||||
if should_hold_ls_socket(n_us_subscribed=n_us_subscribed, now=n):
|
||||
return max(30.0, float(get_env_float("LS_WS_HOLD_INNER_COOLDOWN_SEC", 90.0) or 90.0))
|
||||
wait = seconds_until_kr_ws_open(n)
|
||||
if int(n_us_subscribed or 0) > 0:
|
||||
wait = min(wait, seconds_until_us_ws_open(n))
|
||||
return float(wait)
|
||||
170
kis_trader/utils/ops_alert.py
Normal file
170
kis_trader/utils/ops_alert.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
운영 치명 알림 — 예외 삼킴/로그만 하던 인프라 장애를 MM(+선택 TG)로 올린다.
|
||||
|
||||
- 체결·기동 알림과 분리. 채널 기본 = KIS_SYSTEM_MM_CHANNEL.
|
||||
- 코드별 쿨다운 + 전역 최소간격으로 스팸 방지.
|
||||
- 장중 전용 코드는 session gate (주말·장외 오탐 완화).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .env import get_env_bool, get_env_from_db, get_env_int
|
||||
from .logger import atomic_load_json, atomic_save_json, get_logger, msg_mm, msg_tg
|
||||
|
||||
logger = get_logger("kis_trader.ops_alert")
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
_STATE_PATH = _ROOT / "logs" / "ops_alert_state.json"
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
# 장중 세션에서만 의미 있는 코드 (토큰·PANIC·VI 는 상시)
|
||||
_SESSION_CODES = frozenset({
|
||||
"ws_kis_down",
|
||||
"ws_kiwoom_down",
|
||||
"ws_ls_down",
|
||||
"ws_tick_silence",
|
||||
"universe_zero",
|
||||
"universe_wipe",
|
||||
"history_stale",
|
||||
"kwcond_off",
|
||||
"order_buy_reject",
|
||||
"order_sell_reject",
|
||||
"rate_limit",
|
||||
})
|
||||
|
||||
|
||||
def _channel() -> str:
|
||||
ch = (get_env_from_db("OPS_ALERT_MM_CHANNEL", "") or "").strip()
|
||||
if not ch:
|
||||
ch = (get_env_from_db("KIS_SYSTEM_MM_CHANNEL", "default") or "default").strip()
|
||||
return ch or "default"
|
||||
|
||||
|
||||
def _cooldown_sec(code: str) -> int:
|
||||
per = get_env_int(f"OPS_ALERT_COOLDOWN_{code.upper()}_SEC", 0)
|
||||
if per > 0:
|
||||
return max(30, int(per))
|
||||
return max(30, get_env_int("OPS_ALERT_COOLDOWN_SEC", 300))
|
||||
|
||||
|
||||
def _in_kr_session(now: Optional[datetime] = None) -> bool:
|
||||
now = now or datetime.now()
|
||||
if now.weekday() >= 5:
|
||||
return False
|
||||
hm = now.hour * 100 + now.minute
|
||||
start = int(get_env_int("OPS_ALERT_SESSION_START_HM", 900) or 900)
|
||||
end = int(get_env_int("OPS_ALERT_SESSION_END_HM", 1535) or 1535)
|
||||
return start <= hm <= end
|
||||
|
||||
|
||||
def _load_state() -> Dict[str, Any]:
|
||||
st = atomic_load_json(_STATE_PATH, default={})
|
||||
return st if isinstance(st, dict) else {}
|
||||
|
||||
|
||||
def _save_state(st: Dict[str, Any]) -> None:
|
||||
try:
|
||||
_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_save_json(_STATE_PATH, st)
|
||||
except Exception as e:
|
||||
logger.debug("ops_alert state save: %s", e)
|
||||
|
||||
|
||||
def ops_alert(
|
||||
code: str,
|
||||
title: str,
|
||||
*,
|
||||
detail: str = "",
|
||||
level: str = "critical",
|
||||
force: bool = False,
|
||||
session_only: Optional[bool] = None,
|
||||
) -> bool:
|
||||
"""운영 알림 1건. True=발송됨.
|
||||
|
||||
``level``: critical | warn
|
||||
``session_only``: None 이면 코드 기본(장중 전용 집합), True/False 강제.
|
||||
"""
|
||||
if not get_env_bool("OPS_ALERT_ENABLED", True):
|
||||
return False
|
||||
code = str(code or "").strip().lower() or "unknown"
|
||||
title = str(title or "").strip() or code
|
||||
level = str(level or "critical").strip().lower()
|
||||
if level not in ("critical", "warn"):
|
||||
level = "critical"
|
||||
|
||||
need_session = (
|
||||
bool(session_only) if session_only is not None
|
||||
else (code in _SESSION_CODES)
|
||||
)
|
||||
if need_session and not _in_kr_session():
|
||||
return False
|
||||
|
||||
now = time.time()
|
||||
with _LOCK:
|
||||
st = _load_state()
|
||||
by_code = st.setdefault("by_code", {})
|
||||
if not isinstance(by_code, dict):
|
||||
by_code = {}
|
||||
st["by_code"] = by_code
|
||||
last = float(by_code.get(code) or 0.0)
|
||||
cd = _cooldown_sec(code)
|
||||
if not force and (now - last) < cd:
|
||||
return False
|
||||
global_gap = float(get_env_int("OPS_ALERT_GLOBAL_MIN_GAP_SEC", 20) or 20)
|
||||
last_any = float(st.get("last_any_ts") or 0.0)
|
||||
if not force and (now - last_any) < global_gap:
|
||||
return False
|
||||
|
||||
icon = "🚨" if level == "critical" else "⚠️"
|
||||
lines = [
|
||||
f"{icon} **[운영알림/{level.upper()}] {title}**",
|
||||
f"- 코드: `{code}`",
|
||||
f"- 시각: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
]
|
||||
if detail:
|
||||
clipped = str(detail).strip()
|
||||
if len(clipped) > 1200:
|
||||
clipped = clipped[:1200] + "…"
|
||||
lines.append(f"- 상세: {clipped}")
|
||||
body = "\n".join(lines)
|
||||
|
||||
ok = False
|
||||
try:
|
||||
ok = bool(msg_mm(body, channel_alias=_channel(), jitter=False))
|
||||
except Exception as e:
|
||||
logger.debug("ops_alert MM 실패: %s", e)
|
||||
if level == "critical" and get_env_bool("OPS_ALERT_TG_ON_CRITICAL", True):
|
||||
try:
|
||||
msg_tg(body, jitter=False)
|
||||
except Exception as e:
|
||||
logger.debug("ops_alert TG 실패: %s", e)
|
||||
|
||||
by_code[code] = now
|
||||
st["last_any_ts"] = now
|
||||
st["last_code"] = code
|
||||
_save_state(st)
|
||||
logger.warning("[ops_alert] sent code=%s ok_mm=%s title=%s", code, ok, title)
|
||||
return ok
|
||||
|
||||
|
||||
def note_counter(key: str, *, reset: bool = False) -> int:
|
||||
"""연속 실패 카운터 (주문거부·유량 등). reset=True 이면 0."""
|
||||
with _LOCK:
|
||||
st = _load_state()
|
||||
counters = st.setdefault("counters", {})
|
||||
if not isinstance(counters, dict):
|
||||
counters = {}
|
||||
st["counters"] = counters
|
||||
if reset:
|
||||
counters[key] = 0
|
||||
_save_state(st)
|
||||
return 0
|
||||
n = int(counters.get(key) or 0) + 1
|
||||
counters[key] = n
|
||||
_save_state(st)
|
||||
return n
|
||||
@@ -163,6 +163,21 @@ class SafeRequest:
|
||||
if resp.status_code in self.RETRYABLE_STATUSES:
|
||||
if resp.status_code == 429:
|
||||
self._rate_limit_hits += 1
|
||||
try:
|
||||
from kis_trader.utils.ops_alert import note_counter, ops_alert
|
||||
from kis_trader.utils.env import get_env_int
|
||||
streak = note_counter("rate_limit")
|
||||
need = max(1, get_env_int("OPS_ALERT_RATE_LIMIT_STREAK", 5))
|
||||
if streak >= need:
|
||||
ops_alert(
|
||||
"rate_limit",
|
||||
f"HTTP 429 유량 연속 {streak}회",
|
||||
detail=f"{method.upper()} {url}",
|
||||
level="critical",
|
||||
)
|
||||
note_counter("rate_limit", reset=True)
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning(
|
||||
"HTTP %d on %s %s (%d/%d) → 백오프 후 재시도",
|
||||
resp.status_code, method.upper(), url, attempt, self.max_retries,
|
||||
@@ -178,6 +193,21 @@ class SafeRequest:
|
||||
body = None
|
||||
if body and self._is_kis_rate_limited(body):
|
||||
self._rate_limit_hits += 1
|
||||
try:
|
||||
from kis_trader.utils.ops_alert import note_counter, ops_alert
|
||||
from kis_trader.utils.env import get_env_int
|
||||
streak = note_counter("rate_limit")
|
||||
need = max(1, get_env_int("OPS_ALERT_RATE_LIMIT_STREAK", 5))
|
||||
if streak >= need:
|
||||
ops_alert(
|
||||
"rate_limit",
|
||||
f"KIS 유량초과 연속 {streak}회",
|
||||
detail=f"{body.get('msg_cd')} {url}",
|
||||
level="critical",
|
||||
)
|
||||
note_counter("rate_limit", reset=True)
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning(
|
||||
"KIS rate-limit %s on %s (%d/%d) → 백오프 후 재시도",
|
||||
body.get("msg_cd"), url, attempt, self.max_retries,
|
||||
|
||||
Reference in New Issue
Block a user