fix(시세): LS spill-only·토큰 파일캐시·호가 snap_time 3초컷
- _feed_fallback 미러 OFF, LS cap/grace/hold RAM을 KIS·키움 spill과 정합 - LS 접근토큰 .ls_token_cache_*.json (재시작 재사용, revoke 루프 없음) - 호가 RAM을 틱과 동일 LIVE_FEED_FALLBACK(snap_time)로 컷, 필터 max_age=0은 유지 - 익절 지정가 로그에 실제 호가 벤더(kis/kiwoom/ls 1·2·3차) 표기 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,15 +5,21 @@ kis_trader/ws/orderbook_cache.py — 키움 0D 호가잔량 RAM 캐시
|
||||
매도호가 1~10: 41~50, 매수호가 1~10: 51~60
|
||||
매도수량 1~10: 61~70, 매수수량 1~10: 71~80
|
||||
매도총잔량: 121, 매수총잔량: 125
|
||||
|
||||
RAM 적재: 틱과 동일 — 거래소 시각(snap_time)이 LIVE_FEED_FALLBACK(기본 3초)보다
|
||||
오래면 넣지 않음(ts만 새로 찍어 낡은 호가가 3초 체인을 통과하지 못하게).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger("kis_trader.orderbook_cache")
|
||||
|
||||
|
||||
def _abs_int(v: Any) -> int:
|
||||
try:
|
||||
@@ -22,6 +28,30 @@ def _abs_int(v: Any) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _normalize_ob_snap_time(raw: Any) -> str:
|
||||
"""FID20 / hotime / BSOP_HOUR → YYYYMMDDHHMMSS (가능하면)."""
|
||||
tt = str(raw or "").strip().replace(":", "").replace("-", "").replace(" ", "")
|
||||
if not tt:
|
||||
return ""
|
||||
if len(tt) >= 14 and tt[:14].isdigit():
|
||||
return tt[:14]
|
||||
if len(tt) >= 6 and tt[-6:].isdigit():
|
||||
from datetime import datetime as _dt
|
||||
|
||||
return _dt.now().strftime("%Y%m%d") + tt[-6:]
|
||||
return ""
|
||||
|
||||
|
||||
def _is_snap_time_stale_for_ram(snap: "OrderbookSnapshot") -> bool:
|
||||
"""틱 skip_ram 과 동일 — LIVE_FEED_FALLBACK 기준 snap_time lag."""
|
||||
try:
|
||||
from kis_trader.engine.feed_fallback import is_orderbook_snap_time_stale
|
||||
|
||||
return bool(is_orderbook_snap_time_stale(getattr(snap, "snap_time", "") or ""))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderbookLevel:
|
||||
price: int = 0
|
||||
@@ -152,6 +182,8 @@ def parse_kiwoom_0d_values(code: str, values: Dict[str, Any]) -> OrderbookSnapsh
|
||||
total_bid = sum(b.qty for b in bids)
|
||||
if total_ask <= 0 and asks:
|
||||
total_ask = sum(a.qty for a in asks)
|
||||
# FID20 체결시각(HHMMSS) — 0D에도 실리면 틱과 같은 snap 축. 없으면 "" → lag 컷 스킵.
|
||||
snap_time = _normalize_ob_snap_time(values.get("20") or values.get(20))
|
||||
return OrderbookSnapshot(
|
||||
code=str(code).strip(),
|
||||
asks=asks,
|
||||
@@ -160,6 +192,7 @@ def parse_kiwoom_0d_values(code: str, values: Dict[str, Any]) -> OrderbookSnapsh
|
||||
total_ask_qty=total_ask,
|
||||
ts=time.time(),
|
||||
source="kiwoom_0d",
|
||||
snap_time=snap_time,
|
||||
)
|
||||
|
||||
|
||||
@@ -189,6 +222,52 @@ def _ls_total_qty(body: Dict[str, Any], side: str) -> int:
|
||||
)
|
||||
|
||||
|
||||
def parse_kis_h0stasp0_fields(code: str, fields: List[Any]) -> OrderbookSnapshot:
|
||||
"""한투 실시간 호가 H0STASP0 ``^`` 필드 → ``OrderbookSnapshot``.
|
||||
|
||||
공식 columns (asking_price_krx / MCP):
|
||||
0 MKSC_SHRN_ISCD, 1 BSOP_HOUR, 2 HOUR_CLS_CODE,
|
||||
3~12 ASKP1~10, 13~22 BIDP1~10,
|
||||
23~32 ASKP_RSQN1~10, 33~42 BIDP_RSQN1~10,
|
||||
43 TOTAL_ASKP_RSQN, 44 TOTAL_BIDP_RSQN
|
||||
"""
|
||||
asks: List[OrderbookLevel] = []
|
||||
bids: List[OrderbookLevel] = []
|
||||
n = len(fields or [])
|
||||
for i in range(10):
|
||||
ask_px = _abs_int(fields[3 + i]) if n > 3 + i else 0
|
||||
bid_px = _abs_int(fields[13 + i]) if n > 13 + i else 0
|
||||
ask_qty = _abs_int(fields[23 + i]) if n > 23 + i else 0
|
||||
bid_qty = _abs_int(fields[33 + i]) if n > 33 + i else 0
|
||||
if ask_px > 0:
|
||||
asks.append(OrderbookLevel(price=ask_px, qty=ask_qty))
|
||||
if bid_px > 0:
|
||||
bids.append(OrderbookLevel(price=bid_px, qty=bid_qty))
|
||||
total_ask = _abs_int(fields[43]) if n > 43 else 0
|
||||
total_bid = _abs_int(fields[44]) if n > 44 else 0
|
||||
if total_bid <= 0 and bids:
|
||||
total_bid = sum(b.qty for b in bids)
|
||||
if total_ask <= 0 and asks:
|
||||
total_ask = sum(a.qty for a in asks)
|
||||
|
||||
snap_time = _normalize_ob_snap_time(fields[1] if n > 1 else "")
|
||||
|
||||
code_val = str(code or "").strip()
|
||||
if not code_val and n > 0:
|
||||
code_val = str(fields[0] or "").strip()
|
||||
|
||||
return OrderbookSnapshot(
|
||||
code=code_val,
|
||||
asks=asks,
|
||||
bids=bids,
|
||||
total_bid_qty=total_bid,
|
||||
total_ask_qty=total_ask,
|
||||
ts=time.time(),
|
||||
source="kis_h0stasp0",
|
||||
snap_time=snap_time,
|
||||
)
|
||||
|
||||
|
||||
def parse_ls_hoga_body(
|
||||
code: str,
|
||||
body: Dict[str, Any],
|
||||
@@ -218,13 +297,8 @@ def parse_ls_hoga_body(
|
||||
if total_ask <= 0 and asks:
|
||||
total_ask = sum(a.qty for a in asks)
|
||||
|
||||
hotime = str(body.get("hotime") or "").strip()
|
||||
# UH1 패킷 시각(HHMMSS). 틱동기 저장은 체결 chetime 으로 덮어씀(키움 FID20 과 동일 축).
|
||||
snap_time = ""
|
||||
if len(hotime) >= 6 and hotime[:6].isdigit():
|
||||
from datetime import datetime as _dt
|
||||
|
||||
snap_time = _dt.now().strftime("%Y%m%d") + hotime[:6]
|
||||
snap_time = _normalize_ob_snap_time(body.get("hotime") or "")
|
||||
|
||||
return OrderbookSnapshot(
|
||||
code=str(code).strip(),
|
||||
@@ -245,23 +319,47 @@ class OrderbookCache:
|
||||
self._data: Dict[str, OrderbookSnapshot] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def update_from_kiwoom_0d(self, code: str, values: Dict[str, Any]) -> OrderbookSnapshot:
|
||||
snap = parse_kiwoom_0d_values(code, values)
|
||||
def _commit_if_fresh(self, snap: OrderbookSnapshot) -> Optional[OrderbookSnapshot]:
|
||||
"""거래소 snap_time 이 LIVE_FEED_FALLBACK 초과면 RAM 미반영 (틱 skip_ram 과 동일).
|
||||
|
||||
Returns:
|
||||
저장된 snap. 스킵 시 None (호출측 recorder/DB 도 생략).
|
||||
"""
|
||||
if snap is None or not str(getattr(snap, "code", "") or "").strip():
|
||||
return None
|
||||
if _is_snap_time_stale_for_ram(snap):
|
||||
logger.debug(
|
||||
"호가 RAM skip (snap_time lag) %s src=%s snap=%s",
|
||||
snap.code,
|
||||
getattr(snap, "source", ""),
|
||||
getattr(snap, "snap_time", "") or "-",
|
||||
)
|
||||
return None
|
||||
with self._lock:
|
||||
self._data[snap.code] = snap
|
||||
return snap
|
||||
|
||||
def update_from_kiwoom_0d(
|
||||
self, code: str, values: Dict[str, Any],
|
||||
) -> Optional[OrderbookSnapshot]:
|
||||
snap = parse_kiwoom_0d_values(code, values)
|
||||
return self._commit_if_fresh(snap)
|
||||
|
||||
def update_from_ls_hoga(
|
||||
self,
|
||||
code: str,
|
||||
body: Dict[str, Any],
|
||||
*,
|
||||
source: str = "ls_uh1",
|
||||
) -> OrderbookSnapshot:
|
||||
) -> Optional[OrderbookSnapshot]:
|
||||
snap = parse_ls_hoga_body(code, body, source=source)
|
||||
with self._lock:
|
||||
self._data[snap.code] = snap
|
||||
return snap
|
||||
return self._commit_if_fresh(snap)
|
||||
|
||||
def update_from_kis_h0stasp0(
|
||||
self, code: str, fields: List[Any],
|
||||
) -> Optional[OrderbookSnapshot]:
|
||||
snap = parse_kis_h0stasp0_fields(code, fields)
|
||||
return self._commit_if_fresh(snap)
|
||||
|
||||
def get(self, code: str, max_age_sec: float = 3.0) -> Optional[OrderbookSnapshot]:
|
||||
c = str(code or "").strip()
|
||||
@@ -271,8 +369,13 @@ class OrderbookCache:
|
||||
snap = self._data.get(c)
|
||||
if not snap:
|
||||
return None
|
||||
# 수신 ts 나이 (기존). max_age<=0 = 마지막 RAM(필터용) — ts·snap 컷 안 함.
|
||||
if max_age_sec > 0 and (time.time() - snap.ts) > max_age_sec:
|
||||
return None
|
||||
# 읽기 폴백 나이일 때 거래소 snap 도 틱과 동일 검사 (적재 누락·재시작 잔여 방어).
|
||||
# FILTER_MAX_AGE=0(마지막 RAM) 경로는 여기 안 탐 → 호가필터 구멍 방지 규칙 유지.
|
||||
if max_age_sec > 0 and _is_snap_time_stale_for_ram(snap):
|
||||
return None
|
||||
return snap
|
||||
|
||||
def get_kis_bid_dict(self, code: str, max_age_sec: float = 3.0) -> Optional[Dict[str, Any]]:
|
||||
|
||||
Reference in New Issue
Block a user