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:
Your Name
2026-08-27 15:21:21 +09:00
parent ba2f9f0953
commit 5e44b86f8b
24 changed files with 1923 additions and 203 deletions

View File

@@ -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]]: