""" kis_trader/engine/feed_fallback.py — 실매·옵투나 공통 읽기 나이 헬퍼 ====================================================================== 메인이 지금보다 LIVE_FEED_FALLBACK_MAX_AGE_SEC 초보다 오래면 그 벤더는 없는 것과 같음. 2차(보조 WS) → 3차(LS) → (실매 매도만) 4차 키움 REST. 적재(공책) 임계와 읽기 나이는 분리한다. OHLC 로 틱을 메우지 않음. """ from __future__ import annotations from datetime import datetime from typing import Any, Dict, List, Optional, Sequence, Tuple from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db def packet_lag_seconds(pkt_raw: str, *, now_dt: Optional[datetime] = None) -> Optional[float]: """체결시각 문자열 vs now_dt(기본=서버 지금). 증권사끼리 비교 아님. YYYYMMDDHHMMSS 또는 HHMMSS. 파싱 실패면 None(나이를 모름 → 읽기에서 버리지 않음). """ tt = str(pkt_raw or "").strip() if not tt: return None wall = now_dt if now_dt is not None else datetime.now() pkt_dt = None try: if len(tt) >= 14 and tt[:14].isdigit(): pkt_dt = datetime.strptime(tt[:14], "%Y%m%d%H%M%S") elif len(tt) >= 6 and tt[-6:].isdigit(): pkt_dt = datetime.strptime( wall.strftime("%Y%m%d") + tt[-6:], "%Y%m%d%H%M%S", ) except Exception: return None if pkt_dt is None: return None return float((wall - pkt_dt).total_seconds()) def is_feed_read_stale( lag_sec: Optional[float], max_age_sec: Optional[float] = None, ) -> bool: """읽기 나이 초과. lag/나이를 모르면 False(버리지 않음).""" age = live_feed_fallback_max_age_sec() if max_age_sec is None else float(max_age_sec) if age <= 0 or lag_sec is None: return False try: return float(lag_sec) > age except (TypeError, ValueError): return False def live_feed_fallback_max_age_sec() -> float: """읽기 유효 나이(초). 기본 3. 0 이하면 나이로 벤더를 버리지 않음(레거시).""" try: v = float(get_env_float("LIVE_FEED_FALLBACK_MAX_AGE_SEC", 3.0) or 3.0) except (TypeError, ValueError): v = 3.0 return v def live_tick_primary() -> str: p = str(get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom").strip().lower() return p if p in ("kis", "kiwoom") else "kiwoom" def live_ob_primary() -> str: p = str(get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom").strip().lower() return p if p in ("kis", "kiwoom") else "kiwoom" def vendor_read_max_age_sec(caller_max_age: Optional[float]) -> Optional[float]: """벤더 1회 조회에 쓸 나이. - caller None = 마지막 RAM(명시, 나이 무시) - caller 0/음수 또는 생략 대체 = 폴백 나이(기본 3초) - caller 양수 = min(caller, 폴백나이). 폴백 0이면 caller 그대로. """ fb = live_feed_fallback_max_age_sec() if caller_max_age is None: return None try: c = float(caller_max_age) except (TypeError, ValueError): c = 0.0 if fb <= 0: return None if c <= 0 else c if c <= 0: return fb return min(c, fb) def tick_feed_tier(vendor: str, primary: str) -> int: v = str(vendor or "").strip().lower() p = str(primary or "kiwoom").strip().lower() if v in ("kiwoom_rest", "rest", "ka10007"): return 4 if v == "ls": return 3 if v == p: return 1 return 2 def format_vendor_label(vendor: str, tier: int = 0, spilled: bool = False) -> str: v = str(vendor or "").strip().lower() or "?" names = { "kiwoom": "kiwoom", "kis": "kis", "ls": "ls", "kiwoom_rest": "kiwoom_rest", "rest": "kiwoom_rest", "ka10007": "kiwoom_rest", } name = names.get(v, v) extra: List[str] = [] tmap = {1: "1차", 2: "2차", 3: "3차", 4: "4차"} tlab = tmap.get(int(tier or 0), "") if tlab: extra.append(tlab) if spilled: extra.append("spill") if extra: return f"{name}({','.join(extra)})" return name def format_mm_feed_line(tick_lab: str, ob_lab: str) -> str: t = str(tick_lab or "").strip() or "?" o = str(ob_lab or "").strip() or "?" return f"시세: {t} | 호가: {o}" def _tick_second_key(tick: Dict[str, Any]) -> str: tt = str(tick.get("tick_time") or "")[:14] if len(tt) >= 14: return tt[:14] if len(tt) >= 12: return tt[:12] + "00" return tt def merge_ticks_time_axis_fallback( ticks: Sequence[Dict[str, Any]], *, main_src: str = "kiwoom", max_lag_sec: Optional[float] = None, ) -> List[Dict[str, Any]]: """같은 초에는 메인만. 메인·보조 모두 lag>나이면 그 초는 비움(OHLC 메우지 않음). 분봉에 메인 1건 있다고 보조를 통째로 버리지 않음 (실매 2초 실패→2차와 동일). """ if not ticks: return list(ticks) main = str(main_src or "kiwoom").strip().lower() if main not in ("kis", "kiwoom"): main = "kiwoom" age = live_feed_fallback_max_age_sec() if max_lag_sec is None else float(max_lag_sec) grouped: Dict[str, List[Dict[str, Any]]] = {} order: List[str] = [] for t in ticks: if not isinstance(t, dict): continue sk = _tick_second_key(t) if not sk: continue if sk not in grouped: grouped[sk] = [] order.append(sk) grouped[sk].append(t) out: List[Dict[str, Any]] = [] for sk in order: bucket = grouped[sk] mains: List[Dict[str, Any]] = [] aux: List[Dict[str, Any]] = [] for t in bucket: src = str(t.get("source") or "").strip().lower() lag = t.get("_lag_sec") if is_feed_read_stale(lag, age): continue if src == main: mains.append(t) else: aux.append(t) if mains: out.extend(mains) else: out.extend(aux) return out def orderbook_row_lag_seconds(row: Dict[str, Any]) -> Optional[int]: """recv_ts vs snap_time 초 차이. 파싱 실패면 None(유효로 봄).""" recv_ts = str(row.get("recv_ts") or "").strip() st = str(row.get("snap_time") or "").strip() if len(recv_ts) < 19 or len(st) < 6: return None try: recv_dt = datetime.strptime(recv_ts[:19], "%Y-%m-%d %H:%M:%S") except Exception: return None try: if len(st) >= 14 and st[:14].isdigit(): pkt = datetime.strptime(st[:14], "%Y%m%d%H%M%S") elif len(st) >= 6 and st[-6:].isdigit(): pkt = datetime.strptime(recv_ts[:10].replace("-", "") + st[-6:], "%Y%m%d%H%M%S") else: return None return int((recv_dt - pkt).total_seconds()) except Exception: return None def candle_garbage_fallback_enabled() -> bool: try: return bool(get_env_bool("CANDLE_GARBAGE_FALLBACK", True)) except Exception: return True def bar_end_datetime(candle_time: str, tf_min: int): """봉 시작 candle_time(YYYYMMDDHHMM) + tf → 봉 끝 datetime.""" from kis_trader.engine.candle_rollup import add_candle_minutes end = add_candle_minutes(str(candle_time or "")[:12], int(tf_min or 1)) if not end or len(end) < 12: return None try: return datetime.strptime(end[:12], "%Y%m%d%H%M") except Exception: return None def tick_in_bar_bucket(tick_time: str, candle_time: str, tf_min: int) -> bool: from kis_trader.engine.candle_rollup import add_candle_minutes ct = str(candle_time or "").strip()[:12] raw = str(tick_time or "").strip().replace(":", "").replace("-", "").replace(" ", "") if len(ct) < 12: return False tmin = raw[:12] if len(raw) >= 12 else "" if len(tmin) < 12 or not tmin.isdigit(): return False end = add_candle_minutes(ct, int(tf_min or 1)) if not end: return False return ct <= tmin < end[:12] def bar_is_garbage( ticks: Sequence[Dict[str, Any]], *, candle_time: str, tf_min: int, source: str, missing_policy: str = "hole", ) -> bool: """그 분·그 source 틱이 없거나 전부 봉끝 대비 읽기나이 초과면 True(구멍). missing_policy: hole — 0건이면 쓰레기 (옵투나 공책) keep — 0건이면 유지 (실매 링이 그 분을 커버 못 할 때 호출측에서 keep) 증권사 시계끼리 비교하지 않음. """ if not candle_garbage_fallback_enabled(): return False src = str(source or "").strip().lower() bucket: List[Dict[str, Any]] = [] for t in ticks or []: if not isinstance(t, dict): continue tsrc = str(t.get("source") or "").strip().lower() if tsrc and src and tsrc != src: continue raw = t.get("tick_time_raw") or t.get("tick_time") or "" if tick_in_bar_bucket(str(raw), candle_time, tf_min): bucket.append(t) if not bucket: return str(missing_policy or "hole").strip().lower() == "hole" bar_end = bar_end_datetime(candle_time, tf_min) if bar_end is None: return False for t in bucket: raw = str(t.get("tick_time_raw") or t.get("tick_time") or "") lag = packet_lag_seconds(raw, now_dt=bar_end) if not is_feed_read_stale(lag): return False return True def live_bar_is_garbage( ticks: Sequence[Dict[str, Any]], *, candle_time: str, tf_min: int, source: str, ) -> bool: """실매 링버퍼: 그 소스 틱이 그 분까지 없으면 판정 불가 → False(유지). LS 틱은 TickRecorder 링에 안 들어갈 수 있음. 다른 증권사 틱만으로 cover 하면 LS/2차 봉을 구멍으로 잘못 버린다. 소스별 cover 만 본다. """ if not candle_garbage_fallback_enabled(): return False ct = str(candle_time or "")[:12] if len(ct) < 12: return False src = str(source or "").strip().lower() cover = False for t in ticks or []: if not isinstance(t, dict): continue tsrc = str(t.get("source") or "").strip().lower() if tsrc and src and tsrc != src: continue raw = str(t.get("tick_time_raw") or t.get("tick_time") or "").strip() digits = raw.replace(":", "").replace("-", "").replace(" ", "") tmin = digits[:12] if len(digits) >= 12 else "" if tmin and tmin.isdigit() and tmin <= ct: cover = True break if not cover: return False return bar_is_garbage( ticks, candle_time=candle_time, tf_min=tf_min, source=source, missing_policy="hole", )