"""편입(중분) 시 진입봉 시가가 애매하면 그 봉 매수 보류 — 실매·백테·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, )