242 lines
7.8 KiB
Python
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,
|
|
)
|