ls증권 히스토리 구독 넣음
This commit is contained in:
241
kis_trader/engine/mid_enroll_entry_gate.py
Normal file
241
kis_trader/engine/mid_enroll_entry_gate.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""편입(중분) 시 진입봉 시가가 애매하면 그 봉 매수 보류 — 실매·백테·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,
|
||||
)
|
||||
Reference in New Issue
Block a user