Files
kis_trader/scripts/test_ws_tick_kis_kiwoom_gap.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

328 lines
12 KiB
Python

#!/usr/bin/env python3
"""
scripts/test_ws_tick_kis_kiwoom_gap.py
=====================================
한투(kis) vs 키움(kiwoom) ``ws_ticks`` 밀도·틱타임/수신갭 비교.
실 WS/주문 호출 없음. DB 조회만.
틱매도 비동기 적용 후 ~1시간 뒤, 매도 시각 전후로
「키움만 비고 한투는 쌓임」이 줄었는지 볼 때 사용.
python3 -u scripts/test_ws_tick_kis_kiwoom_gap.py
python3 -u scripts/test_ws_tick_kis_kiwoom_gap.py --minutes 60
python3 -u scripts/test_ws_tick_kis_kiwoom_gap.py --start '2026-08-19 09:00:00' --end '2026-08-19 10:00:00'
python3 -u scripts/test_ws_tick_kis_kiwoom_gap.py --code 025980 --minutes 60
로그: logs/test_ws_tick_kis_kiwoom_gap_YYYYMMDD_HHMMSS.log
"""
from __future__ import annotations
import argparse
import sys
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _tee_log() -> Path:
log_dir = ROOT / "logs"
log_dir.mkdir(exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = log_dir / ("test_ws_tick_kis_kiwoom_gap_%s.log" % ts)
fp = path.open("w", encoding="utf-8")
class _Tee:
def __init__(self, *files: Any) -> None:
self.files = files
def write(self, s: str) -> None:
for f in self.files:
f.write(s)
f.flush()
def flush(self) -> None:
for f in self.files:
f.flush()
sys.stdout = _Tee(sys.__stdout__, fp) # type: ignore[assignment]
sys.stderr = _Tee(sys.__stderr__, fp) # type: ignore[assignment]
print("로그 파일:", path)
return path
def _cols(db: Any, table: str) -> List[str]:
rows = db.conn.execute("SHOW COLUMNS FROM `%s`" % table).fetchall()
return [str(r["Field"]) for r in rows]
def _parse_ts(raw: Any) -> Optional[datetime]:
s = str(raw or "").strip()
if not s:
return None
for fmt in (
"%Y-%m-%d %H:%M:%S.%f",
"%Y-%m-%d %H:%M:%S",
"%Y%m%d%H%M%S",
):
try:
return datetime.strptime(s[:26] if "." in s else s[:19], fmt)
except Exception:
continue
try:
return datetime.fromisoformat(s.replace("Z", ""))
except Exception:
return None
def _gaps(ts_list: List[datetime]) -> List[float]:
if len(ts_list) < 2:
return []
ordered = sorted(ts_list)
out: List[float] = []
for i in range(1, len(ordered)):
out.append((ordered[i] - ordered[i - 1]).total_seconds())
return out
def _gap_stats(gaps: List[float]) -> Dict[str, Any]:
if not gaps:
return {"n": 0, "max": None, "p50": None, "gt2": 0, "gt5": 0}
sg = sorted(gaps)
n = len(sg)
p50 = sg[n // 2]
return {
"n": n,
"max": round(max(sg), 3),
"p50": round(p50, 3),
"gt2": sum(1 for x in sg if x >= 2.0),
"gt5": sum(1 for x in sg if x >= 5.0),
}
def _pick_time_col(cols: List[str]) -> Optional[str]:
for c in (
"created_at", "updated_at", "order_time", "ord_time",
"submitted_at", "ts", "insert_time",
):
if c in cols:
return c
return None
def main() -> int:
ap = argparse.ArgumentParser(description="kis vs kiwoom ws_ticks 밀도·갭")
ap.add_argument("--minutes", type=int, default=60, help="끝시각 기준 최근 N분 (start/end 없을 때)")
ap.add_argument("--start", default="", help="YYYY-MM-DD HH:MM:SS")
ap.add_argument("--end", default="", help="YYYY-MM-DD HH:MM:SS")
ap.add_argument("--code", default="", help="6자리 종목. 비우면 겹치는 종목 상위")
ap.add_argument("--limit-codes", type=int, default=15, help="겹치는 종목 리포트 개수")
args = ap.parse_args()
now = datetime.now()
if str(args.start or "").strip() and str(args.end or "").strip():
start_s = str(args.start).strip()
end_s = str(args.end).strip()
else:
end_dt = now
start_dt = end_dt - timedelta(minutes=max(1, int(args.minutes or 60)))
start_s = start_dt.strftime("%Y-%m-%d %H:%M:%S")
end_s = end_dt.strftime("%Y-%m-%d %H:%M:%S")
from database import TradeDB
db = TradeDB()
print("구간:", start_s, "~", end_s)
print("code:", args.code or "(겹치는 종목 자동)")
tick_cols = _cols(db, "ws_ticks")
print("SHOW COLUMNS ws_ticks:", tick_cols)
need = {"code", "source", "recv_ts"}
missing = sorted(need - set(tick_cols))
if missing:
print("실패: ws_ticks 컬럼 없음", missing)
return 1
has_tick_time = "tick_time" in tick_cols
# 전체 종목수·행수
tot = db.conn.execute(
"""
SELECT
COUNT(*) n,
SUM(source='kiwoom') n_kw,
SUM(source='kis') n_kis,
COUNT(DISTINCT CASE WHEN source='kiwoom' THEN code END) codes_kw,
COUNT(DISTINCT CASE WHEN source='kis' THEN code END) codes_kis,
COUNT(DISTINCT code) codes_either
FROM ws_ticks
WHERE recv_ts >= %s AND recv_ts < %s AND source IN ('kiwoom','kis')
""",
(start_s, end_s),
).fetchone()
print("\n== 구간 합계 ==")
print(dict(tot or {}))
n_kw = int((tot or {}).get("n_kw") or 0)
n_kis = int((tot or {}).get("n_kis") or 0)
ratio = round(n_kis / n_kw, 3) if n_kw else None
print("kis/kiwoom 행수비:", ratio, "(1에 가까울수록 밀도 비슷. 예전에 ~1.7배면 한투가 더 촘촘)")
split = db.conn.execute(
"""
SELECT
SUM(srcs='kis') only_kis,
SUM(srcs='kiwoom') only_kw,
SUM(srcs='both') both_n
FROM (
SELECT code,
CASE
WHEN SUM(source='kis')>0 AND SUM(source='kiwoom')>0 THEN 'both'
WHEN SUM(source='kis')>0 THEN 'kis'
ELSE 'kiwoom'
END srcs
FROM ws_ticks
WHERE recv_ts >= %s AND recv_ts < %s AND source IN ('kiwoom','kis')
GROUP BY code
) z
""",
(start_s, end_s),
).fetchone()
print("종목 커버 키움만/한투만/둘다:", dict(split or {}))
code_filter = str(args.code or "").strip()
if code_filter:
codes = [code_filter]
else:
rows = db.conn.execute(
"""
SELECT code,
SUM(source='kiwoom') n_kw,
SUM(source='kis') n_kis
FROM ws_ticks
WHERE recv_ts >= %s AND recv_ts < %s AND source IN ('kiwoom','kis')
GROUP BY code
HAVING SUM(source='kiwoom') > 0 AND SUM(source='kis') > 0
ORDER BY (SUM(source='kis') / GREATEST(SUM(source='kiwoom'), 1)) DESC
LIMIT %s
""",
(start_s, end_s, int(args.limit_codes)),
).fetchall()
codes = [str(r["code"]) for r in rows]
print("\n== 겹치는 종목 kis/kw 비 상위 ==")
for r in rows:
d = dict(r)
kw = int(d.get("n_kw") or 0)
ki = int(d.get("n_kis") or 0)
d["kis_per_kw"] = round(ki / kw, 2) if kw else None
print(d)
print("\n== 종목별 recv_ts 갭 (키움 vs 한투) ==")
stall_codes = []
for code in codes:
raw = db.conn.execute(
"""
SELECT source, recv_ts, tick_time
FROM ws_ticks
WHERE code=%s AND recv_ts >= %s AND recv_ts < %s
AND source IN ('kiwoom','kis')
ORDER BY recv_ts
""",
(code, start_s, end_s),
).fetchall() if has_tick_time else db.conn.execute(
"""
SELECT source, recv_ts
FROM ws_ticks
WHERE code=%s AND recv_ts >= %s AND recv_ts < %s
AND source IN ('kiwoom','kis')
ORDER BY recv_ts
""",
(code, start_s, end_s),
).fetchall()
by_src: Dict[str, List[datetime]] = {"kiwoom": [], "kis": []}
by_fid: Dict[str, List[str]] = {"kiwoom": [], "kis": []}
for r in raw:
src = str(r.get("source") or "")
dtv = _parse_ts(r.get("recv_ts"))
if src in by_src and dtv is not None:
by_src[src].append(dtv)
if has_tick_time and src in by_fid:
by_fid[src].append(str(r.get("tick_time") or ""))
kw_g = _gap_stats(_gaps(by_src["kiwoom"]))
ki_g = _gap_stats(_gaps(by_src["kis"]))
rec = {
"code": code,
"n_kw": len(by_src["kiwoom"]),
"n_kis": len(by_src["kis"]),
"kis_per_kw": round(len(by_src["kis"]) / len(by_src["kiwoom"]), 2) if by_src["kiwoom"] else None,
"kw_recv_gap": kw_g,
"kis_recv_gap": ki_g,
}
# 키움 max gap 이 한투보다 훨씬 크면 수신 스톨 후보
kw_max = kw_g.get("max")
ki_max = ki_g.get("max")
if kw_max is not None and ki_max is not None and kw_max >= 5.0 and kw_max > (ki_max * 2.0 + 1.0):
rec["stall_candidate"] = True
stall_codes.append(rec)
print(rec)
if has_tick_time and (args.code or rec.get("stall_candidate")):
# FID tick_time 이 같은 초로 밀집 vs recv 공백
print(" tick_time 샘플 kw", by_fid["kiwoom"][:3], "...", by_fid["kiwoom"][-3:] if by_fid["kiwoom"] else [])
print(" tick_time 샘플 kis", by_fid["kis"][:3], "...", by_fid["kis"][-3:] if by_fid["kis"] else [])
print("\n== 스톨 후보 (키움 recv 갭>=5s 이고 한투 갭의 2배+1 초과) ==")
if not stall_codes:
print("(없음)")
else:
for r in stall_codes:
print(r)
# 매도 시각 근처
try:
ocols = _cols(db, "orders")
print("\nSHOW COLUMNS orders:", ocols)
tcol = _pick_time_col(ocols)
side_col = "side" if "side" in ocols else ("ord_side" if "ord_side" in ocols else None)
code_col = "code" if "code" in ocols else None
if tcol and side_col and code_col:
for c in (tcol, side_col, code_col):
if not str(c).replace("_", "").isalnum():
raise ValueError("bad col %s" % c)
sql = (
"SELECT `" + tcol + "` AS ts, `" + code_col + "` AS code, `"
+ side_col + "` AS side FROM orders WHERE `" + tcol
+ "` >= %s AND `" + tcol + "` < %s"
)
sells = db.conn.execute(sql, (start_s, end_s)).fetchall()
sell_rows = [dict(r) for r in sells if str(r.get("side") or "").upper() in ("SELL", "01", "sell")]
if not sell_rows:
sell_rows = [dict(r) for r in sells]
print("orders 구간 행", len(sells), "매도후보", len(sell_rows), "시각컬럼", tcol)
for r in sell_rows[:20]:
print(" ", r)
else:
print("orders 에서 시각/side/code 컬럼을 못 찾음 — 매도 교차는 스킵")
except Exception as ex:
print("orders 조회 스킵:", ex)
print("\n판독 힌트:")
print("- kis/kw 행수비가 1.5~2+ 이고 키움만 max recv 갭이 크면, 예전처럼 키움 수신이 멈춘 패턴.")
print("- 틱매도 비동기 적용 후 매도 시각 전후 키움 갭이 한투와 비슷해지면 1~5 효과.")
print("- 한투만 종목이 많으면 구독 유니버스 차이(41 vs 100)이지 스톨이 아님.")
print("로그 파일은 이 실행 stdout 과 동일")
return 0
if __name__ == "__main__":
_tee_log()
try:
raise SystemExit(main())
except SystemExit:
raise
except Exception:
import traceback
traceback.print_exc()
raise SystemExit(1)