Files
kis_bot/kis_trader/ws/orderbook_cache.py
Your Name 0ecac7cb95 이번에 들어간 내용
한투 호가 = 2번째 앱키 전용
키 없거나 start 실패 시 메인에 H0STASP0 안 붙임. 운영설정 WS_ORDERBOOK_SAVE_KIS 빨간 danger.

LS RAM 합집합
후보∪보유∪영구∪grace. sync_targets와 split reconcile 둘 다. 틱 DB 영구 게이트는 그대로.

분봉 쓰레기 → 다음 소스 봉 통째
그 분 틱 0건이거나 전부 봉끝 대비 LIVE_FEED_FALLBACK_MAX_AGE_SEC 초과면 구멍. 메인 WS → 2차 → LS → REST → rollup. CANDLE_GARBAGE_FALLBACK 기본 true.

파일: feed_fallback.py(신규), ws_manager.py, kis_ws.py, candle_series.py, bt_candle_source.py, live_config_schema.py, database.py, 스모크, MD 2개.

같은 ws_manager/database/kis_ws/live_config에는 직전 커밋 이후 쌓여 있던 시세 폴백·ENV 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
2026-08-19 22:11:31 +09:00

288 lines
9.5 KiB
Python

"""
kis_trader/ws/orderbook_cache.py — 키움 0D 호가잔량 RAM 캐시
================================================================
키움 OpenAPI+ FID (주식호가잔량 0D):
매도호가 1~10: 41~50, 매수호가 1~10: 51~60
매도수량 1~10: 61~70, 매수수량 1~10: 71~80
매도총잔량: 121, 매수총잔량: 125
"""
from __future__ import annotations
import json
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
def _abs_int(v: Any) -> int:
try:
return int(abs(float(str(v or "0").replace(",", ""))))
except (TypeError, ValueError):
return 0
@dataclass
class OrderbookLevel:
price: int = 0
qty: int = 0
@dataclass
class OrderbookSnapshot:
"""종목 1개 호가 스냅샷 (매수호가=비싼 순, 매도호가=싼 순)."""
code: str
bids: List[OrderbookLevel] = field(default_factory=list)
asks: List[OrderbookLevel] = field(default_factory=list)
total_bid_qty: int = 0
total_ask_qty: int = 0
ts: float = 0.0
source: str = "kiwoom_0d"
snap_time: str = "" # YYYYMMDDHHMMSS — DB·판정 스냅 재생용
def best_bid(self) -> int:
return self.bids[0].price if self.bids else 0
def best_ask(self) -> int:
return self.asks[0].price if self.asks else 0
def spread_pct(self) -> float:
bb, ba = self.best_bid(), self.best_ask()
if bb <= 0 or ba <= 0:
return 999.0
mid = (bb + ba) / 2.0
if mid <= 0:
return 999.0
return (ba - bb) / mid * 100.0
def bid_qty_sum(self, levels: int = 3) -> int:
n = max(1, int(levels))
return sum(lv.qty for lv in self.bids[:n])
def ask_qty_sum(self, levels: int = 3) -> int:
n = max(1, int(levels))
return sum(lv.qty for lv in self.asks[:n])
def to_kis_bid_dict(self, levels: int = 10) -> Dict[str, Any]:
"""``orderbook_sell.parse_kis_bid_levels`` 호환 dict."""
out: Dict[str, Any] = {}
for i, lv in enumerate(self.bids[: max(1, levels)], start=1):
out[f"bidp{i}"] = lv.price
out[f"bidp_rsqn{i}"] = lv.qty
return out
def to_storage_dict(self) -> Dict[str, Any]:
"""``ws_orderbook`` INSERT·백테 복원용."""
return {
"code": self.code,
"best_bid": self.best_bid(),
"best_ask": self.best_ask(),
"total_bid_qty": self.total_bid_qty,
"total_ask_qty": self.total_ask_qty,
"bid_qty_l3": self.bid_qty_sum(3),
"ask_qty_l3": self.ask_qty_sum(3),
"levels_json": json.dumps(
{
"bids": [[lv.price, lv.qty] for lv in self.bids[:10]],
"asks": [[lv.price, lv.qty] for lv in self.asks[:10]],
},
ensure_ascii=False,
separators=(",", ":"),
),
"source": self.source,
"ts": self.ts,
"snap_time": self.snap_time or "",
}
def orderbook_snapshot_from_storage(row: Dict[str, Any]) -> OrderbookSnapshot:
"""DB 행 또는 storage dict → ``OrderbookSnapshot``."""
bids: List[OrderbookLevel] = []
asks: List[OrderbookLevel] = []
raw = row.get("levels_json")
if raw:
try:
lv = json.loads(raw) if isinstance(raw, str) else raw
for px, qty in (lv.get("bids") or []):
bids.append(OrderbookLevel(price=_abs_int(px), qty=_abs_int(qty)))
for px, qty in (lv.get("asks") or []):
asks.append(OrderbookLevel(price=_abs_int(px), qty=_abs_int(qty)))
except (TypeError, ValueError, json.JSONDecodeError):
pass
total_bid = _abs_int(row.get("total_bid_qty"))
total_ask = _abs_int(row.get("total_ask_qty"))
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)
ts_raw = row.get("ts")
try:
ts = float(ts_raw) if ts_raw not in (None, "") else time.time()
except (TypeError, ValueError):
ts = time.time()
st = str(row.get("snap_time") or "").strip()
return OrderbookSnapshot(
code=str(row.get("code") or "").strip(),
bids=bids,
asks=asks,
total_bid_qty=total_bid,
total_ask_qty=total_ask,
ts=ts,
source=str(row.get("source") or "kiwoom_0d"),
snap_time=st[:14] if st else "",
)
def parse_kiwoom_0d_values(code: str, values: Dict[str, Any]) -> OrderbookSnapshot:
"""키움 WS 0D ``values`` dict → ``OrderbookSnapshot``."""
asks: List[OrderbookLevel] = []
bids: List[OrderbookLevel] = []
for i in range(1, 11):
ask_px = _abs_int(values.get(str(40 + i)))
bid_px = _abs_int(values.get(str(50 + i)))
ask_qty = _abs_int(values.get(str(60 + i)))
bid_qty = _abs_int(values.get(str(70 + i)))
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(values.get("121"))
total_bid = _abs_int(values.get("125"))
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)
return OrderbookSnapshot(
code=str(code).strip(),
asks=asks,
bids=bids,
total_bid_qty=total_bid,
total_ask_qty=total_ask,
ts=time.time(),
source="kiwoom_0d",
)
def _ls_level_qty(body: Dict[str, Any], side: str, i: int) -> int:
"""LS UH1/H1_/HA_ 잔량 — 통합(UH1)은 krx+nxt+unt 합산, 단일시장은 offerremN."""
# H1_/HA_: offerrem1 / bidrem1
plain = _abs_int(body.get(f"{side}rem{i}"))
if plain > 0:
return plain
# UH1: krx_offerrem1 + nxt_ + unt_
return (
_abs_int(body.get(f"krx_{side}rem{i}"))
+ _abs_int(body.get(f"nxt_{side}rem{i}"))
+ _abs_int(body.get(f"unt_{side}rem{i}"))
)
def _ls_total_qty(body: Dict[str, Any], side: str) -> int:
"""totofferrem / totoffer — UH1 은 krx_tot* + nxt_ + unt_ 합산."""
plain = _abs_int(body.get(f"tot{side}rem"))
if plain > 0:
return plain
return (
_abs_int(body.get(f"krx_tot{side}rem"))
+ _abs_int(body.get(f"nxt_tot{side}rem"))
+ _abs_int(body.get(f"unt_tot{side}rem"))
)
def parse_ls_hoga_body(
code: str,
body: Dict[str, Any],
*,
source: str = "ls_uh1",
) -> OrderbookSnapshot:
"""LS 호가잔량 body (UH1/H1_/HA_) → ``OrderbookSnapshot``.
- 가격: ``offerhoN``(매도)·``bidhoN``(매수) 1~10
- 잔량: 단일 ``offerremN`` 또는 통합 ``krx_/nxt_/unt_`` 합
"""
asks: List[OrderbookLevel] = []
bids: List[OrderbookLevel] = []
for i in range(1, 11):
ask_px = _abs_int(body.get(f"offerho{i}"))
bid_px = _abs_int(body.get(f"bidho{i}"))
ask_qty = _ls_level_qty(body, "offer", i)
bid_qty = _ls_level_qty(body, "bid", i)
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 = _ls_total_qty(body, "offer")
total_bid = _ls_total_qty(body, "bid")
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)
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]
return OrderbookSnapshot(
code=str(code).strip(),
asks=asks,
bids=bids,
total_bid_qty=total_bid,
total_ask_qty=total_ask,
ts=time.time(),
source=str(source or "ls_uh1"),
snap_time=snap_time,
)
class OrderbookCache:
"""스레드 세이프 종목별 호가 캐시."""
def __init__(self) -> None:
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)
with self._lock:
self._data[snap.code] = snap
return snap
def update_from_ls_hoga(
self,
code: str,
body: Dict[str, Any],
*,
source: str = "ls_uh1",
) -> OrderbookSnapshot:
snap = parse_ls_hoga_body(code, body, source=source)
with self._lock:
self._data[snap.code] = snap
return snap
def get(self, code: str, max_age_sec: float = 3.0) -> Optional[OrderbookSnapshot]:
c = str(code or "").strip()
if not c:
return None
with self._lock:
snap = self._data.get(c)
if not snap:
return None
if max_age_sec > 0 and (time.time() - snap.ts) > max_age_sec:
return None
return snap
def get_kis_bid_dict(self, code: str, max_age_sec: float = 3.0) -> Optional[Dict[str, Any]]:
snap = self.get(code, max_age_sec=max_age_sec)
if not snap or not snap.bids:
return None
return snap.to_kis_bid_dict()
def remove(self, code: str) -> None:
c = str(code or "").strip()
with self._lock:
self._data.pop(c, None)