""" kis_trader/engine/limit_entry_common.py — ATR 지정가 진입(C안) 공통 꼬리(SHORT)·하락매수(UPDOW): 신호봉 확정 후 지정가 1회 → 유효 N봉 내 low 터치 시 체결, 미체결 시 취소(실매) / 백테 스킵. """ from __future__ import annotations from typing import Any, Dict, List, Optional, Tuple from ..utils.env import get_env_float, get_env_from_db, get_env_int ENTRY_ALIGN = "align" ENTRY_LIMIT_ATR = "limit_atr" def short_entry_mode(params: Optional[Dict[str, Any]] = None) -> str: if params is not None and params.get("entry_mode") is not None: return str(params.get("entry_mode") or ENTRY_ALIGN).strip().lower() return str( get_env_from_db("TAIL_ENTRY_MODE", ENTRY_LIMIT_ATR) or ENTRY_LIMIT_ATR, ).strip().lower() def updow_entry_mode(cfg: Optional[Dict[str, Any]] = None) -> str: if cfg is not None and cfg.get("entry_mode") is not None: return str(cfg.get("entry_mode") or ENTRY_ALIGN).strip().lower() return str( get_env_from_db("UPDOW_ENTRY_MODE", ENTRY_LIMIT_ATR) or ENTRY_LIMIT_ATR, ).strip().lower() def is_limit_atr_entry(mode: str) -> bool: return str(mode or "").strip().lower() in (ENTRY_LIMIT_ATR, "limit", "atr_limit") def _limit_mult(params: Optional[Dict[str, Any]], prefix: str) -> float: key = f"{prefix}_LIMIT_ATR_MULT" if params is not None and params.get("limit_atr_mult") is not None: # 0.0(저점 그대로 체결)도 유효값 — `or 1.5` 로 덮지 않도록 직접 float 변환 raw = params.get("limit_atr_mult") if str(raw).strip() != "": try: return float(raw) except (TypeError, ValueError): pass return get_env_float(key, get_env_float("LIMIT_ATR_MULT_DEFAULT", 1.5)) def _limit_anchor(params: Optional[Dict[str, Any]], prefix: str) -> str: key = f"{prefix}_LIMIT_ANCHOR" if params is not None and params.get("limit_anchor") is not None: return str(params.get("limit_anchor") or "signal_low").strip().lower() return str(get_env_from_db(key, "signal_low") or "signal_low").strip().lower() def _limit_valid_bars(params: Optional[Dict[str, Any]], prefix: str) -> int: key = f"{prefix}_LIMIT_VALID_BARS" if params is not None and params.get("limit_valid_bars") is not None: return max(1, int(params.get("limit_valid_bars") or 1)) return max(1, get_env_int(key, 1)) def _limit_fill_slip_pct(params: Optional[Dict[str, Any]], prefix: str) -> float: key = f"{prefix}_LIMIT_FILL_SLIP_PCT" if params is not None and params.get("limit_fill_slip_pct") is not None: return float(params.get("limit_fill_slip_pct") or 0.0) return get_env_float(key, 0.0) def tail_limit_params(params: Dict[str, Any]) -> Dict[str, Any]: return { "mult": _limit_mult(params, "TAIL"), "anchor": _limit_anchor(params, "TAIL"), "valid_bars": _limit_valid_bars(params, "TAIL"), "fill_slip_pct": _limit_fill_slip_pct(params, "TAIL"), } def updow_limit_params(cfg: Dict[str, Any]) -> Dict[str, Any]: return { "mult": _limit_mult(cfg, "UPDOW"), "anchor": _limit_anchor(cfg, "UPDOW"), "valid_bars": _limit_valid_bars(cfg, "UPDOW"), "fill_slip_pct": _limit_fill_slip_pct(cfg, "UPDOW"), } def floor_limit_price_krw(price: float) -> int: """지정가(원) — 정수 호가.""" if price <= 0: return 0 return max(1, int(price)) def resolve_limit_anchor_price( anchor: str, sig_bar: Dict[str, Any], candles: List[Dict[str, Any]], sig_i: int, ) -> float: """신호봉 기준 anchor 가격.""" mode = (anchor or "signal_low").strip().lower() if mode == "signal_close": return float(sig_bar.get("close") or 0) if mode == "prev_close" and sig_i > 0: return float(candles[sig_i - 1].get("close") or 0) lo = float(sig_bar.get("low") or 0) if lo > 0: return lo return float(sig_bar.get("close") or 0) def compute_atr_limit_price( anchor_px: float, atr: Optional[float], mult: float, *, min_price: float = 0.0, ) -> float: """anchor − ATR×mult 지정가 (매수 대기).""" if anchor_px <= 0: return 0.0 a = float(atr or 0) if a <= 0: a = anchor_px * 0.01 m = float(mult) if float(mult) > 0 else 1.5 lp = anchor_px - a * m if min_price > 0 and lp < min_price: return 0.0 if lp <= 0: return 0.0 return lp def limit_valid_until_bar_key(candles: List[Dict], sig_i: int, valid_bars: int) -> str: """체결 허용 **마지막 봉** 의 candle_time (이 봉까지 low≤지정가면 체결).""" j = sig_i + max(1, int(valid_bars)) if j >= len(candles): j = len(candles) - 1 return str(candles[j].get("candle_time") or "")[:12] def limit_cancel_after_bar_key(valid_until_key: str) -> str: """ 이 키 **초과** 봉이 나오면 미체결 취소. valid_until=신호+1봉(3분 1개) → 그 다음 봉 시각부터 취소. """ return str(valid_until_key or "")[:12] def should_cancel_unfilled_limit( latest_bar_key: str, valid_until_key: str, ) -> bool: """최신 확정봉 시각이 유효 마지막 봉보다 크면 → 유효기간 종료, 취소.""" lb = str(latest_bar_key or "")[:12] vu = str(valid_until_key or "")[:12] if not lb or not vu: return False return lb > vu def try_limit_fill_on_bar( bar: Dict[str, Any], limit_price: float, fill_slip_pct: float = 0.0, ) -> Optional[float]: """백테: 해당 봉 low가 지정가 이하면 체결 (슬리피지는 불리하게만).""" if limit_price <= 0: return None lo = float(bar.get("low") or bar.get("close") or 0) if lo <= 0 or lo > limit_price: return None slip = float(fill_slip_pct or 0.0) if slip > 0: return limit_price * (1.0 + slip / 100.0) return limit_price def merge_limit_into_signal( sig: Dict[str, Any], *, limit_price: float, valid_until_key: str, signal_bar_key: str, ) -> Dict[str, Any]: out = dict(sig) out["entry_mode"] = ENTRY_LIMIT_ATR out["limit_price"] = limit_price out["use_limit_buy"] = True out["valid_until_bar_key"] = valid_until_key out["signal_bar_key"] = signal_bar_key out["entry_price"] = limit_price return out