Files
kis_bot/kis_trader/engine/mid_enroll_entry_gate.py
2026-07-30 18:05:07 +09:00

242 lines
7.8 KiB
Python

"""편입(중분) 시 진입봉 시가가 애매하면 그 봉 매수 보류 — 실매·백테·Optuna 공통.
초등 버전:
- 분이 이미 시작된 뒤에 종목이 들어오면 → 그 분 \"시가\"를 못 봤을 수 있음
- 그때는 그 분으로 사지 말고, 다음 분부터 (기회↓ · 짜가↓)
대전제(틱 없으면 시가 폴백)는 유지. \"틱 필수\"가 아님.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Optional, Tuple, Union
EnrollLike = Union[datetime, float, int, str, None]
def defer_mid_bar_enroll_enabled(params: Optional[dict] = None) -> bool:
"""ON이면 중분 편입 + 같은 진입봉 시가 매수 보류."""
if isinstance(params, dict) and "defer_mid_bar_enroll" in params:
v = params.get("defer_mid_bar_enroll")
if v is not None:
if isinstance(v, bool):
return v
s = str(v).strip().lower()
if s in ("0", "false", "no", "off", ""):
return False
if s in ("1", "true", "yes", "on"):
return True
try:
from kis_trader.utils.env import get_env_bool
return bool(get_env_bool("ALIGN_DEFER_MID_BAR_ENROLL", True))
except Exception:
return True
def parse_enroll_datetime(enroll: EnrollLike) -> Tuple[Optional[datetime], str]:
"""편입 시각 파싱.
Returns:
(datetime|None, precision) precision = \"second\" | \"minute\" | \"\"
- second: 초까지 앎
- minute: 분키만 있음 → 그 분 중 편입으로 보수 처리
"""
if enroll is None:
return None, ""
if isinstance(enroll, datetime):
return enroll, "second"
if isinstance(enroll, (int, float)):
try:
ts = float(enroll)
if ts > 1e12: # ms
ts = ts / 1000.0
return datetime.fromtimestamp(ts), "second"
except Exception:
return None, ""
s = str(enroll).strip()
if not s:
return None, ""
# YYYY-MM-DD HH:MM:SS / YYYYMMDDHHMMSS / YYYYMMDDHHMM
digits = (
s.replace("-", "").replace(":", "").replace(" ", "").replace("T", "")
)
if len(digits) >= 14 and digits[:14].isdigit():
try:
return datetime.strptime(digits[:14], "%Y%m%d%H%M%S"), "second"
except Exception:
return None, ""
if len(digits) >= 12 and digits[:12].isdigit():
try:
return datetime.strptime(digits[:12], "%Y%m%d%H%M"), "minute"
except Exception:
return None, ""
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M:%S"):
try:
dt = datetime.strptime(s[:19], fmt)
prec = "second" if "%S" in fmt else "minute"
return dt, prec
except Exception:
continue
return None, ""
def is_entry_open_ambiguous(
entry_bar_key: str,
enroll: EnrollLike,
*,
tf_min: int = 1,
params: Optional[dict] = None,
) -> bool:
"""진입봉 시가가 편입 때문에 애매하면 True → 매수 보류.
규칙:
bar_open < enroll_dt < bar_end → 애매 (분 중간에 들어옴)
분키만 알면 해당 분 전체를 애매로 봄 (안전빵)
enroll == bar_open 정각(초=0) → 애매 아님
진입봉이 편입 분과 다르면 → 애매 아님 (REST 시가·이미 구독 후 시가 OK)
"""
if not defer_mid_bar_enroll_enabled(params):
return False
ek = str(entry_bar_key or "").replace(":", "").replace("-", "").replace(" ", "")
if len(ek) < 12 or not ek[:12].isdigit():
return False
try:
bar_open = datetime.strptime(ek[:12], "%Y%m%d%H%M")
except Exception:
return False
tf = max(1, int(tf_min or 1))
bar_end = bar_open + timedelta(minutes=tf)
enroll_dt, prec = parse_enroll_datetime(enroll)
if enroll_dt is None:
return False
if prec == "minute":
# 초를 모르면 그 분 편입 = 중분 가능 → 그 분 시가 매수 금지
enroll_min = enroll_dt.replace(second=0, microsecond=0)
return bar_open <= enroll_min < bar_end
# 초 단위: 봉 열린 뒤~닫히기 전에 편입하면 시가 미관측
return bar_open < enroll_dt < bar_end
def gate_reason_mid_enroll(
entry_bar_key: str,
enroll: EnrollLike,
*,
tf_min: int = 1,
params: Optional[dict] = None,
) -> Optional[str]:
"""보류 시 짧은 사유 문자열, 아니면 None."""
if not is_entry_open_ambiguous(
entry_bar_key, enroll, tf_min=tf_min, params=params,
):
return None
enroll_dt, prec = parse_enroll_datetime(enroll)
en_s = enroll_dt.strftime("%Y%m%d%H%M%S") if enroll_dt else str(enroll)
return "탈락-중편입시가애매 entry=%s enroll=%s prec=%s" % (
str(entry_bar_key or "")[:12], en_s, prec or "?",
)
def last_enter_et_from_timeline(
timeline: Any,
code: str,
at_key: str,
) -> Optional[str]:
"""UniverseTimeline 스냅샷에서 code 의 마지막 ENTER event_time 키(14자리)."""
if timeline is None or not code:
return None
keys = getattr(timeline, "_keys", None)
codes_list = getattr(timeline, "_codes", None)
if not keys or not codes_list:
return None
sk = str(at_key or "")
if len(sk) >= 14:
sk14 = sk[:14]
elif len(sk) >= 12:
sk14 = sk[:12] + "59"
else:
return None
cs = str(code).strip()
prev: set = set()
last_enter: Optional[str] = None
for et_key, snap_codes in zip(keys, codes_list):
et = str(et_key)
if et[:14] > sk14:
break
cur = {str(c).strip() for c in (snap_codes or []) if str(c).strip()}
if cs in cur and cs not in prev:
last_enter = et if len(et) >= 14 else (et[:12] + "00")
prev = cur
return last_enter
def first_slot_enter_minute(
universe_by_slot: Optional[dict],
code: str,
at_minute: str,
) -> Optional[str]:
"""분슬롯 dict 에서 code 의 마지막 ENTER 분키 (초 없음→minute precision)."""
if not universe_by_slot or not code:
return None
cs = str(code).strip()
am = str(at_minute or "")[:12]
if len(am) < 12:
return None
prev_has = False
last_enter: Optional[str] = None
for slot in sorted(universe_by_slot.keys()):
minute = str(slot)[:12]
if minute > am:
break
has = cs in {str(c).strip() for c in (universe_by_slot.get(slot) or [])}
if has and not prev_has:
last_enter = minute
prev_has = has
return last_enter
def bt_resolve_enroll(
code: str,
at_key: str,
*,
universe_timeline: Any = None,
universe_by_slot: Optional[dict] = None,
params: Optional[dict] = None,
) -> Optional[str]:
"""백테용 편입 시각 — 타임라인(초) 우선, 없으면 분슬롯."""
if isinstance(params, dict):
ov = (params.get("_code_enroll_et") or {}).get(str(code).strip())
if ov:
return str(ov)
et = last_enter_et_from_timeline(universe_timeline, code, at_key)
if et:
return et
return first_slot_enter_minute(universe_by_slot, code, str(at_key)[:12])
def bt_should_defer_mid_enroll(
entry_bar_key: str,
code: str,
at_key: str,
*,
tf_min: int = 1,
universe_timeline: Any = None,
universe_by_slot: Optional[dict] = None,
params: Optional[dict] = None,
) -> bool:
"""백테 진입 보류 여부 — 실매 ``_defer_mid_enroll_entry`` 와 동일 의미."""
enroll = bt_resolve_enroll(
code,
at_key,
universe_timeline=universe_timeline,
universe_by_slot=universe_by_slot,
params=params,
)
if not enroll:
return False
return is_entry_open_ambiguous(
entry_bar_key, enroll, tf_min=tf_min, params=params,
)