WS 매니저 spill·조건검색 CNSR 회복·kiwoom_ws_diag 진단 경로를 보강한다. live_sell_price stale RAM/REST 폴백 정합을 유지한다. Co-authored-by: Cursor <cursoragent@cursor.com>
307 lines
9.5 KiB
Python
307 lines
9.5 KiB
Python
#!/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_bool, 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,
|
|
allow_kiwoom_rest: Optional[bool] = 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
|
|
if allow_kiwoom_rest is None:
|
|
allow_kiwoom_rest = bool(get_env_bool("SELL_SCAN_ALLOW_KIWOOM_REST", False))
|
|
_ = 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 allow_kiwoom_rest and 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 allow_kiwoom_rest and 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, ""
|