이번에 들어간 내용

한투 호가 = 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 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
This commit is contained in:
Your Name
2026-08-19 22:11:31 +09:00
parent d1dc274f0e
commit 0ecac7cb95
229 changed files with 8487 additions and 1647 deletions

View File

@@ -1,19 +1,21 @@
"""
kis_trader/ws/kiwoom_ws.py — 키움 WebSocket 실시간 시세 캐시 (시세 마이그레이션 검증용)
kis_trader/ws/kiwoom_ws.py — 키움 WebSocket 실시간 시세 캐시
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
목적
----
KIS WS(41 한도)의 시세를 키움 WS(100 한도)로 옮기기 전에, 둘을 동시에 돌려
가격 일치성을 검증하기 위한 키움 WebSocket 클라이언트.
실매 키움 실시간 시세(체결 0B · 호가 0D · 프로그램 0w) **한 소켓**.
예전에는 KIS 41 한도 대비 검증용이었으나, 지금은 틱/호가 본체.
``LIVE_TICK_PROVIDER`` 는 이 모듈이 아니라 ``WSManager.get_price`` 읽기 순서.
``WS_SUBSCRIBE_KIS_MINIMAL`` 은 한투 구독 명단. 키움 후보 구독과 무관.
설계 원칙 (KIS WS 와 동일)
--------------------------
- ``Dict[str, Dict]`` 메모리 캐시 (락만 잠그고 마이크로초 read/write)
- ``get_price(code)`` 인터페이스를 KIS WS 와 100% 동일 포맷으로 제공
→ 봇 코드 재사용성 100%
- ``KiwoomTokenManager`` 싱글톤 (kis_ws.py 안에 있음) 재사용
- 재연결 백오프, approval 갱신, 종목 등록/해지
- 재연결 백오프, 종목 등록/해지
키움 WebSocket 스펙
-------------------
@@ -34,18 +36,16 @@ URL 모의: wss://mockapi.kiwoom.com:10000/api/dostk/websocket
14 = 누적거래대금 15 = 거래량(체결량)
16 = 시가 17 = 고가 18 = 저가 20 = 체결시간(HHMMSS)
(키움 OpenAPI+ FID 정의 — 종목별 동일)
설치
----
``pip install websocket-client`` (KIS WS 와 공용)
토글 (DB env_config)
--------------------
``WS_PROVIDER`` (기본 ``kis_only`` — 키움 WS 미기동)
키움 WS 기동: Orchestrator ``_start_ws_validator`` — 키 있으면 기동.
``WS_PROVIDER=kis_only`` 로 끄지 않음(구식 주석).
``KIWOOM_WS_URL_REAL`` / ``KIWOOM_WS_URL_MOCK`` (URL 재정의용)
``KIWOOM_WS_REG_CHUNK_SIZE`` / ``KIWOOM_WS_REG_GAP_SEC`` / ``KIWOOM_WS_REG_DEBOUNCE_SEC``
— REG(TRNM) 초당 허용 건수 초과 방지(배치·전송 간격·단건 디바운스).
``KIWOOM_WS_REG_REFRESH`` (기본 1) — REG ``refresh``. 0=기존 그룹 구독 해지 후 재등록.
LOGIN 직후 일괄 REG **첫 청크만** 0. 장중 종목 추가 REG는 항상 1
(청크마다 0이면 앞 종목이 지워짐). 장중 상시 0 비추(catch-up 재점화).
"""
from __future__ import annotations
@@ -119,7 +119,9 @@ class KiwoomWebSocketPriceCache:
FID_LOW = "18"
FID_TICK_VOL = "15"
FID_TICK_TIME = "20"
FID_EXEC_STRENGTH = "567" # 체결강도 (%)
FID_EXEC_STRENGTH = "228" # 체결강도 (%) — 키움 0B 공식
FID_UPPER_LIMIT_TIME = "567" # 상한가발생시간 HHmmss
FID_LOWER_LIMIT_TIME = "568" # 하한가발생시간 HHmmss
# 한도 (키움 권장)
MAX_SUBSCRIPTIONS_PER_GROUP = 100 # grp_no=1 그룹 1개당
@@ -160,6 +162,11 @@ class KiwoomWebSocketPriceCache:
# 메모리 캐시 — KIS WS 와 동일 포맷 (data + ts)
self._cache: Dict[str, Dict] = {}
self._cache_lock = threading.Lock()
# 종목별 마지막 FID20 — 재연결 catch-up(시간 후퇴/지연) vs 시계 동결 구분
self._fid20_last: Dict[str, Any] = {}
# 현재 FID20 를 처음 본 벽시계 — 같은 초 연속 체결 vs 아침 동결(수 분) 구분
self._fid20_first_wall: Dict[str, float] = {}
self._stale_skip_log_ts: Dict[str, float] = {}
self._price_listeners: list = []
self._price_listener_lock = threading.Lock()
self._orderbook_cache = OrderbookCache() if OrderbookCache else None
@@ -234,6 +241,36 @@ class KiwoomWebSocketPriceCache:
self.is_mock, self._ws_url)
return True
def _unsubscribe_all_before_close(self, reason: str = "", *, clear_ram: bool = True) -> int:
"""세션 close 전 서버 REMOVE. LOGIN 전(구독 없음)이면 0.
RAM 은 ``clear_ram=True`` (stop) 일 때만 비운다.
"""
with self._sub_lock:
codes = sorted(self._subscribed)
if clear_ram:
self._reg_batch_codes.clear()
self._subscribed.clear()
if not codes or not self._connected or not self._authenticated or self._ws is None:
return 0
n_ok = 0
try:
chunk = self._reg_chunk_size()
gap = self._reg_gap_sec()
for i in range(0, len(codes), chunk):
part = codes[i:i + chunk]
if self._send_remove(part):
n_ok += len(part)
if i + chunk < len(codes):
time.sleep(gap)
logger.info(
"✅ 키움 WS 종료 전 REMOVE %d/%d종목 (%s)",
n_ok, len(codes), reason or "-",
)
except Exception as e:
logger.warning("키움 WS 종료 전 REMOVE 실패: %s", e)
return n_ok
def stop(self) -> None:
"""수신 스레드 종료 + 구독 REMOVE 후 소켓 닫기 (재시작 세션 꼬임 완화)."""
with self._reg_timer_lock:
@@ -243,28 +280,10 @@ class KiwoomWebSocketPriceCache:
except Exception:
pass
self._reg_timer = None
with self._sub_lock:
self._reg_batch_codes.clear()
codes = sorted(self._subscribed)
self._subscribed.clear()
# 서버에 등록된 종목 REMOVE — KIS stop(clear_subscriptions=True) 와 동일 목적
if codes and self._connected and self._authenticated and self._ws is not None:
try:
chunk = self._reg_chunk_size()
gap = self._reg_gap_sec()
n_ok = 0
for i in range(0, len(codes), chunk):
part = codes[i:i + chunk]
if self._send_remove(part):
n_ok += len(part)
if i + chunk < len(codes):
time.sleep(gap)
logger.info(
"✅ 키움 WS 종료 전 REMOVE %d/%d종목",
n_ok, len(codes),
)
except Exception as e:
logger.warning("키움 WS 종료 전 REMOVE 실패: %s", e)
try:
self._unsubscribe_all_before_close(reason="stop", clear_ram=True)
except Exception as e:
logger.warning("키움 WS 종료 전 REMOVE 실패: %s", e)
self._running = False
self._connected = False
self._authenticated = False
@@ -519,8 +538,8 @@ class KiwoomWebSocketPriceCache:
except Exception as ex:
logger.debug("price_listener 예외 %s: %s", code, ex)
def get_price(self, code: str, max_age_sec: float = 5.0) -> Optional[Dict]:
"""KIS WS ``get_price`` 와 동일 포맷 반환.
def get_price(self, code: str, max_age_sec: Optional[float] = None) -> Optional[Dict]:
"""KIS WS ``get_price`` 와 동일 포맷 반환. None = 마지막 RAM (체결 공백이어도 유지).
반환::
@@ -541,7 +560,7 @@ class KiwoomWebSocketPriceCache:
if not entry:
return None
age_sec = time.time() - entry.get("ts", 0)
if age_sec > max_age_sec:
if max_age_sec is not None and age_sec > max_age_sec:
return None
data = dict(entry["data"])
data["_age_ms"] = int(age_sec * 1000)
@@ -679,7 +698,9 @@ class KiwoomWebSocketPriceCache:
pending = sorted(self._subscribed)
self._reg_batch_codes.clear()
if pending:
self._send_reg_chunked(pending)
# LOGIN ack 를 받은 같은 on_message 스레드에서 REG 청크 전송.
# 이 동안 0B 파싱이 밀림. 장중 추가는 Timer 디바운스(별 스레드).
self._send_reg_chunked(pending, group_replace=True)
# 조건검색 등 공유 세션 모듈 — LOGIN 직후 CNSRLST 재등록
self._fire_login_callbacks(ws)
else:
@@ -766,12 +787,14 @@ class KiwoomWebSocketPriceCache:
except (ValueError, TypeError):
return "0"
# KIS inquire_price 호환 필드 (스칼라 dict)
cntr_raw = values.get(self.FID_EXEC_STRENGTH, "0")
# KIS inquire_price 호환 + 키움 0B 원본 FID 보존 (분석·추후 필터용)
exec_raw = values.get(self.FID_EXEC_STRENGTH, "0")
try:
cntr_str = float(str(cntr_raw).replace(",", ""))
cntr_str = float(str(exec_raw).replace(",", ""))
except (ValueError, TypeError):
cntr_str = 0.0
upper_limit_raw = str(values.get(self.FID_UPPER_LIMIT_TIME, "") or "").strip()
lower_limit_raw = str(values.get(self.FID_LOWER_LIMIT_TIME, "") or "").strip()
data_compat = {
"stck_prpr": str(int(price)),
"stck_oprc": _abs_str(values.get(self.FID_OPEN, "0")),
@@ -780,18 +803,98 @@ class KiwoomWebSocketPriceCache:
"prdy_vrss": _abs_str(values.get(self.FID_CHANGE, "0")),
"prdy_ctrt": str(values.get(self.FID_CHANGE_PCT, "0")),
"cntr_str": str(cntr_str),
"upper_limit_time": upper_limit_raw,
"lower_limit_time": lower_limit_raw,
"kiwoom_fid228": str(exec_raw),
"kiwoom_fid567": upper_limit_raw,
"kiwoom_fid568": lower_limit_raw,
}
with self._cache_lock:
self._cache[code] = {"data": data_compat, "ts": time.time()}
self._emit_price_listeners(code, float(price), data_compat)
# tick_time / tick_vol — CandleAggregator·TickRecorder 공용 (보유-only도 recorder 수집)
# 늦음 판정 = 이 틱의 체결시각(FID20) vs 우리 서버 지금. 증권사끼리 비교 아님.
# 매매 RAM/리스너/봉: 읽기나이(LIVE_FEED_FALLBACK, 기본 3초) 넘으면 안 넣음 → 2차·3차가 연다.
# 적재(ws_ticks): 버리지 않고 전부. LIVE_MAX<=0 이면 적재 skip 없음.
# 아침 FID20 동결: 같은 FID20 가 TIME_MAX초 이상 안 움직일 때만 RAM 유지.
import datetime as _dt
tt_raw = str(values.get(self.FID_TICK_TIME, "") or "").strip()
_now_dt = _dt.datetime.now()
_now_wall = time.time()
if len(tt_raw) >= 6:
tick_time = tt_raw[-6:]
tick_time_pkt = tt_raw[-6:]
else:
import datetime as _dt
tick_time = _dt.datetime.now().strftime("%H%M%S")
tick_time_pkt = _now_dt.strftime("%H%M%S")
_pkt_dt = None
try:
if len(tt_raw) >= 14:
_pkt_dt = _dt.datetime.strptime(tt_raw[:14], "%Y%m%d%H%M%S")
elif len(tt_raw) >= 6:
_pkt_dt = _dt.datetime.strptime(
_now_dt.strftime("%Y%m%d") + tick_time_pkt, "%Y%m%d%H%M%S",
)
except Exception:
_pkt_dt = None
_lag_sec = 0.0
if _pkt_dt is not None:
_lag_sec = float((_now_dt - _pkt_dt).total_seconds())
try:
_live_max = int(get_env_int("KIWOOM_TICK_LIVE_MAX_LAG_SEC", 0) or 0)
except (TypeError, ValueError):
_live_max = 0
try:
from kis_trader.engine.feed_fallback import live_feed_fallback_max_age_sec
_read_max = float(live_feed_fallback_max_age_sec())
except Exception:
_read_max = 2.0
_time_max = max(5, int(get_env_int("KIWOOM_TICK_TIME_MAX_LAG_SEC", 120)))
_last_fid = self._fid20_last.get(code)
if _pkt_dt is not None and _last_fid != _pkt_dt:
self._fid20_first_wall[code] = _now_wall
_first_w = float(self._fid20_first_wall.get(code, _now_wall) or _now_wall)
# 같은 FID20 가 TIME_MAX초 이상 지속 = 아침 시계 멈춤. 밀린 줄의 같은 초 2건(수십 ms)은 제외.
_frozen = bool(
_pkt_dt is not None
and _last_fid is not None
and _pkt_dt == _last_fid
and (_now_wall - _first_w) >= float(_time_max)
)
# 재연결 버퍼: FID20가 지금보다 읽기나이(2초) 이상 과거면 매매 RAM 미반영.
# 적재는 기본 전부(LIVE_MAX=0). 양수일 때만 그 초 초과 미저장.
_skip_ram = bool(
_read_max > 0
and _pkt_dt is not None
and _lag_sec > float(_read_max)
and not _frozen
)
_skip_persist = bool(
_live_max > 0
and _pkt_dt is not None
and _lag_sec > float(_live_max)
and not _frozen
)
if _pkt_dt is not None:
self._fid20_last[code] = _pkt_dt
if not _skip_ram:
wall_ts = time.time()
with self._cache_lock:
self._cache[code] = {"data": data_compat, "ts": wall_ts}
self._emit_price_listeners(code, float(price), data_compat)
else:
_log_gap = max(5.0, float(get_env_float("KIWOOM_TICK_STALE_LOG_GAP_SEC", 30.0) or 30.0))
_prev_log = float(self._stale_skip_log_ts.get(code, 0.0) or 0.0)
if (time.time() - _prev_log) >= _log_gap:
self._stale_skip_log_ts[code] = time.time()
logger.warning(
"키움 0B stale skip RAM %s fid20=%s lag=%.0fs price=%.0f (매수체크 미반영·2차 폴백)",
code, tick_time_pkt, _lag_sec, price,
)
tick_time = tick_time_pkt
if _pkt_dt is None:
tick_time = _now_dt.strftime("%H%M%S")
elif _frozen and _lag_sec > float(_time_max):
# FID20 동결(아침 55분 버그) — 분봉·틱청산 타임라인만 wall-clock
tick_time = _now_dt.strftime("%H%M%S")
try:
tick_vol = int(
abs(float(str(values.get(self.FID_TICK_VOL, "0")).replace(",", "")))
@@ -800,7 +903,8 @@ class KiwoomWebSocketPriceCache:
tick_vol = 0
# ── CandleAggregator: 후보만 (tick_to_agg = 후보−보유) ─────
if self._candle_agg is not None:
# 밀린 메인 틱은 확정봉에 넣지 않음 (freeze 오염 방지)
if self._candle_agg is not None and not _skip_ram:
filt = self._candle_agg_codes
if filt is None or code in filt:
try:
@@ -809,16 +913,21 @@ class KiwoomWebSocketPriceCache:
logger.debug("키움→CandleAggregator on_tick 실패 %s: %s", code, ex)
# ── TickRecorder: 보유 포함 전 구독 종목 (백테 틱청산·B안 폴백) ─────
if self._tick_recorder is not None:
# 공책은 밀려도 적재. 읽기 2초와 분리. 옵투나는 lag>2초 메인을 보조로 교체.
if self._tick_recorder is not None and not _skip_persist:
try:
self._tick_recorder.on_tick(
code, price, tick_vol, tick_time, source="kiwoom",
cntr_str=cntr_str,
upper_limit_time=upper_limit_raw or None,
tick_time_raw=tt_raw or None,
)
except Exception as ex:
logger.debug("키움→TickRecorder on_tick 실패 %s: %s", code, ex)
# ── 호가 틱동기: 체결 1건당 RAM 0D 스냅 1장 (LS tick 모드와 동일) ──
if self._trigger_snapshot_recorder is not None:
# 밀린 체결시각에 지금 호가를 붙이면 옵투나 호가가 미래가 됨 → RAM과 같이 skip.
if self._trigger_snapshot_recorder is not None and not _skip_ram:
try:
max_age = float(
getattr(
@@ -852,32 +961,48 @@ class KiwoomWebSocketPriceCache:
# ------------------------------------------------------------------
# 내부: 등록/해지 메시지 발송
# ------------------------------------------------------------------
def _send_reg_chunked(self, codes: List[str]) -> None:
"""REG 를 청크 단위로 전송하고 청크 사이에 sleep (키움 TRNM 건수 제한 회피)."""
def _reg_refresh_keep(self) -> str:
"""REG refresh. 기본 1(기존 유지). 0=그룹 기존 item/type 해지 — 테스트용."""
v = int(get_env_int("KIWOOM_WS_REG_REFRESH", 1) or 1)
return "0" if v == 0 else "1"
def _send_reg_chunked(self, codes: List[str], *, group_replace: bool = False) -> None:
"""REG 를 청크 단위로 전송하고 청크 사이에 sleep (키움 TRNM 건수 제한 회피).
group_replace=True (LOGIN 일괄): ``KIWOOM_WS_REG_REFRESH=0`` 이면 **첫 청크만** refresh=0.
이후 청크·장중 추가 REG는 항상 1 (0을 반복하면 앞 청크 구독이 삭제됨).
"""
if not codes:
return
chunk = self._reg_chunk_size()
gap = self._reg_gap_sec()
want = self._reg_refresh_keep() if group_replace else "1"
if group_replace and want == "0":
logger.warning(
"⚠️ 키움 WS REG refresh=0 테스트 — LOGIN 일괄 첫 청크만 기존 구독 해지 후 재등록"
)
for i in range(0, len(codes), chunk):
part = codes[i:i + chunk]
self._send_reg(part)
refresh = want if i == 0 else "1"
self._send_reg(part, refresh=refresh)
if i + chunk < len(codes):
time.sleep(gap)
def _send_reg(self, codes: list) -> bool:
def _send_reg(self, codes: list, *, refresh: str = "1") -> bool:
"""REG 발송 (그룹 1, 0B+0D 타입). 한 번에 여러 종목 OK."""
if not codes or not self._ws or not self._connected:
return False
flag = "0" if str(refresh).strip() == "0" else "1"
try:
self._ws.send(json.dumps({
"trnm": "REG",
"grp_no": self.GROUP_NO,
"refresh": "1", # 재시작 시 등록 유지
"refresh": flag,
"data": [{"item": list(codes), "type": self._reg_types()}],
}))
logger.info(
"📡 키움 WS REG 발송: %d종목 types=%s (총 %d/%d)",
len(codes), self._reg_types(), self.subscribed_count(),
"📡 키움 WS REG 발송: %d종목 types=%s refresh=%s (총 %d/%d)",
len(codes), self._reg_types(), flag, self.subscribed_count(),
self._max_subscriptions(),
)
return True