이번에 들어간 내용

한투 호가 = 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

@@ -82,19 +82,16 @@ def _bars_per_day(tf_min: int) -> int:
# ─── 키움 키 로드 ─────────────────────────────────────────────────────
def _load_kiwoom_keys(db: TradeDB) -> tuple[str, str, bool]:
"""env_config 에서 키움 앱키/시크릿/모의여부를 로드.
"""env_auth_config 에서 키움 앱키/시크릿/모의여부를 로드.
우선순위
1. KIS_MOCK=true → KIWOOM_APP_KEY_MOCK/SECRET_MOCK
2. KIS_MOCK=false → KIWOOM_APP_KEY_REAL/SECRET_REAL
3. 위 둘 다 비어있으면 레거시 KIWOOM_APP_KEY/SECRET
"""
row = db.conn.execute(
"SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
).fetchone()
if not row:
raise RuntimeError("env_config 테이블이 비어 있습니다.")
r = dict(row)
r = db.get_auth_env()
if not r:
raise RuntimeError("env_auth_config 테이블이 비어 있습니다.")
is_mock = str(r.get("KIS_MOCK", "")).strip().lower() in ("true", "1", "yes", "on")
@@ -114,7 +111,7 @@ def _load_kiwoom_keys(db: TradeDB) -> tuple[str, str, bool]:
if not key or not sec:
raise RuntimeError(
"키움 API 키 미설정. env_config 에 KIWOOM_APP_KEY_REAL/MOCK 또는 "
"키움 API 키 미설정. env_auth_config 에 KIWOOM_APP_KEY_REAL/MOCK 또는 "
"레거시 KIWOOM_APP_KEY 가 있어야 합니다."
)

View File

@@ -124,6 +124,7 @@ class WsProbe:
self._ws = None
self._thread: Optional[threading.Thread] = None
self._done = threading.Event()
self._sent_codes: List[str] = []
def _build_sub(self, code: str, subscribe: bool = True) -> str:
return json.dumps({
@@ -223,6 +224,7 @@ class WsProbe:
time.sleep(self.gap_sec)
try:
app.send(self._build_sub(code, True))
self._sent_codes.append(code)
self.logger.info(" [WS] subscribe sent: %s", code)
except Exception as e:
self.logger.warning(" [WS] subscribe fail %s: %s", code, e)
@@ -242,6 +244,18 @@ class WsProbe:
self._thread = threading.Thread(target=_runner, daemon=True)
self._thread.start()
self._done.wait(timeout=hold_sec + 15.0)
gap = max(0.0, float(self.gap_sec or 0.12))
if self._ws and self._sent_codes:
self.logger.info(" [WS] unsubscribe %d 후 close", len(self._sent_codes))
for i, code in enumerate(self._sent_codes):
if i > 0 and gap > 0:
time.sleep(gap)
try:
self._ws.send(self._build_sub(code, False))
except Exception:
pass
if gap > 0:
time.sleep(gap)
try:
if self._ws:
self._ws.close()

View File

@@ -0,0 +1,497 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
한투(KIS) 원키(시세 실키 · approval slot=main) 한 세션에서
체결(H0STCNT0) 40종 + 호가(H0STASP0) 같은 40종을 같이 구독하고,
41·42번째가 TR별로 따로 한도인지 실측한다.
질문: 세션 합산 41인가, 체결 41 + 호가 41 따로인가?
정책:
- REST Approval 재발급 없음 (파일 캐시만). 새 키 = 실매 WS 무효.
- access_token(tokenP) 발급 없음. KISClient 생성 안 함.
- 동일 approval 로 2번째 소켓을 열면 기존 세션(해외 포함)이 잠깐 끊길 수 있음.
끝나면 close 해서 봇이 같은 키로 재연결하게 둔다.
사용:
cd /home/hoon/kis_bot
python3 -u kis_trader/scripts/test_kis_ws_tick_ob_limit.py
python3 -u kis_trader/scripts/test_kis_ws_tick_ob_limit.py --slot ob
로그:
logs/test_kis_ws_tick_ob_limit.log
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
HERE = Path(__file__).resolve()
ROOT = HERE.parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from kis_approval_manager import KISApprovalManager # noqa: E402
from kis_trader.utils.env import get_env_float, get_env_from_db # noqa: E402
LOG_DIR = ROOT / "logs"
LOG_DIR.mkdir(exist_ok=True)
LOG_PATH = LOG_DIR / "test_kis_ws_tick_ob_limit.log"
# 임의 대형·중형 40종 (REST 랭킹 조회 없음 — 한도 테스트용 고정 리스트)
CODES_40 = [
"005930", "000660", "005380", "035420", "051910",
"006400", "035720", "068270", "207940", "005490",
"028260", "012330", "066570", "003670", "096770",
"034730", "015760", "032830", "086790", "009150",
"011200", "010130", "018260", "009830", "010950",
"024110", "030200", "034220", "047810", "267250",
"003550", "017670", "036570", "251270", "352820",
"259960", "326030", "377300", "042700", "000270",
]
CODE_41 = "105560" # KB금융
CODE_42 = "055550" # 신한지주
TR_TICK = "H0STCNT0"
TR_OB = "H0STASP0"
def _setup_logging() -> logging.Logger:
lg = logging.getLogger("kis_tick_ob_limit")
lg.setLevel(logging.DEBUG)
lg.propagate = False
lg.handlers.clear()
fmt = logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S")
for h in (logging.StreamHandler(sys.stdout), logging.FileHandler(LOG_PATH, encoding="utf-8")):
h.setFormatter(fmt)
lg.addHandler(h)
return lg
def _is_success(rt: str, msg: str) -> bool:
rt = (rt or "").strip()
msg_u = (msg or "").upper()
if rt == "0":
return True
if "SUBSCRIBE SUCCESS" in msg_u:
return True
if "ALREADY IN SUBSCRIBE" in msg_u:
return True
return False
class TickObLimitProbe:
"""한 세션 · 한 approval · 체결+호가 구독 ACK 수집."""
def __init__(self, ws_url: str, approval_key: str, logger: logging.Logger, gap_sec: float):
self.ws_url = ws_url
self.approval_key = approval_key
self.logger = logger
self.gap_sec = max(0.0, float(gap_sec))
self.acks: List[Dict[str, Any]] = []
self.ticks = 0
self.obs = 0
self.connected_at = 0.0
self.closed_at = 0.0
self.close_code: Any = None
self.close_msg = ""
self.error = ""
self._ws = None
self._send_lock = threading.Lock()
self._open_evt = threading.Event()
self._done = threading.Event()
self._pending: List[Tuple[str, str]] = [] # (tr_id, code) 보낸 순서
self._pending_lock = threading.Lock()
self.fatal_msg = ""
self._alive = True
self._ok_subs: List[Tuple[str, str]] = [] # 해제할 SUCCESS (tr_id, code)
def _build_sub(self, tr_id: str, code: str, subscribe: bool = True) -> str:
return json.dumps({
"header": {
"approval_key": self.approval_key,
"custtype": "P",
"tr_type": "1" if subscribe else "2",
"content-type": "utf-8",
},
"body": {
"input": {
"tr_id": tr_id,
"tr_key": code,
}
},
})
def send_sub(self, tr_id: str, code: str) -> bool:
if not self._alive:
return False
with self._pending_lock:
self._pending.append((tr_id, code))
with self._send_lock:
if not self._ws or not self._alive:
self.logger.warning("send skip (closed) %s %s", tr_id, code)
return False
try:
self._ws.send(self._build_sub(tr_id, code))
except Exception as e:
self._alive = False
self.logger.warning("send fail %s %s: %s", tr_id, code, e)
return False
self.logger.info("SENT %s %s", tr_id, code)
return True
def _pop_pending(self, tr_id: str, code: str) -> Tuple[str, str]:
with self._pending_lock:
if code:
for i, (pt, pc) in enumerate(self._pending):
if pt == tr_id and pc == code:
return self._pending.pop(i)
if self._pending:
if tr_id:
for i, (pt, pc) in enumerate(self._pending):
if pt == tr_id:
return self._pending.pop(i)
return self._pending.pop(0)
return (tr_id, code)
def _on_open(self, ws) -> None:
self.connected_at = time.time()
self.logger.info("on_open OK")
self._open_evt.set()
def _on_message(self, ws, message: str) -> None:
raw = (message or "").strip()
if raw == "PINGPONG":
try:
ws.send("PINGPONG")
except Exception:
pass
return
if raw.startswith("{"):
try:
j = json.loads(raw)
except Exception as e:
self.logger.warning("JSON parse fail: %s | %s", e, raw[:180])
return
hdr = j.get("header") or {}
body = j.get("body") or {}
out = body.get("output") or {}
if not isinstance(out, dict):
out = {}
tr_id = str(hdr.get("tr_id") or "").strip()
tr_key = str(
out.get("tr_key")
or hdr.get("tr_key")
or ""
).strip()
rt = str(body.get("rt_cd") or "").strip()
msg1 = str(body.get("msg1") or "").strip()
matched_tr, matched_code = self._pop_pending(tr_id, tr_key)
ok = _is_success(rt, msg1)
msg_u = msg1.upper()
if "ALREADY IN USE" in msg_u:
self.fatal_msg = msg1
self._alive = False
rec = {
"ts": datetime.now().strftime("%H:%M:%S"),
"tr_id": tr_id or matched_tr,
"code": tr_key or matched_code,
"rt_cd": rt,
"msg1": msg1,
"ok": ok,
}
self.acks.append(rec)
if ok and rec["tr_id"] and rec["code"]:
self._ok_subs.append((rec["tr_id"], rec["code"]))
flag = "OK" if ok else "REJECT"
self.logger.info(
"ACK %s tr=%s code=%s rt_cd=%s msg1=%s",
flag, rec["tr_id"], rec["code"], rt, msg1,
)
return
parts = raw.split("|")
if len(parts) >= 2:
tr = parts[1]
if tr == TR_TICK:
self.ticks += 1
if self.ticks <= 3:
self.logger.info("H0STCNT0 tick #%d (장중만 의미)", self.ticks)
elif tr == TR_OB:
self.obs += 1
if self.obs <= 3:
self.logger.info("H0STASP0 ob #%d (장중만 의미)", self.obs)
def _on_error(self, ws, error) -> None:
self.error = str(error)
self._alive = False
self.logger.warning("on_error: %s", error)
def _on_close(self, ws, code, msg) -> None:
self._alive = False
self.closed_at = time.time()
self.close_code = code
self.close_msg = str(msg or "")
dur = self.closed_at - self.connected_at if self.connected_at else 0.0
self.logger.info("on_close code=%s dur=%.2fs msg=%s", code, dur, self.close_msg or "-")
self._done.set()
def connect(self) -> bool:
try:
import websocket as ws_lib
except ImportError:
self.logger.error("websocket-client 미설치")
return False
def _runner():
app = ws_lib.WebSocketApp(
self.ws_url,
on_open=self._on_open,
on_message=self._on_message,
on_error=self._on_error,
on_close=self._on_close,
)
self._ws = app
app.run_forever(ping_interval=20, ping_timeout=10)
t = threading.Thread(target=_runner, daemon=True, name="KIS-limit-probe")
t.start()
if not self._open_evt.wait(timeout=12.0):
self.logger.error("on_open 타임아웃")
return False
time.sleep(0.4)
return True
def close(self) -> None:
gap = max(0.0, float(self.gap_sec or 0.12))
if self._ws and self._ok_subs:
self.logger.info("구독 해제 %d건 후 close", len(self._ok_subs))
for i, (tr_id, code) in enumerate(self._ok_subs):
if i > 0 and gap > 0:
time.sleep(gap)
try:
self._ws.send(self._build_sub(tr_id, code, subscribe=False))
self.logger.info("UNSUB %s %s", tr_id, code)
except Exception as e:
self.logger.warning("UNSUB fail %s %s: %s", tr_id, code, e)
if gap > 0:
time.sleep(gap)
self._ok_subs.clear()
try:
if self._ws:
self._ws.close()
except Exception:
pass
self._done.wait(timeout=5.0)
def _count(acks: List[Dict[str, Any]], tr_id: str, codes: List[str]) -> Tuple[int, int, List[str]]:
want = set(codes)
ok_n = 0
fail_n = 0
fail_msgs: List[str] = []
seen = set()
for a in acks:
if a.get("tr_id") != tr_id:
continue
c = str(a.get("code") or "")
if c not in want or c in seen:
continue
seen.add(c)
if a.get("ok"):
ok_n += 1
else:
fail_n += 1
fail_msgs.append(f"{c}:{a.get('rt_cd')}:{a.get('msg1')}")
return ok_n, fail_n, fail_msgs
def _ack_for(acks: List[Dict[str, Any]], tr_id: str, code: str) -> Optional[Dict[str, Any]]:
for a in reversed(acks):
if a.get("tr_id") == tr_id and a.get("code") == code:
return a
return None
def _verdict(tick40_ok: int, ob40_ok: int, a41t: Optional[Dict], a42t: Optional[Dict],
a41o: Optional[Dict], a42o: Optional[Dict]) -> str:
def _ok(a: Optional[Dict]) -> Optional[bool]:
if a is None:
return None
return bool(a.get("ok"))
t41, t42 = _ok(a41t), _ok(a42t)
o41, o42 = _ok(a41o), _ok(a42o)
if tick40_ok >= 40 and ob40_ok >= 40:
if t41 is True and o41 is True and t42 is False and o42 is False:
return "따로따로 41 — 체결 41 + 호가 41 (같은 세션·같은 키)"
if t41 is True and o41 is True and t42 is True and o42 is True:
return "40+40 통과, 42번도 통과 — 한도가 41보다 크거나 장외라 한도 ACK가 안 옴"
if t41 is False or o41 is False:
return "40+40은 됐는데 41번째 종목이 거부 — 종목수 41(TR 합산이 아님) 쪽"
return "40종 체결+호가 모두 성공 → 합산 41은 아님. 41/42 ACK로 세부 판정"
if tick40_ok >= 40 and ob40_ok <= 1:
return "합산 41 — 체결 40 후 호가는 남은 1칸만 (또는 0칸)"
if tick40_ok + ob40_ok <= 41 and tick40_ok >= 1 and ob40_ok >= 1:
return "합산 41 가능성 — 체결+호가 성공 합이 41 근처"
if tick40_ok + ob40_ok == 0:
return "ACK 없음 — 장외 즉시 끊김·invalid approval·동시세션 충돌. 한도 판정 불가"
return f"애매 — tick40_ok={tick40_ok} ob40_ok={ob40_ok} (로그 ACK 확인)"
def main() -> int:
parser = argparse.ArgumentParser(description="KIS WS 틱+호가 세션 한도 실측")
parser.add_argument(
"--slot",
choices=("main", "ob"),
default="main",
help="main=시세 원키, ob=호가 전용 2번째 실전 앱키",
)
args = parser.parse_args()
slot = args.slot
log = _setup_logging()
log.info("=" * 64)
log.info("KIS slot=%s 틱+호가 40종 한도 테스트 → %s", slot, LOG_PATH)
log.info("=" * 64)
if len(CODES_40) != 40:
log.error("CODES_40 길이 %d ≠ 40", len(CODES_40))
return 1
if slot == "ob":
app_key = (get_env_from_db("KIS_APP_KEY_OB_REAL", "") or "").strip()
app_secret = (get_env_from_db("KIS_APP_SECRET_OB_REAL", "") or "").strip()
key_names = "KIS_APP_KEY_OB_REAL / KIS_APP_SECRET_OB_REAL"
else:
app_key = (get_env_from_db("KIS_APP_KEY_REAL", "") or "").strip()
app_secret = (get_env_from_db("KIS_APP_SECRET_REAL", "") or "").strip()
key_names = "KIS_APP_KEY_REAL / KIS_APP_SECRET_REAL"
if not app_key or not app_secret:
log.error("%s 없음", key_names)
return 1
ws_url = (get_env_from_db("KIS_WS_URL_REAL", "") or "").strip() or "ws://ops.koreainvestment.com:21000"
gap = float(get_env_float("KIS_WS_SUBSCRIBE_GAP_MIN_SEC", 0.12))
if gap < 0.05:
gap = 0.12
mgr = KISApprovalManager.instance(False, slot=slot)
key = mgr.reload_from_file()
if not key:
log.error(
"approval 파일 캐시 없음 (%s). REST 재발급은 실매 WS를 죽이므로 중단.",
mgr._cache_path.name,
)
return 1
age_min = mgr.age_sec() / 60.0
log.info("키=%s(앞8자 %s…) slot=%s", key_names.split(" / ")[0], app_key[:8], slot)
log.info("WS=%s", ws_url)
log.info("approval 캐시 재사용 앞8자 %s… age=%.0f분 REST없음", key[:8], age_min)
log.info("구독간격=%.2fs | 종목40 + 41=%s 42=%s", gap, CODE_41, CODE_42)
log.info("주의: 같은 approval 2세션 → 해당 키 WS가 있으면 ALREADY IN USE")
probe = TickObLimitProbe(ws_url, key, log, gap)
if not probe.connect():
log.error("WS 연결 실패")
probe.close()
return 1
log.info("--- Phase A: H0STCNT0 x 40 ---")
for i, code in enumerate(CODES_40):
if not probe._alive:
log.warning("세션 종료 — Phase A 중단 (%d/%d)", i, len(CODES_40))
break
if i > 0 and gap > 0:
time.sleep(gap)
if not probe.send_sub(TR_TICK, code):
break
if probe._alive:
log.info("--- Phase B: H0STASP0 x 같은 40 ---")
for i, code in enumerate(CODES_40):
if not probe._alive:
log.warning("세션 종료 — Phase B 중단 (%d/%d)", i, len(CODES_40))
break
if gap > 0:
time.sleep(gap)
if not probe.send_sub(TR_OB, code):
break
else:
log.warning("Phase B 스킵 (세션 없음)")
if probe._alive:
log.info("--- Phase C: 41·42번째 체결 ---")
time.sleep(gap)
probe.send_sub(TR_TICK, CODE_41)
time.sleep(gap)
probe.send_sub(TR_TICK, CODE_42)
log.info("--- Phase D: 41·42번째 호가 ---")
time.sleep(gap)
probe.send_sub(TR_OB, CODE_41)
time.sleep(gap)
probe.send_sub(TR_OB, CODE_42)
wait_acks = 8.0
log.info("ACK 대기 %.0fs (장외면 틱/호가 데이터는 0이어도 ACK는 올 수 있음)", wait_acks)
time.sleep(wait_acks)
else:
log.warning("Phase C/D 스킵 — %s", probe.fatal_msg or probe.error or "연결 끊김")
time.sleep(0.5)
dur = (time.time() - probe.connected_at) if probe.connected_at else 0.0
log.info("연결유지 %.1fs close전 | ticks=%d obs=%d acks=%d err=%s",
dur, probe.ticks, probe.obs, len(probe.acks), probe.error or "-")
probe.close()
tick40_ok, tick40_fail, tick40_fail_msgs = _count(probe.acks, TR_TICK, CODES_40)
ob40_ok, ob40_fail, ob40_fail_msgs = _count(probe.acks, TR_OB, CODES_40)
a41t = _ack_for(probe.acks, TR_TICK, CODE_41)
a42t = _ack_for(probe.acks, TR_TICK, CODE_42)
a41o = _ack_for(probe.acks, TR_OB, CODE_41)
a42o = _ack_for(probe.acks, TR_OB, CODE_42)
def _fmt(a: Optional[Dict[str, Any]]) -> str:
if a is None:
return "ACK없음"
return f"{'OK' if a.get('ok') else 'REJECT'} rt={a.get('rt_cd')} {a.get('msg1')}"
log.info("=" * 64)
log.info("요약")
log.info(" 체결 40종 OK=%d FAIL=%d", tick40_ok, tick40_fail)
log.info(" 호가 40종 OK=%d FAIL=%d", ob40_ok, ob40_fail)
if tick40_fail_msgs:
log.info(" 체결 FAIL 샘플: %s", "; ".join(tick40_fail_msgs[:5]))
if ob40_fail_msgs:
log.info(" 호가 FAIL 샘플: %s", "; ".join(ob40_fail_msgs[:5]))
log.info(" 체결 41(%s): %s", CODE_41, _fmt(a41t))
log.info(" 체결 42(%s): %s", CODE_42, _fmt(a42t))
log.info(" 호가 41(%s): %s", CODE_41, _fmt(a41o))
log.info(" 호가 42(%s): %s", CODE_42, _fmt(a42o))
log.info(" 실시간 수신 tick=%d ob=%d (지금은 장외면 0이 정상)", probe.ticks, probe.obs)
log.info(" close=%s %s", probe.close_code, probe.close_msg or "-")
if "ALREADY IN USE" in (probe.fatal_msg or "").upper() or "ALREADY IN USE" in " ".join(
str(a.get("msg1") or "") for a in probe.acks
).upper():
v = (
"한도 미측정 — 원키(appkey)가 이미 봇 WS에 점유됨 "
"(rt_cd=9 ALREADY IN USE appkey). 앱키당 세션 1개. "
"봇이 원키 소켓을 비운 뒤에야 40+호가 한도 실측 가능."
)
else:
v = _verdict(tick40_ok, ob40_ok, a41t, a42t, a41o, a42o)
log.info("판정: %s", v)
log.info("로그: %s", LOG_PATH)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -67,7 +67,7 @@ def _ui_pct_to_ratio(ui_pct) -> float:
def verify_momentum() -> int:
import backtest_web as bw
import kis_trader.engine.momentum_engine as me
import scalping_engine as se # noqa: F401 (웹이 쓰는 베이스 로더)
import kis_trader.engine.scalping_engine as se # noqa: F401
print("\n=== 모멘텀 (MOMENTUM) ===")
can = me.get_momentum_defaults_from_db() # 실매 + param_search base (비율)