#!/usr/bin/env python3 """ kis_trader/engine/live_sell_price.py — 실매 매도 현재가 ================================================================================ 판정 = WS 읽기 체인(메인 2초 실패→2차→3차). 한투 inquire_price 60초 캐시 금지. 1·2·3 전부 실패일 때만 키움 ka10007 REST 1회(쿨다운). EOD = 마지막 WS → REST → 매수가 폴백. """ from __future__ import annotations import threading import time from typing import Any, Callable, Optional, Tuple from kis_trader.utils.env import get_env_float _stale_rest_lock = threading.Lock() _stale_rest_ts: dict = {} _stale_log_ts: dict = {} # WS/REST 성공 직후 가격 — REST 쿨다운(기본 30s) 동안 재사용 (매도 공백 방지) _last_good_lock = threading.Lock() _last_good_px: dict = {} def _remember_last_good(code: str, px: float, src: str) -> None: code = str(code or "").strip() if not code or px <= 0: return with _last_good_lock: _last_good_px[code] = (float(px), str(src or "WS"), time.time()) def _last_good_price(code: str, max_age_sec: float) -> Tuple[float, str]: code = str(code or "").strip() if not code or max_age_sec <= 0: return 0.0, "" with _last_good_lock: rec = _last_good_px.get(code) if not rec: return 0.0, "" px, src, ts = rec if (time.time() - float(ts)) > float(max_age_sec): return 0.0, "" return float(px), str(src or "") def _stck_prpr(raw: Any) -> float: if raw is None: return 0.0 if isinstance(raw, dict): v = raw.get("stck_prpr") or raw.get("price") or 0 else: v = raw try: return abs(float(str(v).replace(",", ""))) except (TypeError, ValueError): return 0.0 def _ws_chain_price(ws: Any, code: str) -> Tuple[float, str]: """get_price 생략=2초 체인. 성공 시 (px, vendor).""" getter = getattr(ws, "get_price", None) if not callable(getter): return 0.0, "" try: wsd = getter(code) except TypeError: try: wsd = getter(code, max_age_sec=0) except Exception: wsd = None except Exception: wsd = None px = _stck_prpr(wsd) vendor = "" if isinstance(wsd, dict): vendor = str(wsd.get("_feed_vendor") or "").strip() if not vendor: lab = getattr(ws, "get_tick_feed_label", None) if callable(lab): try: vendor = str(lab(code) or "").strip() except Exception: vendor = "" if px > 0: return px, vendor or "WS" return 0.0, "" def _quote_packet_raw(wsd: Any) -> str: """체결시각 원문. FID20 동결 RAM 을 매도 last-RAM 으로 쓰지 않기 위함.""" if not isinstance(wsd, dict): return "" for k in ( "kis_cntg_hour_raw", "chetime", "tick_time", "kiwoom_fid20", "stck_cntg_hour", ): v = str(wsd.get(k) or "").strip() if v: return v return "" def _last_ram_sell_ok(wsd: Any, max_age_sec: float) -> bool: """마지막 RAM: wall 나이·패킷 나이 둘 다 max 이내. 동결 FID20 거부.""" if max_age_sec <= 0 or not isinstance(wsd, dict): return False try: wall = float(wsd.get("_age_ms") or 0) / 1000.0 except (TypeError, ValueError): wall = 0.0 if wall > float(max_age_sec): return False raw = _quote_packet_raw(wsd) if not raw: return True try: from kis_trader.engine.feed_fallback import packet_lag_seconds lag = packet_lag_seconds(raw) except Exception: return True if lag is None: return True return float(lag) <= float(max_age_sec) def _ws_last_and_age(ws: Any, code: str) -> Tuple[float, float]: """마지막 RAM 체결가 + 나이(초). 캐시 없으면 (0, inf). EOD 전용.""" getter = getattr(ws, "get_price", None) if not callable(getter): return 0.0, float("inf") wsd = None try: wsd = getter(code, max_age_sec=None) except TypeError: try: wsd = getter(code) except Exception: wsd = None except Exception: wsd = None px = _stck_prpr(wsd) age = float("inf") if isinstance(wsd, dict): try: age = float(wsd.get("_age_ms") or 0) / 1000.0 except (TypeError, ValueError): age = 0.0 if px > 0 else float("inf") elif px > 0: age = 0.0 return px, age def _kiwoom_rest_once(ws: Any, code: str) -> float: fn = getattr(ws, "inquire_kiwoom_rest_price", None) if not callable(fn): return 0.0 try: return abs(float(fn(code) or 0.0)) except (TypeError, ValueError): return 0.0 except Exception: return 0.0 def _stale_rest_allowed(code: str, cooldown_sec: float) -> bool: now = time.time() with _stale_rest_lock: last = float(_stale_rest_ts.get(code, 0.0) or 0.0) if last > 0.0 and (now - last) < float(cooldown_sec): return False _stale_rest_ts[code] = now return True def _log_throttled(logger: Any, key: str, msg: str, *args: Any) -> None: if logger is None: return now = time.time() last = float(_stale_log_ts.get(key, 0.0) or 0.0) if now - last < 60.0: return _stale_log_ts[key] = now try: logger.info(msg, *args) except Exception: pass def resolve_live_sell_price( ws: Any, inquire_price: Optional[Callable], code: str, *, is_eod: bool = False, fallback_price: float = 0.0, logger: Any = None, ) -> Tuple[float, str]: """ 실매 매도용 현재가. Returns: (price, src) src = kiwoom|kis|ls|kiwoom_rest|EOD_FALLBACK|"" inquire_price 인자는 호환용으로 받지만 한투 60초 캐시는 쓰지 않음. """ code = str(code or "").strip() fb = float(fallback_price or 0.0) stale_sec = float(get_env_float("SELL_WS_STALE_REST_SEC", 1.0) or 0.0) cooldown = float(get_env_float("SELL_WS_STALE_REST_COOLDOWN_SEC", 30.0) or 0.0) if cooldown < 1.0: cooldown = 1.0 _ = inquire_price # 한투 경로 사용 금지 (시그니처 유지) if is_eod: ws_px, _age = _ws_last_and_age(ws, code) if ws_px > 0: _remember_last_good(code, ws_px, "WS") return ws_px, "WS" if stale_sec > 0 and _stale_rest_allowed(code, cooldown): rest_px = _kiwoom_rest_once(ws, code) if rest_px > 0: return rest_px, "kiwoom_rest" if fb > 0: _log_throttled( logger, "eod_fb:" + code, "📌 [EOD가격] %s WS·키움REST 없음 → 매수가 폴백 %.0f (시장가 청산)", code, fb, ) return fb, "EOD_FALLBACK" return 0.0, "" chain_px, vendor = _ws_chain_price(ws, code) if chain_px > 0: _remember_last_good(code, chain_px, vendor or "WS") return chain_px, vendor or "WS" last_max = float(get_env_float("SELL_WS_LAST_RAM_MAX_AGE_SEC", 30.0) or 0.0) if last_max > 0: last_px, last_age = _ws_last_and_age(ws, code) getter = getattr(ws, "get_price", None) wsd_last = None if callable(getter): try: wsd_last = getter(code, max_age_sec=None) except TypeError: wsd_last = None except Exception: wsd_last = None if last_px > 0 and _last_ram_sell_ok(wsd_last, last_max): lab = "" if isinstance(wsd_last, dict): lab = str(wsd_last.get("_feed_vendor") or "").strip() _remember_last_good(code, last_px, lab or "WS_last") _log_throttled( logger, "last_ram:" + code, "📌 [매도시세] %s 3초체인 실패 → last-RAM %.0f (age=%.1fs)", code, last_px, last_age if last_age != float("inf") else -1.0, ) return last_px, "WS_last" cached_px, cached_src = _last_good_price(code, cooldown) if cached_px > 0: _log_throttled( logger, "last_good:" + code, "📌 [매도시세] %s WS체인 실패 → 직전가 유지(%s) %.0f", code, cached_src, cached_px, ) return cached_px, cached_src + "_cached" if stale_sec > 0 and _stale_rest_allowed(code, cooldown): rest_px = _kiwoom_rest_once(ws, code) if rest_px > 0: _remember_last_good(code, rest_px, "kiwoom_rest") _log_throttled( logger, "stale_rest:" + code, "📌 [매도시세] %s 1·2·3차 실패 → 키움 REST(ka10007) %.0f", code, rest_px, ) lab = getattr(ws, "_remember_feed_read", None) if callable(lab): try: lab("tick", code, "kiwoom_rest") except Exception: pass return rest_px, "kiwoom_rest" _log_throttled( logger, "stale_keep:" + code, "📌 [매도시세] %s 키움 REST 실패·쿨다운 — 가격 없음", code, ) return 0.0, ""